a1f6701bac
- gRPC service with MusicBrainz provider - PostgreSQL schema with migrations - Service layer with database-first caching - Repository pattern for data access - YAML configuration support - Research documentation for 17 music metadata projects
742 lines
18 KiB
Markdown
742 lines
18 KiB
Markdown
# GraphBrainz Codebase
|
|
|
|
## Configuration System
|
|
|
|
GraphBrainz uses environment variables for all configuration.
|
|
|
|
### Core Configuration
|
|
|
|
| Variable | Type | Default | Purpose |
|
|
|----------|------|---------|---------|
|
|
| NODE_ENV | string | development | Environment mode |
|
|
| PORT | number | 3000 | Server port |
|
|
| GRAPHBRAINZ_PATH | string | / | GraphQL endpoint path |
|
|
| GRAPHBRAINZ_CORS_ORIGIN | string/boolean | false | CORS origin (false, *, or URL) |
|
|
| GRAPHBRAINZ_GRAPHIQL | boolean | true (dev) | Enable GraphiQL interface |
|
|
| GRAPHBRAINZ_EXTENSIONS | string | - | Comma-separated extension list |
|
|
|
|
### Cache Configuration
|
|
|
|
| Variable | Type | Default | Purpose |
|
|
|----------|------|---------|---------|
|
|
| GRAPHBRAINZ_CACHE_SIZE | number | 8192 | LRU cache max items |
|
|
| GRAPHBRAINZ_CACHE_TTL | number | 86400000 | Cache TTL in milliseconds (1 day) |
|
|
|
|
### MusicBrainz Configuration
|
|
|
|
| Variable | Type | Default | Purpose |
|
|
|----------|------|---------|---------|
|
|
| MUSICBRAINZ_BASE_URL | string | http://musicbrainz.org/ws/2/ | MusicBrainz API endpoint |
|
|
|
|
### Extension Configuration
|
|
|
|
#### Cover Art Archive
|
|
|
|
| Variable | Type | Default | Purpose |
|
|
|----------|------|---------|---------|
|
|
| COVERART_CACHE_SIZE | number | 8192 | LRU cache max items |
|
|
| COVERART_CACHE_TTL | number | 86400000 | Cache TTL in milliseconds |
|
|
|
|
#### fanart.tv
|
|
|
|
| Variable | Type | Default | Purpose |
|
|
|----------|------|---------|---------|
|
|
| FANART_API_KEY | string | - | API authentication (required) |
|
|
| FANART_CACHE_SIZE | number | 8192 | LRU cache max items |
|
|
| FANART_CACHE_TTL | number | 86400000 | Cache TTL in milliseconds |
|
|
|
|
#### MediaWiki
|
|
|
|
| Variable | Type | Default | Purpose |
|
|
|----------|------|---------|---------|
|
|
| MEDIAWIKI_CACHE_SIZE | number | 8192 | LRU cache max items |
|
|
| MEDIAWIKI_CACHE_TTL | number | 86400000 | Cache TTL in milliseconds |
|
|
|
|
#### TheAudioDB
|
|
|
|
| Variable | Type | Default | Purpose |
|
|
|----------|------|---------|---------|
|
|
| THEAUDIODB_API_KEY | string | - | API authentication (required) |
|
|
| THEAUDIODB_CACHE_SIZE | number | 8192 | LRU cache max items |
|
|
| THEAUDIODB_CACHE_TTL | number | 86400000 | Cache TTL in milliseconds |
|
|
|
|
### Configuration Loading
|
|
|
|
**File**: `src/config.js`
|
|
|
|
```javascript
|
|
import dotenv from 'dotenv';
|
|
|
|
dotenv.config();
|
|
|
|
export default {
|
|
port: parseInt(process.env.PORT, 10) || 3000,
|
|
path: process.env.GRAPHBRAINZ_PATH || '/',
|
|
corsOrigin: process.env.GRAPHBRAINZ_CORS_ORIGIN === 'false'
|
|
? false
|
|
: process.env.GRAPHBRAINZ_CORS_ORIGIN || false,
|
|
graphiql: process.env.GRAPHBRAINZ_GRAPHIQL === 'true'
|
|
|| process.env.NODE_ENV === 'development',
|
|
extensions: process.env.GRAPHBRAINZ_EXTENSIONS
|
|
? process.env.GRAPHBRAINZ_EXTENSIONS.split(',')
|
|
: [],
|
|
cache: {
|
|
size: parseInt(process.env.GRAPHBRAINZ_CACHE_SIZE, 10) || 8192,
|
|
ttl: parseInt(process.env.GRAPHBRAINZ_CACHE_TTL, 10) || 86400000
|
|
},
|
|
musicbrainz: {
|
|
baseURL: process.env.MUSICBRAINZ_BASE_URL || 'http://musicbrainz.org/ws/2/'
|
|
}
|
|
};
|
|
```
|
|
|
|
## Logging System
|
|
|
|
GraphBrainz uses the `debug` package for namespace-based logging.
|
|
|
|
### Debug Namespaces
|
|
|
|
| Namespace | Purpose | Location |
|
|
|-----------|---------|----------|
|
|
| graphbrainz:schema | Schema construction | src/schema.js |
|
|
| graphbrainz:context | Context creation | src/context.js |
|
|
| graphbrainz:loaders | DataLoader operations | src/loaders/*.js |
|
|
| graphbrainz:rate-limit | Rate limiter activity | src/rate-limit.js |
|
|
| graphbrainz:api/client | HTTP requests | src/client.js |
|
|
| graphbrainz:extensions:coverart | Cover Art Archive | src/extensions/cover-art-archive/ |
|
|
| graphbrainz:extensions:fanart | fanart.tv | src/extensions/fanart/ |
|
|
| graphbrainz:extensions:mediawiki | MediaWiki | src/extensions/mediawiki/ |
|
|
| graphbrainz:extensions:theaudiodb | TheAudioDB | src/extensions/theaudiodb/ |
|
|
|
|
### Enabling Debug Logging
|
|
|
|
**All Namespaces**:
|
|
```bash
|
|
DEBUG=graphbrainz:* node cli.js
|
|
```
|
|
|
|
**Specific Namespace**:
|
|
```bash
|
|
DEBUG=graphbrainz:api/client node cli.js
|
|
```
|
|
|
|
**Multiple Namespaces**:
|
|
```bash
|
|
DEBUG=graphbrainz:schema,graphbrainz:loaders node cli.js
|
|
```
|
|
|
|
**Exclude Namespaces**:
|
|
```bash
|
|
DEBUG=graphbrainz:*,-graphbrainz:api/client node cli.js
|
|
```
|
|
|
|
### Debug Output Format
|
|
|
|
```
|
|
graphbrainz:api/client GET http://musicbrainz.org/ws/2/artist/5b11f4ce-a62d-471e-81fc-a69a8278c7da +0ms
|
|
graphbrainz:loaders Artist loader: batching 3 requests +5ms
|
|
graphbrainz:rate-limit Acquired token (4 remaining) +10ms
|
|
graphbrainz:extensions:fanart GET http://webservice.fanart.tv/v3/music/5b11f4ce-a62d-471e-81fc-a69a8278c7da +150ms
|
|
```
|
|
|
|
### Implementation
|
|
|
|
**File**: `src/client.js`
|
|
|
|
```javascript
|
|
import debug from 'debug';
|
|
|
|
const log = debug('graphbrainz:api/client');
|
|
|
|
class Client {
|
|
async get(url, options) {
|
|
log(`GET ${url}`);
|
|
const response = await this.client.get(url, options);
|
|
log(`Response: ${response.statusCode}`);
|
|
return response;
|
|
}
|
|
}
|
|
```
|
|
|
|
## Error Handling
|
|
|
|
GraphBrainz implements custom error classes for different failure modes.
|
|
|
|
### Error Class Hierarchy
|
|
|
|
```
|
|
Error (built-in)
|
|
├── GraphBrainzError (base)
|
|
│ ├── MusicBrainzError
|
|
│ ├── CoverArtArchiveError
|
|
│ ├── FanArtError
|
|
│ ├── MediaWikiError
|
|
│ └── TheAudioDBError
|
|
└── ValidationError
|
|
```
|
|
|
|
### Custom Error Classes
|
|
|
|
**File**: `src/errors.js`
|
|
|
|
```javascript
|
|
import ExtendableError from 'es6-error';
|
|
|
|
export class GraphBrainzError extends ExtendableError {
|
|
constructor(message, statusCode) {
|
|
super(message);
|
|
this.statusCode = statusCode;
|
|
}
|
|
}
|
|
|
|
export class MusicBrainzError extends GraphBrainzError {
|
|
constructor(message, statusCode) {
|
|
super(message, statusCode);
|
|
this.name = 'MusicBrainzError';
|
|
}
|
|
}
|
|
|
|
export class FanArtError extends GraphBrainzError {
|
|
constructor(message, statusCode) {
|
|
super(message, statusCode);
|
|
this.name = 'FanArtError';
|
|
}
|
|
}
|
|
|
|
export class TheAudioDBError extends GraphBrainzError {
|
|
constructor(message, statusCode) {
|
|
super(message, statusCode);
|
|
this.name = 'TheAudioDBError';
|
|
}
|
|
}
|
|
|
|
export class CoverArtArchiveError extends GraphBrainzError {
|
|
constructor(message, statusCode) {
|
|
super(message, statusCode);
|
|
this.name = 'CoverArtArchiveError';
|
|
}
|
|
}
|
|
|
|
export class ValidationError extends GraphBrainzError {
|
|
constructor(message) {
|
|
super(message, 400);
|
|
this.name = 'ValidationError';
|
|
}
|
|
}
|
|
```
|
|
|
|
### Error Handling in Resolvers
|
|
|
|
```javascript
|
|
async function resolveArtist(parent, args, context) {
|
|
try {
|
|
return await context.loaders.artist.load(args.mbid);
|
|
} catch (error) {
|
|
if (error.statusCode === 404) {
|
|
return null; // Artist not found
|
|
}
|
|
throw new MusicBrainzError(
|
|
`Failed to fetch artist: ${error.message}`,
|
|
error.statusCode
|
|
);
|
|
}
|
|
}
|
|
```
|
|
|
|
### Scalar Validation Errors
|
|
|
|
**File**: `src/scalars.js`
|
|
|
|
```javascript
|
|
import { GraphQLScalarType } from 'graphql';
|
|
import { ValidationError } from './errors.js';
|
|
|
|
export const MBID = new GraphQLScalarType({
|
|
name: 'MBID',
|
|
description: 'MusicBrainz ID (UUID format)',
|
|
|
|
serialize(value) {
|
|
return value;
|
|
},
|
|
|
|
parseValue(value) {
|
|
if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(value)) {
|
|
throw new ValidationError(`Invalid MBID format: ${value}`);
|
|
}
|
|
return value;
|
|
},
|
|
|
|
parseLiteral(ast) {
|
|
if (ast.kind !== 'StringValue') {
|
|
throw new ValidationError('MBID must be a string');
|
|
}
|
|
return this.parseValue(ast.value);
|
|
}
|
|
});
|
|
```
|
|
|
|
### GraphQL Error Formatting
|
|
|
|
**File**: `src/index.js`
|
|
|
|
```javascript
|
|
import { formatError } from 'graphql';
|
|
|
|
function customFormatError(error) {
|
|
const formatted = formatError(error);
|
|
|
|
// Include stack trace in development only
|
|
if (process.env.NODE_ENV === 'development') {
|
|
formatted.stack = error.stack;
|
|
}
|
|
|
|
// Add custom error code
|
|
if (error.originalError) {
|
|
formatted.extensions = {
|
|
...formatted.extensions,
|
|
code: error.originalError.name,
|
|
statusCode: error.originalError.statusCode
|
|
};
|
|
}
|
|
|
|
return formatted;
|
|
}
|
|
|
|
export const middleware = (options) => {
|
|
return expressGraphQL({
|
|
schema,
|
|
context,
|
|
graphiql: options.graphiql,
|
|
customFormatErrorFn: customFormatError
|
|
});
|
|
};
|
|
```
|
|
|
|
### Error Response Format
|
|
|
|
**Development**:
|
|
```json
|
|
{
|
|
"errors": [
|
|
{
|
|
"message": "Failed to fetch artist: Network error",
|
|
"locations": [{ "line": 2, "column": 3 }],
|
|
"path": ["lookup", "artist"],
|
|
"extensions": {
|
|
"code": "MusicBrainzError",
|
|
"statusCode": 503
|
|
},
|
|
"stack": "MusicBrainzError: Failed to fetch artist: Network error\n at resolveArtist (src/resolvers/artist.js:15:11)\n ..."
|
|
}
|
|
],
|
|
"data": null
|
|
}
|
|
```
|
|
|
|
**Production**:
|
|
```json
|
|
{
|
|
"errors": [
|
|
{
|
|
"message": "Failed to fetch artist: Network error",
|
|
"locations": [{ "line": 2, "column": 3 }],
|
|
"path": ["lookup", "artist"],
|
|
"extensions": {
|
|
"code": "MusicBrainzError",
|
|
"statusCode": 503
|
|
}
|
|
}
|
|
],
|
|
"data": null
|
|
}
|
|
```
|
|
|
|
## Testing Infrastructure
|
|
|
|
GraphBrainz uses AVA test framework with ava-nock for HTTP mocking.
|
|
|
|
### Test Framework
|
|
|
|
| Tool | Purpose | Version |
|
|
|------|---------|---------|
|
|
| AVA | Test runner | Latest |
|
|
| ava-nock | HTTP mocking | Latest |
|
|
| c8 | Code coverage | Latest |
|
|
|
|
### Test Configuration
|
|
|
|
**File**: `package.json`
|
|
|
|
```json
|
|
{
|
|
"ava": {
|
|
"files": [
|
|
"test/**/*.test.js"
|
|
],
|
|
"timeout": "30s",
|
|
"verbose": true,
|
|
"require": [
|
|
"dotenv/config"
|
|
]
|
|
}
|
|
}
|
|
```
|
|
|
|
### HTTP Mocking with ava-nock
|
|
|
|
ava-nock provides three modes:
|
|
|
|
| Mode | Purpose | Behavior |
|
|
|------|---------|----------|
|
|
| play | Replay fixtures | Use cached HTTP responses |
|
|
| record | Record fixtures | Make real HTTP requests, save responses |
|
|
| cache | Hybrid | Use cache if available, record if missing |
|
|
|
|
**Configuration**:
|
|
```javascript
|
|
import test from 'ava';
|
|
import nock from 'ava-nock';
|
|
|
|
test.before(() => {
|
|
nock.setupTests({
|
|
mode: 'play', // or 'record', 'cache'
|
|
fixtures: 'test/fixtures'
|
|
});
|
|
});
|
|
```
|
|
|
|
### Test Fixtures
|
|
|
|
**Location**: `test/fixtures/*.nock`
|
|
|
|
**Format**: JSON files containing HTTP request/response pairs
|
|
|
|
**Example**: `test/fixtures/artist-lookup.nock`
|
|
|
|
```json
|
|
[
|
|
{
|
|
"scope": "http://musicbrainz.org:80",
|
|
"method": "GET",
|
|
"path": "/ws/2/artist/5b11f4ce-a62d-471e-81fc-a69a8278c7da?fmt=json",
|
|
"status": 200,
|
|
"response": {
|
|
"id": "5b11f4ce-a62d-471e-81fc-a69a8278c7da",
|
|
"name": "Radiohead",
|
|
"sort-name": "Radiohead",
|
|
"type": "Group",
|
|
"country": "GB"
|
|
}
|
|
}
|
|
]
|
|
```
|
|
|
|
### Test Suite Structure
|
|
|
|
**File**: `test/schema.test.js` (1475+ lines)
|
|
|
|
```javascript
|
|
import test from 'ava';
|
|
import { graphql } from 'graphql';
|
|
import { schema, context } from '../src/index.js';
|
|
|
|
test('lookup artist by MBID', async t => {
|
|
const query = `
|
|
{
|
|
lookup {
|
|
artist(mbid: "5b11f4ce-a62d-471e-81fc-a69a8278c7da") {
|
|
name
|
|
country
|
|
}
|
|
}
|
|
}
|
|
`;
|
|
|
|
const result = await graphql({
|
|
schema,
|
|
source: query,
|
|
contextValue: context
|
|
});
|
|
|
|
t.is(result.errors, undefined);
|
|
t.is(result.data.lookup.artist.name, 'Radiohead');
|
|
t.is(result.data.lookup.artist.country, 'GB');
|
|
});
|
|
|
|
test('browse releases by artist', async t => {
|
|
const query = `
|
|
{
|
|
browse {
|
|
releases(artist: "5b11f4ce-a62d-471e-81fc-a69a8278c7da", first: 5) {
|
|
edges {
|
|
node {
|
|
title
|
|
}
|
|
}
|
|
totalCount
|
|
}
|
|
}
|
|
}
|
|
`;
|
|
|
|
const result = await graphql({
|
|
schema,
|
|
source: query,
|
|
contextValue: context
|
|
});
|
|
|
|
t.is(result.errors, undefined);
|
|
t.true(result.data.browse.releases.edges.length > 0);
|
|
t.true(result.data.browse.releases.totalCount > 0);
|
|
});
|
|
|
|
test('search artists', async t => {
|
|
const query = `
|
|
{
|
|
search {
|
|
artists(query: "artist:Radiohead", first: 5) {
|
|
edges {
|
|
node {
|
|
name
|
|
score
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
`;
|
|
|
|
const result = await graphql({
|
|
schema,
|
|
source: query,
|
|
contextValue: context
|
|
});
|
|
|
|
t.is(result.errors, undefined);
|
|
t.true(result.data.search.artists.edges.length > 0);
|
|
t.is(result.data.search.artists.edges[0].node.name, 'Radiohead');
|
|
});
|
|
```
|
|
|
|
### Extension Tests
|
|
|
|
**File**: `test/extensions.test.js`
|
|
|
|
```javascript
|
|
import test from 'ava';
|
|
import { graphql } from 'graphql';
|
|
import { schema, context } from '../src/index.js';
|
|
|
|
test('Cover Art Archive extension', async t => {
|
|
const query = `
|
|
{
|
|
lookup {
|
|
release(mbid: "f0c8b1e5-c3b6-46c0-9641-25fd3c00e56a") {
|
|
title
|
|
coverArtArchive {
|
|
front
|
|
images {
|
|
image
|
|
thumbnails {
|
|
large
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
`;
|
|
|
|
const result = await graphql({
|
|
schema,
|
|
source: query,
|
|
contextValue: context
|
|
});
|
|
|
|
t.is(result.errors, undefined);
|
|
t.true(result.data.lookup.release.coverArtArchive.front);
|
|
t.true(result.data.lookup.release.coverArtArchive.images.length > 0);
|
|
});
|
|
```
|
|
|
|
### Test Separation
|
|
|
|
GraphBrainz separates tests into two categories:
|
|
|
|
| Test File | Purpose | Lines |
|
|
|-----------|---------|-------|
|
|
| test/base-schema.test.js | Core schema without extensions | ~800 |
|
|
| test/extended-schema.test.js | Schema with all extensions | ~675 |
|
|
|
|
### Coverage Configuration
|
|
|
|
**File**: `package.json`
|
|
|
|
```json
|
|
{
|
|
"scripts": {
|
|
"test": "c8 ava",
|
|
"coverage": "c8 report --reporter=text-lcov > coverage/lcov.info"
|
|
},
|
|
"c8": {
|
|
"include": [
|
|
"src/**/*.js"
|
|
],
|
|
"exclude": [
|
|
"test/**/*.js"
|
|
],
|
|
"reporter": [
|
|
"text",
|
|
"lcov",
|
|
"html"
|
|
],
|
|
"all": true
|
|
}
|
|
}
|
|
```
|
|
|
|
### Coverage Reporting
|
|
|
|
**Services**:
|
|
- Codecov: https://codecov.io/gh/exogen/graphbrainz
|
|
- Coveralls: https://coveralls.io/github/exogen/graphbrainz
|
|
|
|
**Upload**:
|
|
```bash
|
|
npm run coverage
|
|
npx codecov
|
|
npx coveralls < coverage/lcov.info
|
|
```
|
|
|
|
## File Structure
|
|
|
|
```
|
|
graphbrainz/
|
|
├── cli.js # CLI entry point
|
|
├── package.json # NPM package configuration
|
|
├── schema.json # Schema introspection JSON
|
|
├── schema.graphql # Schema SDL
|
|
├── Procfile # Heroku process definition
|
|
├── .travis.yml # Travis CI configuration
|
|
├── .env.example # Example environment variables
|
|
├── src/
|
|
│ ├── index.js # Main module exports
|
|
│ ├── schema.js # Schema construction
|
|
│ ├── context.js # Context factory
|
|
│ ├── config.js # Configuration loading
|
|
│ ├── client.js # Base HTTP client
|
|
│ ├── rate-limit.js # Rate limiter implementation
|
|
│ ├── errors.js # Custom error classes
|
|
│ ├── scalars.js # Custom scalar types
|
|
│ ├── types/ # Entity type definitions
|
|
│ │ ├── area.js
|
|
│ │ ├── artist.js
|
|
│ │ ├── collection.js
|
|
│ │ ├── disc.js
|
|
│ │ ├── event.js
|
|
│ │ ├── instrument.js
|
|
│ │ ├── label.js
|
|
│ │ ├── place.js
|
|
│ │ ├── recording.js
|
|
│ │ ├── release.js
|
|
│ │ ├── release-group.js
|
|
│ │ ├── series.js
|
|
│ │ ├── tag.js
|
|
│ │ ├── track.js
|
|
│ │ ├── url.js
|
|
│ │ ├── work.js
|
|
│ │ └── relationships.js
|
|
│ ├── resolvers/ # Resolver implementations
|
|
│ │ ├── query.js
|
|
│ │ └── subquery.js
|
|
│ ├── loaders/ # DataLoader batch functions
|
|
│ │ └── musicbrainz.js
|
|
│ └── extensions/ # Built-in extensions
|
|
│ ├── cover-art-archive/
|
|
│ │ ├── index.js
|
|
│ │ ├── client.js
|
|
│ │ └── schema.js
|
|
│ ├── fanart/
|
|
│ │ ├── index.js
|
|
│ │ ├── client.js
|
|
│ │ └── schema.js
|
|
│ ├── mediawiki/
|
|
│ │ ├── index.js
|
|
│ │ ├── client.js
|
|
│ │ └── schema.js
|
|
│ └── theaudiodb/
|
|
│ ├── index.js
|
|
│ ├── client.js
|
|
│ └── schema.js
|
|
├── test/
|
|
│ ├── base-schema.test.js # Core schema tests (~800 lines)
|
|
│ ├── extended-schema.test.js # Extension tests (~675 lines)
|
|
│ └── fixtures/ # HTTP mock fixtures
|
|
│ ├── artist-lookup.nock
|
|
│ ├── release-browse.nock
|
|
│ ├── artist-search.nock
|
|
│ └── ...
|
|
├── scripts/
|
|
│ ├── deploy.sh # Heroku deployment script
|
|
│ ├── generate-readme-toc.js # README table of contents
|
|
│ ├── generate-schema-docs.js # Schema documentation
|
|
│ ├── generate-type-docs.js # Type documentation
|
|
│ └── generate-extension-docs.js # Extension documentation
|
|
├── docs/ # Generated documentation
|
|
│ ├── schema.md
|
|
│ ├── types.md
|
|
│ └── extensions.md
|
|
└── coverage/ # Code coverage reports
|
|
├── lcov.info
|
|
└── index.html
|
|
```
|
|
|
|
## Code Metrics
|
|
|
|
| Metric | Value |
|
|
|--------|-------|
|
|
| Total Lines | ~5000 |
|
|
| Entity Types | 17 |
|
|
| Type Definitions | ~2000 lines |
|
|
| Test Suite | 1475+ lines |
|
|
| Extensions | 4 built-in |
|
|
| Dependencies | 10 core |
|
|
|
|
## No Metrics/APM
|
|
|
|
GraphBrainz does not include:
|
|
|
|
- Prometheus metrics
|
|
- StatsD integration
|
|
- APM (Application Performance Monitoring)
|
|
- Health check endpoints
|
|
- Readiness probes
|
|
- Liveness probes
|
|
|
|
These would need to be added for production observability.
|
|
|
|
## No Structured Logging
|
|
|
|
GraphBrainz uses `debug` package for logging, which is:
|
|
|
|
- Namespace-based (good)
|
|
- Opt-in via DEBUG env var (good)
|
|
- Plain text output (not structured)
|
|
- No log levels (only on/off per namespace)
|
|
- No log aggregation support
|
|
|
|
For production, consider migrating to structured logging:
|
|
|
|
```javascript
|
|
import pino from 'pino';
|
|
|
|
const logger = pino({
|
|
level: process.env.LOG_LEVEL || 'info',
|
|
formatters: {
|
|
level: (label) => ({ level: label })
|
|
}
|
|
});
|
|
|
|
logger.info({ mbid: '...', duration: 150 }, 'Artist lookup completed');
|
|
```
|