Skip to content

Commit e2b9cda

Browse files
authored
feat(core): Add addConsoleInstrumentationFilter utility (#20790)
We want to leverage this for Node 26 to filter deprecation messages from IITM, but this could also be used by users if they want to silence certain things. Required for #20710
1 parent c259c75 commit e2b9cda

9 files changed

Lines changed: 219 additions & 10 deletions

File tree

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import * as Sentry from '@sentry/node';
2+
import { loggingTransport } from '@sentry-internal/node-integration-tests';
3+
4+
Sentry.init({
5+
dsn: 'https://public@dsn.ingest.sentry.io/1337',
6+
release: '1.0',
7+
transport: loggingTransport,
8+
defaultIntegrations: false,
9+
integrations: [Sentry.consoleIntegration({ filter: ['foo'] })],
10+
});
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
/* eslint-disable no-console */
2+
import * as Sentry from '@sentry/node';
3+
4+
console.log('hello');
5+
console.log('foo');
6+
console.log('foo2');
7+
console.log('baz');
8+
9+
Sentry.captureException(new Error('Test Error'));
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import { afterAll, describe, expect } from 'vitest';
2+
import { cleanupChildProcesses, createEsmAndCjsTests } from '../../../../utils/runner';
3+
4+
describe('Console Integration', () => {
5+
afterAll(() => {
6+
cleanupChildProcesses();
7+
});
8+
9+
createEsmAndCjsTests(__dirname, 'scenario.mjs', 'instrument.mjs', (createRunner, test) => {
10+
test('filters console messages', async () => {
11+
await createRunner()
12+
.expect({
13+
event: {
14+
exception: {
15+
values: [
16+
{
17+
value: 'Test Error',
18+
},
19+
],
20+
},
21+
breadcrumbs: [
22+
expect.objectContaining({
23+
message: 'hello',
24+
}),
25+
expect.objectContaining({
26+
message: 'baz',
27+
}),
28+
],
29+
},
30+
})
31+
.start()
32+
.completed();
33+
});
34+
});
35+
});

packages/core/src/instrument/console.ts

Lines changed: 45 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,20 @@
11
/* eslint-disable @typescript-eslint/no-explicit-any */
22
/* eslint-disable @typescript-eslint/ban-types */
3+
import { DEBUG_BUILD } from '../debug-build';
34
import type { ConsoleLevel, HandlerDataConsole } from '../types-hoist/instrument';
45
import { CONSOLE_LEVELS, originalConsoleMethods } from '../utils/debug-logger';
56
import { fill } from '../utils/object';
7+
import { stringMatchesSomePattern } from '../utils/string';
68
import { GLOBAL_OBJ } from '../utils/worldwide';
79
import { addHandler, maybeInstrument, triggerHandlers } from './handlers';
10+
import { debug } from '../utils/debug-logger';
11+
12+
/**
13+
* Filter out console messages that match the given strings or regular expressions.
14+
* These will neither be passed to the handler, and they will also not be logged to the user, unless they have debug enabled.
15+
* This is a set to avoid duplicate integration setups to add the same filter multiple times.
16+
*/
17+
const _filter = new Set<string | RegExp>([]);
818

919
/**
1020
* Add an instrumentation handler for when a console.xxx method is called.
@@ -20,6 +30,27 @@ export function addConsoleInstrumentationHandler(handler: (data: HandlerDataCons
2030
return removeHandler;
2131
}
2232

33+
/**
34+
* Add a filter to the console instrumentation to filter out console messages that match the given strings or regular expressions.
35+
* Returns a function to remove the filter.
36+
*/
37+
export function addConsoleInstrumentationFilter(filter: (string | RegExp)[]): () => void {
38+
for (const f of filter) {
39+
_filter.add(f);
40+
}
41+
42+
return () => {
43+
for (const f of filter) {
44+
_filter.delete(f);
45+
}
46+
};
47+
}
48+
49+
/** Only exported for tests. */
50+
export function _INTERNAL_resetConsoleInstrumentationOptions(): void {
51+
_filter.clear();
52+
}
53+
2354
function instrumentConsole(): void {
2455
if (!('console' in GLOBAL_OBJ)) {
2556
return;
@@ -34,10 +65,21 @@ function instrumentConsole(): void {
3465
originalConsoleMethods[level] = originalConsoleMethod;
3566

3667
return function (...args: any[]): void {
37-
triggerHandlers('console', { args, level } as HandlerDataConsole);
38-
68+
const firstArg = args[0];
3969
const log = originalConsoleMethods[level];
40-
log?.apply(GLOBAL_OBJ.console, args);
70+
71+
const isFiltered = _filter.size && typeof firstArg === 'string' && stringMatchesSomePattern(firstArg, _filter);
72+
73+
// Only trigger handlers for non-filtered messages
74+
if (!isFiltered) {
75+
triggerHandlers('console', { args, level } as HandlerDataConsole);
76+
}
77+
78+
// Only log filtered messages in debug mode
79+
if (!isFiltered || (DEBUG_BUILD && debug.isEnabled())) {
80+
// Call original console method
81+
log?.apply(GLOBAL_OBJ.console, args);
82+
}
4183
};
4284
});
4385
});

