diff --git a/packages/core/src/client.ts b/packages/core/src/client.ts index 07b5224e5928..cf7968804429 100644 --- a/packages/core/src/client.ts +++ b/packages/core/src/client.ts @@ -623,12 +623,19 @@ export abstract class Client { 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. */ @@ -777,10 +784,15 @@ export abstract class Client { 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()`. */ diff --git a/packages/core/src/logs/index.ts b/packages/core/src/logs/index.ts index 56de1b0bdc15..20b654e39532 100644 --- a/packages/core/src/logs/index.ts +++ b/packages/core/src/logs/index.ts @@ -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; @@ -117,7 +127,7 @@ export function _INTERNAL_captureLog(log: Log, client = getClient(), scope = get } } - client.emit('beforeCaptureLog', log); + client.emit('afterCaptureLog', log); } /** diff --git a/packages/core/src/server-runtime-client.ts b/packages/core/src/server-runtime-client.ts index 9f160d7ee25e..16af7b57f7c4 100644 --- a/packages/core/src/server-runtime-client.ts +++ b/packages/core/src/server-runtime-client.ts @@ -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 diff --git a/packages/core/src/types-hoist/options.ts b/packages/core/src/types-hoist/options.ts index d0474b959fa9..51ccc7cdab79 100644 --- a/packages/core/src/types-hoist/options.ts +++ b/packages/core/src/types-hoist/options.ts @@ -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'; @@ -188,6 +189,17 @@ export interface ClientOptions Log | null; }; /** diff --git a/packages/core/test/lib/log/index.test.ts b/packages/core/test/lib/log/index.test.ts index ab7cfe9bb4b4..22e76a99e528 100644 --- a/packages/core/test/lib/log/index.test.ts +++ b/packages/core/test/lib/log/index.test.ts @@ -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'; @@ -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(); + }); });