Skip to content

Commit 9ca030d

Browse files
authored
feat(core): Add support for beforeSendLog (#15814)
ref #15526 Adds support for `beforeSendLog`, currently in the `_experiments` options namespace. While adding `beforeSendLog`, I noticed the `beforeCaptureLog` was not placed correctly. This PR also fixes that, and introduces a `afterCaptureLog` that runs in the capturing lifecycle properly.
1 parent f2f21ee commit 9ca030d

File tree

5 files changed

+131
-6
lines changed

5 files changed

+131
-6
lines changed

packages/core/src/client.ts

+14-2
Original file line numberDiff line numberDiff line change
@@ -623,12 +623,19 @@ export abstract class Client<O extends ClientOptions = ClientOptions> {
623623
public on(hook: 'close', callback: () => void): () => void;
624624

625625
/**
626-
* A hook that is called before a log is captured
626+
* A hook that is called before a log is captured. This hooks runs before `beforeSendLog` is fired.
627627
*
628628
* @returns {() => void} A function that, when executed, removes the registered callback.
629629
*/
630630
public on(hook: 'beforeCaptureLog', callback: (log: Log) => void): () => void;
631631

632+
/**
633+
* A hook that is called after a log is captured
634+
*
635+
* @returns {() => void} A function that, when executed, removes the registered callback.
636+
*/
637+
public on(hook: 'afterCaptureLog', callback: (log: Log) => void): () => void;
638+
632639
/**
633640
* Register a hook on this client.
634641
*/
@@ -777,10 +784,15 @@ export abstract class Client<O extends ClientOptions = ClientOptions> {
777784
public emit(hook: 'close'): void;
778785

779786
/**
780-
* Emit a hook event for client before capturing a log
787+
* Emit a hook event for client before capturing a log. This hooks runs before `beforeSendLog` is fired.
781788
*/
782789
public emit(hook: 'beforeCaptureLog', log: Log): void;
783790

791+
/**
792+
* Emit a hook event for client after capturing a log.
793+
*/
794+
public emit(hook: 'afterCaptureLog', log: Log): void;
795+
784796
/**
785797
* Emit a hook that was previously registered via `on()`.
786798
*/

packages/core/src/logs/index.ts

+13-3
Original file line numberDiff line numberDiff line change
@@ -62,18 +62,28 @@ export function logAttributeToSerializedLogAttribute(key: string, value: unknown
6262
* @experimental This method will experience breaking changes. This is not yet part of
6363
* the stable Sentry SDK API and can be changed or removed without warning.
6464
*/
65-
export function _INTERNAL_captureLog(log: Log, client = getClient(), scope = getCurrentScope()): void {
65+
export function _INTERNAL_captureLog(beforeLog: Log, client = getClient(), scope = getCurrentScope()): void {
6666
if (!client) {
6767
DEBUG_BUILD && logger.warn('No client available to capture log.');
6868
return;
6969
}
7070

7171
const { _experiments, release, environment } = client.getOptions();
72-
if (!_experiments?.enableLogs) {
72+
const { enableLogs = false, beforeSendLog } = _experiments ?? {};
73+
if (!enableLogs) {
7374
DEBUG_BUILD && logger.warn('logging option not enabled, log will not be captured.');
7475
return;
7576
}
7677

78+
client.emit('beforeCaptureLog', beforeLog);
79+
80+
const log = beforeSendLog ? beforeSendLog(beforeLog) : beforeLog;
81+
if (!log) {
82+
client.recordDroppedEvent('before_send', 'log_item', 1);
83+
DEBUG_BUILD && logger.warn('beforeSendLog returned null, log will not be captured.');
84+
return;
85+
}
86+
7787
const [, traceContext] = _getTraceInfoFromScope(client, scope);
7888

7989
const { level, message, attributes, severityNumber } = log;
@@ -117,7 +127,7 @@ export function _INTERNAL_captureLog(log: Log, client = getClient(), scope = get
117127
}
118128
}
119129

120-
client.emit('beforeCaptureLog', log);
130+
client.emit('afterCaptureLog', log);
121131
}
122132

123133
/**

packages/core/src/server-runtime-client.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ export class ServerRuntimeClient<
5757
_INTERNAL_flushLogsBuffer(client);
5858
});
5959

60-
this.on('beforeCaptureLog', log => {
60+
this.on('afterCaptureLog', log => {
6161
client._logWeight += estimateLogSizeInBytes(log);
6262

6363
// We flush the logs buffer if it exceeds 0.8 MB

packages/core/src/types-hoist/options.ts

+12
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import type { CaptureContext } from '../scope';
22
import type { Breadcrumb, BreadcrumbHint } from './breadcrumb';
33
import type { ErrorEvent, EventHint, TransactionEvent } from './event';
44
import type { Integration } from './integration';
5+
import type { Log } from './log';
56
import type { TracesSamplerSamplingContext } from './samplingcontext';
67
import type { SdkMetadata } from './sdkmetadata';
78
import type { SpanJSON } from './span';
@@ -188,6 +189,17 @@ export interface ClientOptions<TO extends BaseTransportOptions = BaseTransportOp
188189
* If logs support should be enabled. Defaults to false.
189190
*/
190191
enableLogs?: boolean;
192+
/**
193+
* An event-processing callback for logs, guaranteed to be invoked after all other log
194+
* processors. This allows a log to be modified or dropped before it's sent.
195+
*
196+
* Note that you must return a valid log from this callback. If you do not wish to modify the log, simply return
197+
* it at the end. Returning `null` will cause the log to be dropped.
198+
*
199+
* @param log The log generated by the SDK.
200+
* @returns A new log that will be sent | null.
201+
*/
202+
beforeSendLog?: (log: Log) => Log | null;
191203
};
192204

193205
/**

packages/core/test/lib/log/index.test.ts

+91
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
import { TestClient, getDefaultTestClientOptions } from '../../mocks/client';
99
import * as loggerModule from '../../../src/utils-hoist/logger';
1010
import { Scope } from '../../../src';
11+
import type { Log } from '../../../src/types-hoist/log';
1112

1213
const PUBLIC_DSN = 'https://username@domain/123';
1314

@@ -187,4 +188,94 @@ describe('_INTERNAL_captureLog', () => {
187188
_INTERNAL_flushLogsBuffer(client);
188189
expect(mockSendEnvelope).not.toHaveBeenCalled();
189190
});
191+
192+
it('processes logs through beforeSendLog when provided', () => {
193+
const beforeSendLog = vi.fn().mockImplementation(log => ({
194+
...log,
195+
message: `Modified: ${log.message}`,
196+
attributes: { ...log.attributes, processed: true },
197+
}));
198+
199+
const options = getDefaultTestClientOptions({
200+
dsn: PUBLIC_DSN,
201+
_experiments: { enableLogs: true, beforeSendLog },
202+
});
203+
const client = new TestClient(options);
204+
205+
_INTERNAL_captureLog(
206+
{
207+
level: 'info',
208+
message: 'original message',
209+
attributes: { original: true },
210+
},
211+
client,
212+
undefined,
213+
);
214+
215+
expect(beforeSendLog).toHaveBeenCalledWith({
216+
level: 'info',
217+
message: 'original message',
218+
attributes: { original: true },
219+
});
220+
221+
const logBuffer = _INTERNAL_getLogBuffer(client);
222+
expect(logBuffer).toBeDefined();
223+
expect(logBuffer?.[0]).toEqual(
224+
expect.objectContaining({
225+
body: {
226+
stringValue: 'Modified: original message',
227+
},
228+
attributes: expect.arrayContaining([
229+
expect.objectContaining({ key: 'processed', value: { boolValue: true } }),
230+
expect.objectContaining({ key: 'original', value: { boolValue: true } }),
231+
]),
232+
}),
233+
);
234+
});
235+
236+
it('drops logs when beforeSendLog returns null', () => {
237+
const beforeSendLog = vi.fn().mockReturnValue(null);
238+
const recordDroppedEventSpy = vi.spyOn(TestClient.prototype, 'recordDroppedEvent');
239+
const loggerWarnSpy = vi.spyOn(loggerModule.logger, 'warn').mockImplementation(() => undefined);
240+
241+
const options = getDefaultTestClientOptions({
242+
dsn: PUBLIC_DSN,
243+
_experiments: { enableLogs: true, beforeSendLog },
244+
});
245+
const client = new TestClient(options);
246+
247+
_INTERNAL_captureLog(
248+
{
249+
level: 'info',
250+
message: 'test message',
251+
},
252+
client,
253+
undefined,
254+
);
255+
256+
expect(beforeSendLog).toHaveBeenCalled();
257+
expect(recordDroppedEventSpy).toHaveBeenCalledWith('before_send', 'log_item', 1);
258+
expect(loggerWarnSpy).toHaveBeenCalledWith('beforeSendLog returned null, log will not be captured.');
259+
expect(_INTERNAL_getLogBuffer(client)).toBeUndefined();
260+
261+
recordDroppedEventSpy.mockRestore();
262+
loggerWarnSpy.mockRestore();
263+
});
264+
265+
it('emits beforeCaptureLog and afterCaptureLog events', () => {
266+
const beforeCaptureLogSpy = vi.spyOn(TestClient.prototype, 'emit');
267+
const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, _experiments: { enableLogs: true } });
268+
const client = new TestClient(options);
269+
270+
const log: Log = {
271+
level: 'info',
272+
message: 'test message',
273+
};
274+
275+
_INTERNAL_captureLog(log, client, undefined);
276+
277+
expect(beforeCaptureLogSpy).toHaveBeenCalledWith('beforeCaptureLog', log);
278+
expect(beforeCaptureLogSpy).toHaveBeenCalledWith('afterCaptureLog', log);
279+
beforeCaptureLogSpy.mockRestore();
280+
});
190281
});

0 commit comments

Comments
 (0)