Skip to content
Merged
Show file tree
Hide file tree
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 Dec 17, 2025
e92f1f3
chore: format
drdreo Dec 17, 2025
eab5e16
chore: remove open questions
drdreo Dec 17, 2025
5ce4f9a
chore: trigger CLA verification
drdreo Dec 17, 2025
43402e8
chore: trigger CLA verification
drdreo Dec 17, 2025
1b992d6
Update packages/instrumentation-console/package.json
drdreo Dec 18, 2025
cf2687d
chore: clean up imports and fix linting
drdreo Jan 2, 2026
5e9f1f2
Merge branch 'open-telemetry:main' into ot-console-instrumentation
drdreo Jan 6, 2026
e0c7941
chore: update linting from main
drdreo Jan 6, 2026
3dede67
refactor: PR feedback
drdreo Jan 7, 2026
9bbc0e7
chore: add instrumentation test app
drdreo Jan 7, 2026
f8a6bd7
Update packages/instrumentation-console/examples/package.json
drdreo Jan 9, 2026
04b8c00
Merge branch 'open-telemetry:main' into ot-console-instrumentation
drdreo Jan 15, 2026
caf96de
refactor: log context improvements
drdreo Jan 15, 2026
0c7b2e5
test: remove log spam in tests
drdreo Jan 15, 2026
d455a1d
refactor: remove trace parent context
drdreo Jan 22, 2026
5e9cb98
chore: update readme
drdreo Jan 25, 2026
20c0861
Merge branch 'main' into ot-console-instrumentation
martinkuba Jan 28, 2026
636d32a
Merge remote-tracking branch 'upstream/main' into ot-console-instrume…
drdreo Apr 13, 2026
331e74b
chore: move console instrumentation to new folder structure
drdreo Apr 13, 2026
4f81b2a
Merge remote-tracking branch 'upstream/main' into ot-console-instrume…
drdreo Apr 25, 2026
d261cf0
feat: make log methods dynamically configurable
drdreo Apr 25, 2026
35d05c7
Merge branch 'main' into ot-console-instrumentation
david-luna Apr 27, 2026
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
2 changes: 2 additions & 0 deletions packages/instrumentation/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
"browser",
"web",
"instrumentation",
"console",
"navigation-timing",
"user-action",
"web-vitals",
Expand All @@ -27,6 +28,7 @@
"#instrumentation-test-utils": "./src/test-utils/index.ts"
},
"exports": {
"./experimental/console": "./dist/console/index.js",
"./experimental/navigation-timing": "./dist/navigation-timing/index.js",
"./experimental/user-action": "./dist/user-action/index.js",
"./experimental/web-vitals": "./dist/web-vitals/index.js",
Expand Down
10 changes: 10 additions & 0 deletions packages/instrumentation/src/console/index.ts
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 packages/instrumentation/src/console/instrumentation.test.ts
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();
Comment thread
martinkuba marked this conversation as resolved.
});

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', () => {
Comment thread
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');
});
});
});
Loading