Skip to content

Commit 5a27ddb

Browse files
feat(log-capture): pino integration and public API
Adds log-capture support to the pino plugin. When logCaptureEnabled is true, the write hook publishes each serialized JSON log line to the apm:pino:log:json diagnostic channel so the log-capture sender can forward them to the Datadog log intake without requiring a sidecar Agent. The hook is designed as an additive path alongside the existing log injection: log injection (dd.trace_id / dd.span_id correlation) still works independently, and capture-only mode (logInjection: false, logCaptureEnabled: true) leaves the log record unmodified. - pino/src/index.js: publish complete JSON line to apm:pino:log:json on every write when logCaptureEnabled; compatible with pino 5-8. Pass hasUserDd so the plugin skips injection but still captures records that already carry a caller-provided dd field. - datadog-instrumentations/src/pino.js: always publish to channel when subscribers exist; hasUserDd flag prevents injection from overwriting a caller-owned dd field while still forwarding the record for capture. - docs/API.md: public documentation for DD_LOG_CAPTURE_* options - .github/workflows/instrumentation.yml: add pino to instrumentation CI - pino/test/unit.spec.js: unit test for capture-only mode - pino/test/index.spec.js: integration smoke test for the JSON channel Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
1 parent 697c324 commit 5a27ddb

6 files changed

Lines changed: 152 additions & 11 deletions

File tree

.github/workflows/instrumentation.yml

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -464,6 +464,16 @@ jobs:
464464
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
465465
- uses: ./.github/actions/instrumentations/test
466466

467+
instrumentation-pino:
468+
runs-on: ubuntu-latest
469+
permissions:
470+
id-token: write
471+
env:
472+
PLUGINS: pino
473+
steps:
474+
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
475+
- uses: ./.github/actions/instrumentations/test
476+
467477
instrumentation-promise-js:
468478
runs-on: ubuntu-latest
469479
permissions:

