Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 20 additions & 2 deletions packages/js-logger/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,12 +51,30 @@ For more detailed usage check the [pino documentation](https://github.com/pinojs
| opentelemetryOptions.url | string | `http://localhost:4317` | OpenTelemetry gRPC collector URL, for example an Alloy OTLP endpoint. |
| opentelemetryOptions.resourceAttributes | object | undefined | Extra resource attributes merged into the emitted OpenTelemetry resource. |

The second argument controls the output destination and defaults to standard output (`1`).
The second argument controls the output destination - either a file path or a file descriptor number. It defaults to standard output (`1`).

Once created, the logger emits a `logger initialized` message at `debug` level summarizing the settings it started with: level, `prettyPrint`, `pinoCaller`, and whether OpenTelemetry is enabled along with its URL.

## OpenTelemetry log support

When `opentelemetryOptions.enabled` is `true`, `js-logger` switches to `pino-opentelemetry-transport` and populates resource attributes from:
When `opentelemetryOptions.enabled` is `true`, `js-logger` adds `pino-opentelemetry-transport` alongside the regular destination, so logs are still written locally as well as exported. Resource attributes are populated from:

- the current package name and version
- detected container metadata
- `process.env.K8S_POD_UID`
- any `resourceAttributes` you pass, which override the detected values

```typescript
import { jsLogger } from '@map-colonies/js-logger';

const logger = await jsLogger({
level: 'info',
opentelemetryOptions: {
enabled: true,
url: 'http://otel-collector:4317',
resourceAttributes: { 'deployment.environment': 'production' },
},
});
```

Note that with OpenTelemetry enabled the `level` field is emitted as its numeric pino value (`30`) rather than the label (`"info"`), since both the OpenTelemetry transport and the routing between the two targets rely on numeric levels.
52 changes: 33 additions & 19 deletions packages/js-logger/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
type LoggerOptions as PinoOptions,
type Logger,
type TransportSingleOptions,
type TransportMultiOptions,
transport as pinoTransport,
type DestinationStream,
} from 'pino';
Expand Down Expand Up @@ -72,6 +73,8 @@ interface LoggerOptions {
};
}

const DEFAULT_OTEL_URL = 'http://localhost:4317';

const baseOptions: PinoOptions = {
formatters: {
level(label): Record<string, string> {
Expand All @@ -89,16 +92,22 @@ const baseOptions: PinoOptions = {
* @public
*/
export async function jsLogger(options?: LoggerOptions, destination: string | number = 1): Promise<Logger> {
let transport: TransportSingleOptions = { target: 'pino/file', options: { destination } };
// captured before the deletes below remove them from options
const prettyPrintEnabled = options?.prettyPrint === true;
const otelEnabled = options?.opentelemetryOptions?.enabled === true;
const otelUrl = options?.opentelemetryOptions?.url ?? DEFAULT_OTEL_URL;

/* istanbul ignore next */
if (options?.prettyPrint === true) {
transport = { target: 'pino-pretty' };

if (prettyPrintEnabled) {
delete options.prettyPrint;
}

if (options?.opentelemetryOptions?.enabled === true) {
// where logs are written locally. when otel is enabled this becomes the second target alongside it.
const localTransport: TransportSingleOptions = prettyPrintEnabled ? { target: 'pino-pretty' } : { target: 'pino/file', options: { destination } };

let transportOptions: TransportSingleOptions | TransportMultiOptions = localTransport;

if (otelEnabled) {
const pkg = readPackageJsonSync();

const detectedResources = detectResources({ detectors: [containerDetector] });
Expand All @@ -112,33 +121,38 @@ export async function jsLogger(options?: LoggerOptions, destination: string | nu
[ATTR_SERVICE_NAME]: pkg.name,
[ATTR_SERVICE_VERSION]: pkg.version,
[ATTR_K8S_POD_UID]: process.env.K8S_POD_UID,
...options.opentelemetryOptions.resourceAttributes,
...options.opentelemetryOptions?.resourceAttributes,
},
logRecordProcessorOptions: [
{
recordProcessorType: 'simple',
exporterOptions: {
protocol: 'console',
},
},
{
recordProcessorType: 'batch',
exporterOptions: { protocol: 'grpc', grpcExporterOptions: { url: options.opentelemetryOptions.url ?? 'http://localhost:4317' } },
exporterOptions: { protocol: 'grpc', grpcExporterOptions: { url: otelUrl } },
},
],
};
transport = { target: 'pino-opentelemetry-transport', options: otelOptions };

// routing between multiple targets happens by level in a worker, which requires the numeric levels left in place by
// dropping the formatter - so this must stay limited to the otel path.
delete baseOptions.formatters;

transportOptions = { targets: [{ target: 'pino-opentelemetry-transport', options: otelOptions }, localTransport] };
}

const pinoOptions: PinoOptions = { ...baseOptions, ...options };
const logger = pino(pinoOptions, pinoTransport(transport) as DestinationStream);
const logger = pino(pinoOptions, pinoTransport(transportOptions) as DestinationStream);

if (options?.pinoCaller === true) {
return pinoCaller(logger);
}
const finalLogger = options?.pinoCaller === true ? pinoCaller(logger) : logger;

finalLogger.debug({
msg: 'logger initialized',
level: pinoOptions.level ?? 'info',

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

collides with pino's reserved level key
it seems it will print twice "level" key
here is generated script that demonstrate it

/**
 * Demonstrates finding #2 from pr-261-js-logger-async-init-otel.md:
 * the "logger initialized" debug line in src/index.ts sets a `level`
 * property on the merging object, which collides with pino's own
 * reserved `level` output key.
 *
 * Run from packages/js-logger:
 *   node scripts/demo-level-key-collision.js
 */
const pino = require('pino');

console.log('--- raw pino: what happens when you merge an object with its own "level" key ---\n');

const logger = pino({ level: 'debug' });

// This mirrors exactly what src/index.ts does:
//   finalLogger.debug({ msg: 'logger initialized', level: pinoOptions.level ?? 'info', ... })
logger.debug({ msg: 'logger initialized', level: 'info', otelEnabled: true });

console.log('\n--- look at the raw line above: count the "level" keys ---');
console.log('there are two: pino\'s real numeric level (20 = debug), and the fake string "info"\n');

console.log('--- now show what a JSON consumer actually sees after parsing ---\n');

// Capture what pino would actually write, then parse it like a log shipper would.
const chunks = [];
const captured = pino({ level: 'debug' }, { write: (chunk) => chunks.push(chunk) });
captured.debug({ msg: 'logger initialized', level: 'info', otelEnabled: true });

const rawLine = chunks.join('');
const parsed = JSON.parse(rawLine);

console.log('raw line written by pino:');
console.log(rawLine);
console.log('\nafter JSON.parse (what Loki/ELK/Datadog/etc. would ingest):');
console.log(parsed);
console.log(
  `\n=> parsed.level is ${JSON.stringify(parsed.level)} (a string), the real numeric severity (20) is gone.`,
);

prettyPrint: prettyPrintEnabled,
pinoCaller: options?.pinoCaller === true,
otelEnabled,
otelUrl: otelEnabled ? otelUrl : undefined,
});

return logger;
return finalLogger;
}

export type { Logger } from 'pino';
Expand Down