Skip to content

feat(core): Add support for beforeSendLog #15814

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
merged 1 commit into from
Mar 25, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
16 changes: 14 additions & 2 deletions packages/core/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -623,12 +623,19 @@ export abstract class Client<O extends ClientOptions = ClientOptions> {
public on(hook: 'close', callback: () => void): () => void;

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

/**
* A hook that is called after a log is captured
*
* @returns {() => void} A function that, when executed, removes the registered callback.
*/
public on(hook: 'afterCaptureLog', callback: (log: Log) => void): () => void;

/**
* Register a hook on this client.
*/
Expand Down Expand Up @@ -777,10 +784,15 @@ export abstract class Client<O extends ClientOptions = ClientOptions> {
public emit(hook: 'close'): void;

/**
* Emit a hook event for client before capturing a log
* Emit a hook event for client before capturing a log. This hooks runs before `beforeSendLog` is fired.
*/
public emit(hook: 'beforeCaptureLog', log: Log): void;

/**
* Emit a hook event for client after capturing a log.
*/
public emit(hook: 'afterCaptureLog', log: Log): void;

/**
* Emit a hook that was previously registered via `on()`.
*/
Expand Down
16 changes: 13 additions & 3 deletions packages/core/src/logs/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,18 +62,28 @@ export function logAttributeToSerializedLogAttribute(key: string, value: unknown
* @experimental This method will experience breaking changes. This is not yet part of
* the stable Sentry SDK API and can be changed or removed without warning.
*/
export function _INTERNAL_captureLog(log: Log, client = getClient(), scope = getCurrentScope()): void {
export function _INTERNAL_captureLog(beforeLog: Log, client = getClient(), scope = getCurrentScope()): void {
if (!client) {
DEBUG_BUILD && logger.warn('No client available to capture log.');
return;
}

const { _experiments, release, environment } = client.getOptions();
if (!_experiments?.enableLogs) {
const { enableLogs = false, beforeSendLog } = _experiments ?? {};
if (!enableLogs) {
DEBUG_BUILD && logger.warn('logging option not enabled, log will not be captured.');
return;
}

client.emit('beforeCaptureLog', beforeLog);

const log = beforeSendLog ? beforeSendLog(beforeLog) : beforeLog;
if (!log) {
client.recordDroppedEvent('before_send', 'log_item', 1);
DEBUG_BUILD && logger.warn('beforeSendLog returned null, log will not be captured.');
return;
}

const [, traceContext] = _getTraceInfoFromScope(client, scope);

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

client.emit('beforeCaptureLog', log);
client.emit('afterCaptureLog', log);
}

/**
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/server-runtime-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ export class ServerRuntimeClient<
_INTERNAL_flushLogsBuffer(client);
});

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

// We flush the logs buffer if it exceeds 0.8 MB
Expand Down
12 changes: 12 additions & 0 deletions packages/core/src/types-hoist/options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type { CaptureContext } from '../scope';
import type { Breadcrumb, BreadcrumbHint } from './breadcrumb';
import type { ErrorEvent, EventHint, TransactionEvent } from './event';
import type { Integration } from './integration';
import type { Log } from './log';
import type { TracesSamplerSamplingContext } from './samplingcontext';
import type { SdkMetadata } from './sdkmetadata';
import type { SpanJSON } from './span';
Expand Down Expand Up @@ -188,6 +189,17 @@ export interface ClientOptions<TO extends BaseTransportOptions = BaseTransportOp
* If logs support should be enabled. Defaults to false.
*/
enableLogs?: boolean;
/**
* An event-processing callback for logs, guaranteed to be invoked after all other log
* processors. This allows a log to be modified or dropped before it's sent.
*
* Note that you must return a valid log from this callback. If you do not wish to modify the log, simply return
* it at the end. Returning `null` will cause the log to be dropped.
*
* @param log The log generated by the SDK.
* @returns A new log that will be sent | null.
*/
beforeSendLog?: (log: Log) => Log | null;
};

/**
Expand Down
91 changes: 91 additions & 0 deletions packages/core/test/lib/log/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
import { TestClient, getDefaultTestClientOptions } from '../../mocks/client';
import * as loggerModule from '../../../src/utils-hoist/logger';
import { Scope } from '../../../src';
import type { Log } from '../../../src/types-hoist/log';

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

Expand Down Expand Up @@ -187,4 +188,94 @@ describe('_INTERNAL_captureLog', () => {
_INTERNAL_flushLogsBuffer(client);
expect(mockSendEnvelope).not.toHaveBeenCalled();
});

it('processes logs through beforeSendLog when provided', () => {
const beforeSendLog = vi.fn().mockImplementation(log => ({
...log,
message: `Modified: ${log.message}`,
attributes: { ...log.attributes, processed: true },
}));

const options = getDefaultTestClientOptions({
dsn: PUBLIC_DSN,
_experiments: { enableLogs: true, beforeSendLog },
});
const client = new TestClient(options);

_INTERNAL_captureLog(
{
level: 'info',
message: 'original message',
attributes: { original: true },
},
client,
undefined,
);

expect(beforeSendLog).toHaveBeenCalledWith({
level: 'info',
message: 'original message',
attributes: { original: true },
});

const logBuffer = _INTERNAL_getLogBuffer(client);
expect(logBuffer).toBeDefined();
expect(logBuffer?.[0]).toEqual(
expect.objectContaining({
body: {
stringValue: 'Modified: original message',
},
attributes: expect.arrayContaining([
expect.objectContaining({ key: 'processed', value: { boolValue: true } }),
expect.objectContaining({ key: 'original', value: { boolValue: true } }),
]),
}),
);
});

it('drops logs when beforeSendLog returns null', () => {
const beforeSendLog = vi.fn().mockReturnValue(null);
const recordDroppedEventSpy = vi.spyOn(TestClient.prototype, 'recordDroppedEvent');
const loggerWarnSpy = vi.spyOn(loggerModule.logger, 'warn').mockImplementation(() => undefined);

const options = getDefaultTestClientOptions({
dsn: PUBLIC_DSN,
_experiments: { enableLogs: true, beforeSendLog },
});
const client = new TestClient(options);

_INTERNAL_captureLog(
{
level: 'info',
message: 'test message',
},
client,
undefined,
);

expect(beforeSendLog).toHaveBeenCalled();
expect(recordDroppedEventSpy).toHaveBeenCalledWith('before_send', 'log_item', 1);
expect(loggerWarnSpy).toHaveBeenCalledWith('beforeSendLog returned null, log will not be captured.');
expect(_INTERNAL_getLogBuffer(client)).toBeUndefined();

recordDroppedEventSpy.mockRestore();
loggerWarnSpy.mockRestore();
});

it('emits beforeCaptureLog and afterCaptureLog events', () => {
const beforeCaptureLogSpy = vi.spyOn(TestClient.prototype, 'emit');
const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, _experiments: { enableLogs: true } });
const client = new TestClient(options);

const log: Log = {
level: 'info',
message: 'test message',
};

_INTERNAL_captureLog(log, client, undefined);

expect(beforeCaptureLogSpy).toHaveBeenCalledWith('beforeCaptureLog', log);
expect(beforeCaptureLogSpy).toHaveBeenCalledWith('afterCaptureLog', log);
beforeCaptureLogSpy.mockRestore();
});
});
Loading