Skip to content

Commit 055ef41

Browse files
trentmhectorhdzgpichlermarc
authored
feat(instrumentation-pino): add log sending to Logs Bridge API (#2249)
* feat(instrumentation-pino): add log sending to Logs Bridge API * refactor tests (mostly from separate #2247 PR); lint:fix; some in-progress changes * remove some old dev/debug code * feat!: make it so re-enabled instr after creating a logger will NOT change behaviour for that logger This removes functionality that was there before, so technically could be breaking. The motivation is to have the contract of pino instrumentation be cleaner. With this change a pino Logger instance will not be touched if the PinoInstrumentation is disabled. I.e. we are hands-off as much as possible when disabled. Before this change, even when disabled, the instrumentation would tweak the pino Logger config to have a no-op mixin. If the instrumentation was later enabled, then the mixin would become active (adding trace_id et al fields in a span context). The coming "log sending" to the Logs Bridge API will *not* support this "work if instrumentation is re-enabled later", so I think it is clearer if neither "log sending" nor "log correlation" support this. We can back this out if we think it is important to support a possible future feature of the SDK doing live enabling/disabling of individual instrumentations. * impl disableLogCorrelation config; undo the previous commit so that log-correlation *will* follow the live instr enable/disable state * log sending: first tests; change impl to use pino.multistream * edge case tests; almost complete * more tests and a fix for 'useOnlyCustomLevels: true' usage * lint:fix * refactor some code out to utils file * add some internal docs * update readme * fix lint * avoid a possible flaky error if using pino 'unixTime' and logging in the first half-second since process start Effectively the issue is that this sometimes returns true: node -e 'console.log(Math.round(Date.now() / 1e3) * 1e3 < performance.timeOrigin)' * limit log-sending to pino@7 and later because that's when pino.multistream was added * lint:fix * discuss pino-opentelemetry-transport alternative * fix a mis-merge * update changed deps to their new latest * typo in README Co-authored-by: Hector Hernandez <[email protected]> --------- Co-authored-by: Hector Hernandez <[email protected]> Co-authored-by: Marc Pichler <[email protected]>
1 parent d9d558f commit 055ef41

File tree

7 files changed

+784
-72
lines changed

7 files changed

+784
-72
lines changed

package-lock.json

Lines changed: 6 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

plugins/node/opentelemetry-instrumentation-pino/README.md

Lines changed: 73 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
[![NPM Published Version][npm-img]][npm-url]
44
[![Apache License][license-image]][license-image]
55

6-
This module provides automatic instrumentation for injection of trace context for the [`pino`](https://www.npmjs.com/package/pino) module, which may be loaded using the [`@opentelemetry/sdk-trace-node`](https://github.com/open-telemetry/opentelemetry-js/tree/main/packages/opentelemetry-sdk-trace-node) package and is included in the [`@opentelemetry/auto-instrumentations-node`](https://www.npmjs.com/package/@opentelemetry/auto-instrumentations-node) bundle.
6+
This module provides automatic instrumentation of the [`pino`](https://www.npmjs.com/package/pino) module to inject trace-context into Pino log records (log correlation) and to send Pino logging to the OpenTelemetry Logging SDK (log sending). It may be loaded using the [`@opentelemetry/sdk-trace-node`](https://github.com/open-telemetry/opentelemetry-js/tree/main/packages/opentelemetry-sdk-trace-node) package and is included in the [`@opentelemetry/auto-instrumentations-node`](https://www.npmjs.com/package/@opentelemetry/auto-instrumentations-node) bundle.
77

88
If total installation size is not constrained, it is recommended to use the [`@opentelemetry/auto-instrumentations-node`](https://www.npmjs.com/package/@opentelemetry/auto-instrumentations-node) bundle with [@opentelemetry/sdk-node](`https://www.npmjs.com/package/@opentelemetry/sdk-node`) for the most seamless instrumentation experience.
99

@@ -15,60 +15,105 @@ Compatible with OpenTelemetry JS API and SDK `1.0+`.
1515
npm install --save @opentelemetry/instrumentation-pino
1616
```
1717

18-
### Supported Versions
18+
## Supported Versions
1919

2020
- [`pino`](https://www.npmjs.com/package/pino) versions `>=5.14.0 <10`
21+
- The "log sending" feature is only supported in pino v7 and later.
2122

2223
## Usage
2324

2425
```js
25-
const { NodeTracerProvider } = require('@opentelemetry/sdk-trace-node');
26+
const { NodeSDK, tracing, logs, api } = require('@opentelemetry/sdk-node');
2627
const { PinoInstrumentation } = require('@opentelemetry/instrumentation-pino');
27-
const { registerInstrumentations } = require('@opentelemetry/instrumentation');
28-
29-
const provider = new NodeTracerProvider();
30-
provider.register();
31-
32-
registerInstrumentations({
28+
const sdk = new NodeSDK({
29+
spanProcessor: new tracing.SimpleSpanProcessor(new tracing.ConsoleSpanExporter()),
30+
logRecordProcessor: new logs.SimpleLogRecordProcessor(new logs.ConsoleLogRecordExporter()),
3331
instrumentations: [
3432
new PinoInstrumentation({
35-
// Optional hook to insert additional context to log object.
36-
logHook: (span, record, level) => {
37-
record['resource.service.name'] =
38-
provider.resource.attributes['service.name'];
39-
},
40-
// Log span context under custom keys
41-
// This is optional, and will default to "trace_id", "span_id" and "trace_flags" as the keys
42-
logKeys: {
43-
traceId: 'traceId',
44-
spanId: 'spanId',
45-
traceFlags: 'traceFlags',
46-
},
33+
// See below for Pino instrumentation options.
4734
}),
48-
// other instrumentations
49-
],
50-
});
35+
]
36+
})
37+
sdk.start();
5138

5239
const pino = require('pino');
5340
const logger = pino();
54-
logger.info('foobar');
55-
// {"msg":"foobar","trace_id":"fc30029f30df383a4090d3189fe0ffdf","span_id":"625fa861d19d1056","trace_flags":"01", ...}
41+
42+
logger.info('hi');
43+
// 1. Log records will be sent to the SDK-registered log record processor, if any.
44+
// This is called "log sending".
45+
46+
const tracer = api.trace.getTracer('example');
47+
tracer.startActiveSpan('manual-span', span => {
48+
logger.info('in a span');
49+
// 2. Fields identifying the current span will be added to log records:
50+
// {"level":30,...,"msg":"in a span","trace_id":"d61b4e4af1032e0aae279d12f3ab0159","span_id":"d140da862204f2a2","trace_flags":"01"}
51+
// This feature is called "log correlation".
52+
});
5653
```
5754

58-
### Fields added to pino log objects
55+
### Log sending
56+
57+
Creation of a Pino Logger will automatically add a [Pino destination](https://getpino.io/#/docs/api?id=pinooptions-destination-gt-logger) that sends log records to the OpenTelemetry Logs SDK. The OpenTelemetry SDK can be configured to handle those records -- for example, sending them on to an OpenTelemetry collector for log archiving and processing. The example above shows a minimal configuration that emits OpenTelemetry log records to the console for debugging.
58+
59+
If the OpenTelemetry SDK is not configured with a Logger provider, then this added destination will be a no-op.
60+
61+
Log sending can be disabled with the `disableLogSending: true` option.
5962

60-
For the current active span, the following fields are injected. These field names can be optionally configured via `logKeys` in the PinoInstrumentation config:
63+
### Log correlation
64+
65+
Pino logger calls in the context of a tracing span will have fields identifying the span added to the log record. This allows [correlating](https://opentelemetry.io/docs/specs/otel/logs/#log-correlation) log records with tracing data. The added fields are ([spec](https://opentelemetry.io/docs/specs/otel/compatibility/logging_trace_context/)):
6166

6267
- `trace_id`
6368
- `span_id`
6469
- `trace_flags`
6570

71+
These field names can optionally be configured via the `logKeys` option. For example:
72+
73+
```js
74+
new PinoInstrumentation({
75+
logKeys: {
76+
traceId: 'myTraceId',
77+
spanId: 'mySpanId',
78+
traceFlags: 'myTraceFlags',
79+
},
80+
}),
81+
```
82+
83+
After adding these fields, the optional `logHook` is called to allow injecting additional fields. For example:
84+
85+
```js
86+
logHook: (span, record) => {
87+
record['resource.service.name'] = provider.resource.attributes['service.name'];
88+
}
89+
```
90+
6691
When no span context is active or the span context is invalid, injection is skipped.
92+
Log injection can be disabled with the `disableLogCorrelation: true` option.
93+
94+
### Pino instrumentation options
95+
96+
| Option | Type | Description |
97+
| ----------------------- | ----------------- | ----------- |
98+
| `disableLogSending` | `boolean` | Whether to disable [log sending](#log-sending). Default `false`. |
99+
| `disableLogCorrelation` | `boolean` | Whether to disable [log correlation](#log-correlation). Default `false`. |
100+
| `logKeys` | record | A record with keys `traceId`, `spanId`, and `traceFlags` string fields giving the field names to use for log-correlation span data. |
101+
| `logHook` | `LogHookFunction` | An option hook to inject additional context to a log record after trace-context has been added. This requires `disableLogCorrelation` to be false. |
67102

68103
## Semantic Conventions
69104

70105
This package does not currently generate any attributes from semantic conventions.
71106

107+
## Alternative log sending with `pino-opentelemetry-transport`
108+
109+
A possible alternative to the "log sending" support provided by this instrumentation is the [`pino-opentelemetry-transport` package](https://github.com/pinojs/pino-opentelemetry-transport).
110+
111+
Every Pino logger has an output ["destination"](https://getpino.io/#/docs/api?id=destination), for example, a file or stdout. Since v7, Pino includes support for ["transports"](https://getpino.io/#/docs/transports), a type of destination that uses a [worker thread](https://nodejs.org/api/worker_threads.html) to run the transport code. When calling `logger.info("hi")`, Pino serializes the log record to a JSON string, [sends that string to the worker](https://nodejs.org/api/worker_threads.html#workerpostmessagevalue-transferlist) for it to be handled.
112+
113+
The `pino-opentelemetry-transport` package uses this mechanism. It starts an OpenTelemetry SDK `LoggerProvider` in the worker thread, parses each log record string, translates it into the OpenTelemetry Logs data model and sends it. Note that this `LoggerProvider` is independent of any OpenTelemetry SDK components in the main thread.
114+
115+
The log sending support in this instrumentation works on the main thread and uses the OpenTelemetry SDK configured in the main thread. Otherwise the two mechanisms are very similar. Note that because they are maintained separately, there might be small differences in how Pino log records are translated into the OpenTelemetry Logs data model.
116+
72117
## Useful links
73118

74119
- For more information on OpenTelemetry, visit: <https://opentelemetry.io/>

plugins/node/opentelemetry-instrumentation-pino/package.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@
4949
"@opentelemetry/contrib-test-utils": "^0.40.0",
5050
"@opentelemetry/sdk-trace-base": "^1.8.0",
5151
"@opentelemetry/sdk-trace-node": "^1.8.0",
52+
"@opentelemetry/semantic-conventions": "^1.22.0",
5253
"@types/mocha": "7.0.2",
5354
"@types/node": "18.6.5",
5455
"@types/semver": "7.5.3",
@@ -64,6 +65,8 @@
6465
"typescript": "4.4.4"
6566
},
6667
"dependencies": {
68+
"@opentelemetry/api-logs": "^0.52.0",
69+
"@opentelemetry/core": "^1.25.0",
6770
"@opentelemetry/instrumentation": "^0.52.0"
6871
},
6972
"homepage": "https://github.com/open-telemetry/opentelemetry-js-contrib/tree/main/plugins/node/opentelemetry-instrumentation-pino#readme"

plugins/node/opentelemetry-instrumentation-pino/src/instrumentation.ts

Lines changed: 68 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import {
2828
} from '@opentelemetry/instrumentation';
2929
import { PinoInstrumentationConfig } from './types';
3030
import { PACKAGE_NAME, PACKAGE_VERSION } from './version';
31+
import { getTimeConverter, OTelPinoStream } from './log-sending-utils';
3132

3233
const pinoVersions = ['>=5.14.0 <10'];
3334

@@ -48,30 +49,73 @@ export class PinoInstrumentation extends InstrumentationBase {
4849
const isESM = module[Symbol.toStringTag] === 'Module';
4950
const moduleExports = isESM ? module.default : module;
5051
const instrumentation = this;
52+
5153
const patchedPino = Object.assign((...args: unknown[]) => {
52-
if (args.length === 0) {
53-
return moduleExports({
54-
mixin: instrumentation._getMixinFunction(),
55-
});
54+
const config = instrumentation.getConfig();
55+
const isEnabled = instrumentation.isEnabled();
56+
57+
const logger = moduleExports(...args);
58+
59+
// Setup "log correlation" -- injection of `trace_id` et al fields.
60+
// Note: If the Pino logger is configured with `nestedKey`, then
61+
// the `trace_id` et al fields added by `otelMixin` will be nested
62+
// as well. https://getpino.io/#/docs/api?id=mixin-function
63+
const otelMixin = instrumentation._getMixinFunction();
64+
const mixinSym = moduleExports.symbols.mixinSym;
65+
const origMixin = logger[mixinSym];
66+
if (origMixin === undefined) {
67+
logger[mixinSym] = otelMixin;
68+
} else {
69+
logger[mixinSym] = (ctx: object, level: number) => {
70+
return Object.assign(
71+
otelMixin(ctx, level),
72+
origMixin(ctx, level)
73+
);
74+
};
5675
}
5776

58-
if (args.length === 1) {
59-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
60-
const optsOrStream = args[0] as any;
61-
if (
62-
typeof optsOrStream === 'string' ||
63-
typeof optsOrStream?.write === 'function'
64-
) {
65-
args.splice(0, 0, {
66-
mixin: instrumentation._getMixinFunction(),
67-
});
68-
return moduleExports(...args);
69-
}
70-
}
77+
// Setup "log sending" -- sending log records to the Logs API.
78+
// This depends on `pino.multistream`, which was added in v7.0.0.
79+
if (
80+
isEnabled &&
81+
!config.disableLogSending &&
82+
typeof moduleExports.multistream === 'function'
83+
) {
84+
const otelTimestampFromTime = getTimeConverter(
85+
logger,
86+
moduleExports
87+
);
88+
const otelStream = new OTelPinoStream({
89+
messageKey: logger[moduleExports.symbols.messageKeySym],
90+
levels: logger.levels,
91+
otelTimestampFromTime,
92+
});
93+
(otelStream as any)[Symbol.for('pino.metadata')] = true; // for `stream.lastLevel`
94+
95+
// An error typically indicates a Pino bug, or logger configuration
96+
// bug. `diag.warn` *once* for the first error on the assumption
97+
// subsequent ones stem from the same bug.
98+
otelStream.once('unknown', (line, err) => {
99+
instrumentation._diag.warn(
100+
'could not send pino log line (will only log first occurrence)',
101+
{ line, err }
102+
);
103+
});
71104

72-
args[0] = instrumentation._combineOptions(args[0]);
105+
// Use pino's own `multistream` to send to the original stream and
106+
// to the OTel Logs API/SDK.
107+
// https://getpino.io/#/docs/api?id=pinomultistreamstreamsarray-opts-gt-multistreamres
108+
const origStream = logger[moduleExports.symbols.streamSym];
109+
logger[moduleExports.symbols.streamSym] = moduleExports.multistream(
110+
[
111+
{ level: logger.level, stream: origStream },
112+
{ level: logger.level, stream: otelStream },
113+
],
114+
{ levels: logger.levels.values }
115+
);
116+
}
73117

74-
return moduleExports(...args);
118+
return logger;
75119
}, moduleExports);
76120

77121
if (typeof patchedPino.pino === 'function') {
@@ -80,6 +124,7 @@ export class PinoInstrumentation extends InstrumentationBase {
80124
if (typeof patchedPino.default === 'function') {
81125
patchedPino.default = patchedPino;
82126
}
127+
/* istanbul ignore if */
83128
if (isESM) {
84129
if (module.pino) {
85130
// This was added in [email protected] (https://github.com/pinojs/pino/pull/936).
@@ -122,7 +167,10 @@ export class PinoInstrumentation extends InstrumentationBase {
122167
private _getMixinFunction() {
123168
const instrumentation = this;
124169
return function otelMixin(_context: object, level: number) {
125-
if (!instrumentation.isEnabled()) {
170+
if (
171+
!instrumentation.isEnabled() ||
172+
instrumentation.getConfig().disableLogCorrelation
173+
) {
126174
return {};
127175
}
128176

@@ -151,27 +199,4 @@ export class PinoInstrumentation extends InstrumentationBase {
151199
return record;
152200
};
153201
}
154-
155-
private _combineOptions(options?: any) {
156-
if (options === undefined) {
157-
return { mixin: this._getMixinFunction() };
158-
}
159-
160-
if (options.mixin === undefined) {
161-
options.mixin = this._getMixinFunction();
162-
return options;
163-
}
164-
165-
const originalMixin = options.mixin;
166-
const otelMixin = this._getMixinFunction();
167-
168-
options.mixin = (context: object, level: number) => {
169-
return Object.assign(
170-
otelMixin(context, level),
171-
originalMixin(context, level)
172-
);
173-
};
174-
175-
return options;
176-
}
177202
}

0 commit comments

Comments
 (0)