-
Notifications
You must be signed in to change notification settings - Fork 18
feat: add @opentelemetry/instrumentation-console package for capturing console calls as OpenTelemetry logs
#98
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
david-luna
merged 23 commits into
open-telemetry:main
from
drdreo:ot-console-instrumentation
Apr 27, 2026
Merged
Changes from all commits
Commits
Show all changes
23 commits
Select commit
Hold shift + click to select a range
3002ca9
feat: add `@opentelemetry/instrumentation-console` package for captur…
drdreo e92f1f3
chore: format
drdreo eab5e16
chore: remove open questions
drdreo 5ce4f9a
chore: trigger CLA verification
drdreo 43402e8
chore: trigger CLA verification
drdreo 1b992d6
Update packages/instrumentation-console/package.json
drdreo cf2687d
chore: clean up imports and fix linting
drdreo 5e9f1f2
Merge branch 'open-telemetry:main' into ot-console-instrumentation
drdreo e0c7941
chore: update linting from main
drdreo 3dede67
refactor: PR feedback
drdreo 9bbc0e7
chore: add instrumentation test app
drdreo f8a6bd7
Update packages/instrumentation-console/examples/package.json
drdreo 04b8c00
Merge branch 'open-telemetry:main' into ot-console-instrumentation
drdreo caf96de
refactor: log context improvements
drdreo 0c7b2e5
test: remove log spam in tests
drdreo d455a1d
refactor: remove trace parent context
drdreo 5e9cb98
chore: update readme
drdreo 20c0861
Merge branch 'main' into ot-console-instrumentation
martinkuba 636d32a
Merge remote-tracking branch 'upstream/main' into ot-console-instrume…
drdreo 331e74b
chore: move console instrumentation to new folder structure
drdreo 4f81b2a
Merge remote-tracking branch 'upstream/main' into ot-console-instrume…
drdreo d261cf0
feat: make log methods dynamically configurable
drdreo 35d05c7
Merge branch 'main' into ot-console-instrumentation
david-luna File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,10 @@ | ||
| /* | ||
| * Copyright The OpenTelemetry Authors | ||
| * SPDX-License-Identifier: Apache-2.0 | ||
| */ | ||
|
|
||
| export { ConsoleInstrumentation } from './instrumentation.ts'; | ||
| export type { | ||
| ConsoleInstrumentationConfig, | ||
| ConsoleMethod, | ||
| } from './types.ts'; |
322 changes: 322 additions & 0 deletions
322
packages/instrumentation/src/console/instrumentation.test.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,322 @@ | ||
| /* | ||
| * Copyright The OpenTelemetry Authors | ||
| * SPDX-License-Identifier: Apache-2.0 | ||
| */ | ||
|
|
||
| import { SeverityNumber } from '@opentelemetry/api-logs'; | ||
| import type { InMemoryLogRecordExporter } from '@opentelemetry/sdk-logs'; | ||
| import { | ||
| afterAll, | ||
| afterEach, | ||
| beforeAll, | ||
| beforeEach, | ||
| describe, | ||
| expect, | ||
| it, | ||
| } from 'vitest'; | ||
| import { setupTestLogExporter } from '#instrumentation-test-utils'; | ||
| import { ConsoleInstrumentation } from './instrumentation.ts'; | ||
| import { ATTR_CONSOLE_METHOD, CONSOLE_LOG_EVENT_NAME } from './semconv.ts'; | ||
|
|
||
| describe('ConsoleInstrumentation', () => { | ||
| let inMemoryExporter: InMemoryLogRecordExporter; | ||
| let instrumentation: ConsoleInstrumentation; | ||
| let originalConsole: Console; | ||
|
|
||
| beforeAll(() => { | ||
| originalConsole = globalThis.console; | ||
| globalThis.console = { | ||
| error: () => {}, | ||
| log: () => {}, | ||
| info: () => {}, | ||
| warn: () => {}, | ||
| trace: () => {}, | ||
| debug: () => {}, | ||
| } as unknown as Console; | ||
| inMemoryExporter = setupTestLogExporter(); | ||
| }); | ||
|
|
||
| afterAll(() => { | ||
| globalThis.console = originalConsole; | ||
| }); | ||
|
|
||
| beforeEach(() => { | ||
| inMemoryExporter.reset(); | ||
| instrumentation = new ConsoleInstrumentation(); | ||
| }); | ||
|
|
||
| afterEach(() => { | ||
| instrumentation.disable(); | ||
| }); | ||
|
|
||
| describe('severity mapping', () => { | ||
| it('should emit a log with DEBUG severity for console.debug', () => { | ||
| console.debug('debug message'); | ||
|
|
||
| const logs = inMemoryExporter.getFinishedLogRecords(); | ||
| expect(logs.length).toBe(1); | ||
|
|
||
| const log = logs[0]; | ||
| expect(log?.severityNumber).toBe(SeverityNumber.DEBUG); | ||
| expect(log?.severityText).toBe('debug'); | ||
| expect(log?.body).toBe('debug message'); | ||
| expect(log?.eventName).toBe(CONSOLE_LOG_EVENT_NAME); | ||
| expect(log?.attributes[ATTR_CONSOLE_METHOD]).toBe('debug'); | ||
| }); | ||
|
|
||
| it('should emit a log with INFO severity for console.log', () => { | ||
| console.log('log message'); | ||
|
|
||
| const logs = inMemoryExporter.getFinishedLogRecords(); | ||
| expect(logs.length).toBe(1); | ||
|
|
||
| const log = logs[0]; | ||
| expect(log?.severityNumber).toBe(SeverityNumber.INFO); | ||
| expect(log?.severityText).toBe('log'); | ||
| expect(log?.body).toBe('log message'); | ||
| expect(log?.attributes[ATTR_CONSOLE_METHOD]).toBe('log'); | ||
| }); | ||
|
|
||
| it('should emit a log with INFO severity for console.info', () => { | ||
| console.info('info message'); | ||
|
|
||
| const logs = inMemoryExporter.getFinishedLogRecords(); | ||
| expect(logs.length).toBe(1); | ||
|
|
||
| const log = logs[0]; | ||
| expect(log?.severityNumber).toBe(SeverityNumber.INFO); | ||
| expect(log?.severityText).toBe('info'); | ||
| expect(log?.body).toBe('info message'); | ||
| expect(log?.attributes[ATTR_CONSOLE_METHOD]).toBe('info'); | ||
| }); | ||
|
|
||
| it('should emit a log with WARN severity for console.warn', () => { | ||
| console.warn('warn message'); | ||
|
|
||
| const logs = inMemoryExporter.getFinishedLogRecords(); | ||
| expect(logs.length).toBe(1); | ||
|
|
||
| const log = logs[0]; | ||
| expect(log?.severityNumber).toBe(SeverityNumber.WARN); | ||
| expect(log?.severityText).toBe('warn'); | ||
| expect(log?.body).toBe('warn message'); | ||
| expect(log?.attributes[ATTR_CONSOLE_METHOD]).toBe('warn'); | ||
| }); | ||
|
|
||
| it('should emit a log with ERROR severity for console.error', () => { | ||
| console.error('error message'); | ||
|
|
||
| const logs = inMemoryExporter.getFinishedLogRecords(); | ||
| expect(logs.length).toBe(1); | ||
|
|
||
| const log = logs[0]; | ||
| expect(log?.severityNumber).toBe(SeverityNumber.ERROR); | ||
| expect(log?.severityText).toBe('error'); | ||
| expect(log?.body).toBe('error message'); | ||
| expect(log?.attributes[ATTR_CONSOLE_METHOD]).toBe('error'); | ||
| }); | ||
| }); | ||
|
|
||
| describe('logMethods config', () => { | ||
| it('should only instrument configured methods', () => { | ||
| instrumentation.disable(); | ||
| inMemoryExporter.reset(); | ||
| instrumentation = new ConsoleInstrumentation({ | ||
| enabled: true, | ||
| logMethods: ['error', 'warn'], | ||
| }); | ||
|
|
||
| console.log('log message'); | ||
| console.info('info message'); | ||
| console.debug('debug message'); | ||
| console.warn('warn message'); | ||
| console.error('error message'); | ||
|
|
||
| const logs = inMemoryExporter.getFinishedLogRecords(); | ||
| expect(logs.length).toBe(2); | ||
|
|
||
| expect(logs[0]?.severityText).toBe('warn'); | ||
| expect(logs[1]?.severityText).toBe('error'); | ||
| }); | ||
|
|
||
| it('should not emit any logs when logMethods is empty', () => { | ||
| instrumentation.disable(); | ||
| inMemoryExporter.reset(); | ||
| instrumentation = new ConsoleInstrumentation({ | ||
| enabled: true, | ||
| logMethods: [], | ||
| }); | ||
|
|
||
| console.log('log message'); | ||
| console.warn('warn message'); | ||
| console.error('error message'); | ||
|
|
||
| const logs = inMemoryExporter.getFinishedLogRecords(); | ||
| expect(logs.length).toBe(0); | ||
| }); | ||
|
|
||
| it('should respect logMethods updates via setConfig at runtime', () => { | ||
| instrumentation.setConfig({ logMethods: ['error'] }); | ||
|
|
||
| console.log('log message'); | ||
| console.warn('warn message'); | ||
| console.error('error message'); | ||
|
|
||
| let logs = inMemoryExporter.getFinishedLogRecords(); | ||
| expect(logs.length).toBe(1); | ||
| expect(logs[0]?.severityText).toBe('error'); | ||
|
|
||
| inMemoryExporter.reset(); | ||
| instrumentation.setConfig({ logMethods: ['log', 'warn'] }); | ||
|
|
||
| console.log('log message'); | ||
| console.warn('warn message'); | ||
| console.error('error message'); | ||
|
|
||
| logs = inMemoryExporter.getFinishedLogRecords(); | ||
| expect(logs.length).toBe(2); | ||
| expect(logs[0]?.severityText).toBe('log'); | ||
| expect(logs[1]?.severityText).toBe('warn'); | ||
| }); | ||
| }); | ||
|
|
||
| describe('default serialization', () => { | ||
| it('should serialize primitive values', () => { | ||
| console.log('string', 123, true, null, undefined); | ||
|
|
||
| const logs = inMemoryExporter.getFinishedLogRecords(); | ||
| expect(logs.length).toBe(1); | ||
| expect(logs[0]?.body).toBe('string 123 true null undefined'); | ||
| }); | ||
|
|
||
| it('should serialize objects as JSON', () => { | ||
| console.log({ name: 'test', value: 42 }); | ||
|
|
||
| const logs = inMemoryExporter.getFinishedLogRecords(); | ||
| expect(logs.length).toBe(1); | ||
| expect(logs[0]?.body).toBe('{"name":"test","value":42}'); | ||
| }); | ||
|
|
||
| it('should serialize multiple arguments', () => { | ||
| console.log('User:', { id: 1 }, 'logged in'); | ||
|
|
||
| const logs = inMemoryExporter.getFinishedLogRecords(); | ||
| expect(logs.length).toBe(1); | ||
| expect(logs[0]?.body).toBe('User: {"id":1} logged in'); | ||
| }); | ||
|
|
||
| it('should serialize arrays as JSON', () => { | ||
| console.log([1, 2, 3]); | ||
|
|
||
| const logs = inMemoryExporter.getFinishedLogRecords(); | ||
| expect(logs.length).toBe(1); | ||
| expect(logs[0]?.body).toBe('[1,2,3]'); | ||
| }); | ||
| }); | ||
|
|
||
| describe('custom messageSerializer', () => { | ||
| it('should use custom serializer when provided', () => { | ||
| instrumentation.disable(); | ||
| instrumentation = new ConsoleInstrumentation({ | ||
| enabled: true, | ||
| messageSerializer: (args) => | ||
| args.map((arg) => `[${typeof arg}]`).join('-'), | ||
| }); | ||
|
|
||
| console.log('hello', 123, { test: true }); | ||
|
|
||
| const logs = inMemoryExporter.getFinishedLogRecords(); | ||
| expect(logs.length).toBe(1); | ||
| expect(logs[0]?.body).toBe('[string]-[number]-[object]'); | ||
| }); | ||
| }); | ||
|
|
||
| describe('circular reference handling', () => { | ||
| it('should handle circular references by falling back to String()', () => { | ||
| const circularObj: Record<string, unknown> = { name: 'circular' }; | ||
| circularObj['self'] = circularObj; | ||
|
|
||
| console.log(circularObj); | ||
|
|
||
| const logs = inMemoryExporter.getFinishedLogRecords(); | ||
| expect(logs.length).toBe(1); | ||
| // Should fall back to String() which returns [object Object] | ||
| expect(logs[0]?.body).toBe('[object Object]'); | ||
| }); | ||
| }); | ||
|
|
||
| describe('original console behavior', () => { | ||
| it('should still call the original console method', () => { | ||
| let called = false; | ||
| const originalLog = console.log; | ||
|
|
||
| console.log = (...args: unknown[]) => { | ||
| called = true; | ||
| originalLog.apply(console, args); | ||
| }; | ||
|
|
||
| instrumentation = new ConsoleInstrumentation(); | ||
|
|
||
| console.log('test'); | ||
|
|
||
| expect(called).toBe(true); | ||
|
|
||
| instrumentation.disable(); | ||
| console.log = originalLog; | ||
| }); | ||
| }); | ||
|
|
||
| describe('enable/disable lifecycle', () => { | ||
| it('should not emit logs when disabled', () => { | ||
|
martinkuba marked this conversation as resolved.
|
||
| instrumentation.disable(); | ||
| inMemoryExporter.reset(); | ||
|
|
||
| console.log('should not be captured'); | ||
|
|
||
| const logs = inMemoryExporter.getFinishedLogRecords(); | ||
| expect(logs.length).toBe(0); | ||
|
|
||
| // enable instrumentation again | ||
| instrumentation.enable(); | ||
| }); | ||
|
|
||
| it('should emit logs when re-enabled', () => { | ||
| instrumentation.disable(); | ||
| inMemoryExporter.reset(); | ||
| instrumentation.enable(); | ||
|
|
||
| console.log('should be captured'); | ||
|
|
||
| const logs = inMemoryExporter.getFinishedLogRecords(); | ||
| expect(logs.length).toBe(1); | ||
| expect(logs[0]?.body).toBe('should be captured'); | ||
| }); | ||
|
|
||
| it('should keep console methods patched after disable (patch-once pattern)', () => { | ||
| const wrappedLog = console.log; | ||
|
|
||
| instrumentation.disable(); | ||
|
|
||
| // After disable, console.log remains wrapped but is a no-op for emitting logs. | ||
| // This avoids unpatch-order issues when multiple instrumentations wrap the same API. | ||
| expect(console.log).toBe(wrappedLog); | ||
|
|
||
| // enable instrumentation again | ||
| instrumentation.enable(); | ||
| }); | ||
|
|
||
| it('should not wrap console methods multiple times when enable() is called repeatedly', () => { | ||
| inMemoryExporter.reset(); | ||
|
|
||
| instrumentation.enable(); | ||
| instrumentation.enable(); | ||
| instrumentation.enable(); | ||
|
|
||
| console.log('single log message'); | ||
|
|
||
| const logs = inMemoryExporter.getFinishedLogRecords(); | ||
| expect(logs.length).toBe(1); | ||
| expect(logs[0]?.body).toBe('single log message'); | ||
| }); | ||
| }); | ||
| }); | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.