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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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();
});
});
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content
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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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();
});
});
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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();
});
});
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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();
});
});
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content
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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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();
});
});
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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();
});
});
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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();
});
});
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content
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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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();
});
});