packages/core/src/integrations/console.ts

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { addBreadcrumb } from '../breadcrumbs';
22
import { getClient } from '../currentScopes';
3-
import { addConsoleInstrumentationHandler } from '../instrument/console';
3+
import { addConsoleInstrumentationFilter, addConsoleInstrumentationHandler } from '../instrument/console';
44
import { defineIntegration } from '../integration';
55
import type { ConsoleLevel } from '../types-hoist/instrument';
66
import { CONSOLE_LEVELS } from '../utils/debug-logger';
@@ -10,6 +10,11 @@ import { GLOBAL_OBJ } from '../utils/worldwide';
1010

1111
interface ConsoleIntegrationOptions {
1212
levels: ConsoleLevel[];
13+
/**
14+
* Filter out console messages that match the given strings or regular expressions.
15+
* These will neither be passed to the handler, and they will also not be logged to the user, unless they have debug enabled.
16+
*/
17+
filter?: (string | RegExp)[];
1318
}
1419

1520
type GlobalObjectWithUtil = typeof GLOBAL_OBJ & {
@@ -48,8 +53,12 @@ export const consoleIntegration = defineIntegration((options: Partial<ConsoleInt
4853

4954
addConsoleBreadcrumb(level, args);
5055
});
51-
5256
client.registerCleanup(unsubscribe);
57+
58+
if (options.filter) {
59+
const unsubscribe = addConsoleInstrumentationFilter(options.filter);
60+
client.registerCleanup(unsubscribe);
61+
}
5362
},
5463
};
5564
});

packages/core/src/shared-exports.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -208,7 +208,7 @@ export { dsnFromString, dsnToString, makeDsn } from './utils/dsn';
208208
export { SentryError } from './utils/error';
209209
export { GLOBAL_OBJ } from './utils/worldwide';
210210
export type { InternalGlobal } from './utils/worldwide';
211-
export { addConsoleInstrumentationHandler } from './instrument/console';
211+
export { addConsoleInstrumentationHandler, addConsoleInstrumentationFilter } from './instrument/console';
212212
export { addFetchEndInstrumentationHandler, addFetchInstrumentationHandler } from './instrument/fetch';
213213
export { addGlobalErrorInstrumentationHandler } from './instrument/globalError';
214214
export { addGlobalUnhandledRejectionInstrumentationHandler } from './instrument/globalUnhandledRejection';