docs/API.md

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -535,6 +535,38 @@ const tracer = require('dd-trace').init({
535535
})
536536
```
537537

538+
<h4 id="log-capture">Automatic log capture</h4>
539+
540+
dd-trace can forward JSON log records written by Pino directly to a custom HTTP(S) intake without adding transports to each logger. The forwarding hook publishes full log entries (including Datadog trace metadata when log injection is enabled) through an internal buffer that flushes on a timer and before process exit.
541+
542+
**Default behavior:** Log capture is enabled automatically in certain environments. For all other environments, enable it with `logCaptureEnabled: true` (or `DD_LOG_CAPTURE_ENABLED=true`). By default the sender targets `localhost:10517`; override with `logCaptureHost`/`DD_LOG_CAPTURE_HOST` and `logCapturePort`/`DD_LOG_CAPTURE_PORT` when needed.
543+
544+
Optional tuning knobs map 1:1 with the configuration API:
545+
546+
* `logCaptureHost` (`DD_LOG_CAPTURE_HOST`, default `localhost`)
547+
* `logCapturePort` (`DD_LOG_CAPTURE_PORT`, default `10517`)
548+
* `logCapturePath` (`DD_LOG_CAPTURE_PATH`, default `/logs`)
549+
* `logCaptureProtocol` (`DD_LOG_CAPTURE_PROTOCOL`, default `http:`)
550+
* `logCaptureMaxBufferSize` (`DD_LOG_CAPTURE_MAX_BUFFER_SIZE`, default `1000` records)
551+
* `logCaptureFlushIntervalMs` (`DD_LOG_CAPTURE_FLUSH_INTERVAL_MS`, default `5000`)
552+
* `logCaptureTimeoutMs` (`DD_LOG_CAPTURE_TIMEOUT_MS`, default `5000`)
553+
554+
Example:
555+
556+
```javascript
557+
require('dd-trace').init({
558+
logInjection: true,
559+
logCaptureEnabled: true,
560+
// host and port default to localhost:10517; override if needed:
561+
logCaptureHost: 'logs.my-intake.internal',
562+
logCapturePort: 8080,
563+
logCaptureProtocol: 'https:',
564+
})
565+
```
566+
567+
No additional transports or stream shims are required in user code.
568+
569+
538570
<h3 id="span-hooks">Span Hooks</h3>
539571

540572
In some cases, it's necessary to update the metadata of a span created by one of the built-in integrations. This is possible using span hooks registered by integration. Each hook provides the span as the first argument and other contextual objects as additional arguments.

packages/datadog-instrumentations/src/pino.js

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -35,12 +35,12 @@ function wrapAsJson (asJson) {
3535
return function asJsonWithTrace (obj, msg, num, time) {
3636
obj = arguments[0] = obj || {}
3737

38-
// Caller-provided `dd` wins -- skip the splice so a bespoke `dd` survives.
39-
if (!jsonCh.hasSubscribers || Object.hasOwn(obj, 'dd')) {
38+
if (!jsonCh.hasSubscribers) {
4039
return asJson.apply(this, arguments)
4140
}
4241

43-
const payload = { line: asJson.apply(this, arguments) }
42+
// Pass hasUserDd so the plugin can skip injection while still capturing.
43+
const payload = { line: asJson.apply(this, arguments), hasUserDd: Object.hasOwn(obj, 'dd') }
4444
jsonCh.publish(payload)
4545
return payload.line
4646
}

packages/datadog-plugin-pino/src/index.js

Lines changed: 49 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -13,23 +13,62 @@ class PinoPlugin extends LogPlugin {
1313
}
1414

1515
/**
16-
* Splice `,"dd":<json>` into the JSON line pino has already produced.
16+
* Disable the generic apm:${id}:log capture path for pino.
17+
*
18+
* Pino's apm:pino:log:json channel provides the fully-serialized JSON line
19+
* and is used for both injection and capture in handleJsonLine.
20+
* This prevents double-capture from the LogPlugin base class subscriber.
21+
*
22+
* @returns {false}
23+
*/
24+
get _captureEnabled () {
25+
return false
26+
}
27+
28+
/**
29+
* Splice `,"dd":<json>` into the JSON line pino has already produced,
30+
* and optionally capture the complete record for log forwarding.
1731
* The caller-owned message object is never observed -- user Proxies and
1832
* custom serialisers see nothing because there is no mutation to see.
1933
*
2034
* @param {{ line: string }} payload
2135
*/
2236
handleJsonLine (payload) {
37+
if (typeof payload?.line !== 'string') return
38+
39+
// hasUserDd: caller already has a dd field — preserve it; skip injection but still capture.
40+
const shouldInject = this.config.logInjection && !payload.hasUserDd
41+
const shouldCapture = this.config.logCaptureEnabled
42+
43+
if (!shouldInject && !shouldCapture) return
44+
2345
const logHolder = buildLogHolder(this.tracer)
24-
if (!logHolder) return
2546

26-
const line = payload.line
27-
const lastClose = line.lastIndexOf('}')
28-
if (lastClose < 1) return
47+
if (shouldInject && logHolder) {
48+
const line = payload.line
49+
const lastClose = line.lastIndexOf('}')
50+
if (lastClose >= 1) {
51+
const ddJson = JSON.stringify(logHolder.dd)
52+
const sep = line.charCodeAt(lastClose - 1) === 0x7B ? '' : ','
53+
payload.line = line.slice(0, lastClose) + sep + '"dd":' + ddJson + line.slice(lastClose)
54+
}
55+
}
2956

30-
const ddJson = JSON.stringify(logHolder.dd)
31-
const sep = line.charCodeAt(lastClose - 1) === 0x7B ? '' : ','
32-
payload.line = line.slice(0, lastClose) + sep + '"dd":' + ddJson + line.slice(lastClose)
57+
if (shouldCapture) {
58+
if (!shouldInject && logHolder) {
59+
// Enrich the captured record with dd context without modifying the actual log line.
60+
const line = payload.line
61+
const lastClose = line.lastIndexOf('}')
62+
if (lastClose >= 1) {
63+
const ddJson = JSON.stringify(logHolder.dd)
64+
const sep = line.charCodeAt(lastClose - 1) === 0x7B ? '' : ','
65+
this.capture(line.slice(0, lastClose) + sep + '"dd":' + ddJson + line.slice(lastClose))
66+
return
67+
}
68+
}
69+
// If injection already happened, payload.line includes dd; otherwise capture raw.
70+
this.capture(payload.line)
71+
}
3372
}
3473

3574
/**
@@ -41,6 +80,8 @@ class PinoPlugin extends LogPlugin {
4180
* @param {{ message: object }} arg
4281
*/
4382
handlePrettyMessage (arg) {
83+
if (!this.config.logInjection) return
84+
4485
const logHolder = buildLogHolder(this.tracer)
4586
if (!logHolder) return
4687

packages/datadog-plugin-pino/test/index.spec.js

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,16 @@ describe('Plugin', () => {
4747
const pretty = require('../../../versions/pino-pretty@8.0.0').get()
4848

4949
stream = pretty().pipe(stream)
50+
} else if (semver.intersects(version, '>=5 <8') && options.prettyPrint) {
51+
// pino 5-7 supports prettyPrint internally by calling require('pino-pretty').
52+
// In this test environment, that resolves to pino-pretty@8 which exports an
53+
// abstractTransport-based Transform stream — incompatible with pino 5-7's
54+
// expectation of a sync factory function. pino-pretty@8 also creates a
55+
// SonicBoom (writing to stdout) that registers with on-exit-leak-free,
56+
// leaving async resources that prevent mocha from exiting after the test.
57+
// Provide pino-pretty@3 via the `prettifier` option so pino uses its
58+
// synchronous factory interface and avoids the leaked resources.
59+
options.prettifier = require('../../../versions/pino-pretty@3.0.0').get()
5060
}
5161

5262
logger = pino(options, stream)
@@ -247,6 +257,41 @@ describe('Plugin', () => {
247257
})
248258
}
249259
})
260+
261+
describe('log capture channel (apm:pino:log:json)', () => {
262+
beforeEach(() => {
263+
return agent.load('pino')
264+
})
265+
266+
beforeEach(function () {
267+
setupTest()
268+
269+
if (!logger) {
270+
this.skip()
271+
}
272+
})
273+
274+
it('should emit a complete JSON record including pid, hostname, level, time, msg', (done) => {
275+
const { channel } = require('dc-polyfill')
276+
const captureCh = channel('apm:pino:log:json')
277+
let captured
278+
const sub = (payload) => { captured = payload }
279+
captureCh.subscribe(sub)
280+
281+
logger.info('hello capture')
282+
283+
setImmediate(() => {
284+
captureCh.unsubscribe(sub)
285+
assert.ok(captured, 'json channel should have fired')
286+
const record = JSON.parse(captured.line)
287+
assert.ok(record.pid, 'should have pid')
288+
assert.ok(record.hostname, 'should have hostname')
289+
assert.ok(record.time, 'should have time')
290+
assert.strictEqual(record.msg, 'hello capture')
291+
done()
292+
})
293+
})
294+
})
250295
})
251296
})
252297
})

packages/datadog-plugin-pino/test/unit.spec.js

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,5 +132,18 @@ describe('PinoPlugin', () => {
132132
tracer.inject = originalInject
133133
}
134134
})
135+
136+
it('leaves the message untouched when logInjection is disabled (capture-only mode)', () => {
137+
plugin.configure({ logInjection: false, logCaptureEnabled: true, enabled: true })
138+
try {
139+
const original = { msg: 'hello', level: 30 }
140+
const data = { message: original }
141+
messageCh.publish(data)
142+
assert.strictEqual(data.message, original, 'message should not be wrapped in capture-only mode')
143+
assert.ok(!('dd' in data.message), 'dd should not be injected when logInjection is false')
144+
} finally {
145+
plugin.configure({ logInjection: true, enabled: true })
146+
}
147+
})
135148
})
136149
})

0 commit comments

Comments
 (0)