packages/core/src/utils/string.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -136,8 +136,15 @@ export function isMatchingPattern(
136136
*/
137137
export function stringMatchesSomePattern(
138138
testString: string,
139-
patterns: Array<string | RegExp | ((value: string) => boolean)> = [],
139+
patterns:
140+
| Array<string | RegExp | ((value: string) => boolean)>
141+
| Set<string | RegExp | ((value: string) => boolean)> = [],
140142
requireExactStringMatch: boolean = false,
141143
): boolean {
142-
return patterns.some(pattern => isMatchingPattern(testString, pattern, requireExactStringMatch));
144+
for (const pattern of patterns) {
145+
if (isMatchingPattern(testString, pattern, requireExactStringMatch)) {
146+
return true;
147+
}
148+
}
149+
return false;
143150
}
Lines changed: 90 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,110 @@
1-
import { describe, expect, it, vi } from 'vitest';
2-
import { addConsoleInstrumentationHandler } from '../../../src/instrument/console';
1+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
2+
import {
3+
_INTERNAL_resetConsoleInstrumentationOptions,
4+
addConsoleInstrumentationFilter,
5+
addConsoleInstrumentationHandler,
6+
} from '../../../src/instrument/console';
37
import { GLOBAL_OBJ } from '../../../src/utils/worldwide';
8+
import { debug, originalConsoleMethods } from '../../../src/utils/debug-logger';
9+
import { resetInstrumentationHandlers } from '../../../src/instrument/handlers';
410

511
describe('addConsoleInstrumentationHandler', () => {
12+
let _originalConsoleMethods: typeof originalConsoleMethods = {};
13+
14+
afterEach(() => {
15+
Object.assign(originalConsoleMethods, _originalConsoleMethods);
16+
resetInstrumentationHandlers();
17+
vi.restoreAllMocks();
18+
});
19+
20+
// This cannot be done in beforeEach, as the first invocation of `addConsoleInstrumentationHandler` will overwrite the original console methods.
21+
// Due to `fill` being called
22+
// So instead, we need to call this each time after calling `addConsoleInstrumentationHandler`
23+
function mockConsoleMethods() {
24+
// Re-store this with the current implementation
25+
Object.assign(_originalConsoleMethods, originalConsoleMethods);
26+
27+
// Overwrite with mock console methods
28+
Object.assign(originalConsoleMethods, {
29+
log: vi.fn(),
30+
warn: vi.fn(),
31+
error: vi.fn(),
32+
debug: vi.fn(),
33+
info: vi.fn(),
34+
});
35+
}
36+
637
it.each(['log', 'warn', 'error', 'debug', 'info'] as const)(
738
'calls registered handler when console.%s is called',
839
level => {
940
const handler = vi.fn();
1041
addConsoleInstrumentationHandler(handler);
42+
mockConsoleMethods();
1143

1244
GLOBAL_OBJ.console[level]('test message');
1345

1446
expect(handler).toHaveBeenCalledWith(expect.objectContaining({ args: ['test message'], level }));
47+
expect(originalConsoleMethods[level]).toHaveBeenCalledWith('test message');
1548
},
1649
);
1750

1851
it('calls through to the underlying console method without throwing', () => {
1952
addConsoleInstrumentationHandler(vi.fn());
53+
mockConsoleMethods();
2054
expect(() => GLOBAL_OBJ.console.log('hello')).not.toThrow();
2155
});
56+
57+
describe('filter', () => {
58+
afterEach(() => {
59+
_INTERNAL_resetConsoleInstrumentationOptions();
60+
});
61+
62+
describe('when debug is disabled', () => {
63+
beforeEach(() => {
64+
vi.spyOn(debug, 'isEnabled').mockImplementation(() => false);
65+
});
66+
67+
it('filters out messages that match the filter', () => {
68+
const handler = vi.fn();
69+
addConsoleInstrumentationHandler(handler);
70+
addConsoleInstrumentationFilter(['test message']);
71+
mockConsoleMethods();
72+
73+
GLOBAL_OBJ.console.log('test message');
74+
75+
expect(originalConsoleMethods.log).not.toHaveBeenCalledWith('test message');
76+
expect(handler).not.toHaveBeenCalled();
77+
});
78+
79+
it('does not filter out messages that do not match the filter', () => {
80+
const handler = vi.fn();
81+
addConsoleInstrumentationHandler(handler);
82+
addConsoleInstrumentationFilter(['test message']);
83+
mockConsoleMethods();
84+
85+
GLOBAL_OBJ.console.log('other message');
86+
87+
expect(handler).toHaveBeenCalled();
88+
expect(originalConsoleMethods.log).toHaveBeenCalledWith('other message');
89+
});
90+
});
91+
92+
describe('when debug is enabled', () => {
93+
beforeEach(() => {
94+
vi.spyOn(debug, 'isEnabled').mockImplementation(() => true);
95+
});
96+
97+
it('logs filtered messages but does not call the handler for them', () => {
98+
const handler = vi.fn();
99+
addConsoleInstrumentationHandler(handler);
100+
addConsoleInstrumentationFilter(['test message']);
101+
mockConsoleMethods();
102+
103+
GLOBAL_OBJ.console.log('test message');
104+
105+
expect(handler).not.toHaveBeenCalled();
106+
expect(originalConsoleMethods.log).toHaveBeenCalledWith('test message');
107+
});
108+
});
109+
});
22110
});

packages/node-core/src/integrations/console.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,11 @@ import {
1414

1515
interface ConsoleIntegrationOptions {
1616
levels: ConsoleLevel[];
17+
/**
18+
* Filter out console messages that match the given strings or regular expressions.
19+
* These will neither be passed to the handler, and they will also not be logged to the user, unless they have debug enabled.
20+
*/
21+
filter?: (string | RegExp)[];
1722
}
1823

1924
/**
@@ -40,6 +45,10 @@ export const consoleIntegration = defineIntegration((options: Partial<ConsoleInt
4045
};
4146
});
4247

48+
/**
49+
* NOTE: This currently ignores the filter option.
50+
* We can revisit this later.
51+
*/
4352
function instrumentConsoleLambda(): void {
4453
const consoleObj = GLOBAL_OBJ?.console;
4554
if (!consoleObj) {

0 commit comments

Comments
 (0)