Skip to content
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.

Commit 993e004

Browse files
committedMar 17, 2025·
feat: Show how to implement outside of client
1 parent d05e232 commit 993e004

File tree

11 files changed

+582
-541
lines changed

11 files changed

+582
-541
lines changed
 

‎packages/browser/src/client.ts

+2-1
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import {
1515
addAutoIpAddressToUser,
1616
applySdkMetadata,
1717
getSDKSource,
18+
_INTERNAL_flushLogsBuffer,
1819
} from '@sentry/core';
1920
import { eventFromException, eventFromMessage } from './eventbuilder';
2021
import { WINDOW } from './helpers';
@@ -91,7 +92,7 @@ export class BrowserClient extends Client<BrowserClientOptions> {
9192

9293
if (opts._experiments?.enableLogs) {
9394
setInterval(() => {
94-
this._flushLogsBuffer();
95+
_INTERNAL_flushLogsBuffer(this);
9596
}, 5000);
9697
}
9798

‎packages/core/src/client.ts

+1-74
Original file line numberDiff line numberDiff line change
@@ -63,13 +63,11 @@ import { parseSampleRate } from './utils/parseSampleRate';
6363
import { prepareEvent } from './utils/prepareEvent';
6464
import { showSpanDropWarning, spanToTraceContext } from './utils/spanUtils';
6565
import { convertSpanJsonToTransactionEvent, convertTransactionEventToSpanJson } from './utils/transactionEvent';
66-
import type { Log, SerializedOtelLog } from './types-hoist/log';
67-
import { SEVERITY_TEXT_TO_SEVERITY_NUMBER, createOtelLogEnvelope, logAttributeToSerializedLogAttribute } from './log';
66+
import type { SerializedOtelLog } from './types-hoist/log';
6867
import { _getSpanForScope } from './utils/spanOnScope';
6968

7069
const ALREADY_SEEN_ERROR = "Not capturing exception because it's already been captured.";
7170
const MISSING_RELEASE_FOR_SESSION_ERROR = 'Discarded session because of missing or non-string release';
72-
const MAX_LOG_BUFFER_SIZE = 100;
7371

7472
/**
7573
* Base implementation for all JavaScript SDK clients.
@@ -125,8 +123,6 @@ export abstract class Client<O extends ClientOptions = ClientOptions> {
125123
// eslint-disable-next-line @typescript-eslint/ban-types
126124
private _hooks: Record<string, Function[]>;
127125

128-
private _logsBuffer: Array<SerializedOtelLog>;
129-
130126
/**
131127
* Initializes this client instance.
132128
*
@@ -139,7 +135,6 @@ export abstract class Client<O extends ClientOptions = ClientOptions> {
139135
this._outcomes = {};
140136
this._hooks = {};
141137
this._eventProcessors = [];
142-
this._logsBuffer = [];
143138

144139
if (options.dsn) {
145140
this._dsn = makeDsn(options.dsn);
@@ -267,58 +262,6 @@ export abstract class Client<O extends ClientOptions = ClientOptions> {
267262
*/
268263
public captureCheckIn?(checkIn: CheckIn, monitorConfig?: MonitorConfig, scope?: Scope): string;
269264

270-
/**
271-
* Captures a log event and sends it to Sentry.
272-
*
273-
* @param log The log event to capture.
274-
*
275-
* @experimental This method will experience breaking changes. This is not yet part of
276-
* the stable Sentry SDK API and can be changed or removed without warning.
277-
*/
278-
public captureLog({ level, message, attributes, severityNumber }: Log, currentScope = getCurrentScope()): void {
279-
const { _experiments, release, environment } = this.getOptions();
280-
if (!_experiments?.enableLogs) {
281-
DEBUG_BUILD && logger.warn('logging option not enabled, log will not be captured.');
282-
return;
283-
}
284-
285-
const [, traceContext] = _getTraceInfoFromScope(this, currentScope);
286-
287-
const logAttributes = {
288-
...attributes,
289-
};
290-
291-
if (release) {
292-
logAttributes.release = release;
293-
}
294-
295-
if (environment) {
296-
logAttributes.environment = environment;
297-
}
298-
299-
const span = _getSpanForScope(currentScope);
300-
if (span) {
301-
// Add the parent span ID to the log attributes for trace context
302-
logAttributes['sentry.trace.parent_span_id'] = span.spanContext().spanId;
303-
}
304-
305-
const serializedLog: SerializedOtelLog = {
306-
severityText: level,
307-
body: {
308-
stringValue: message,
309-
},
310-
attributes: Object.entries(logAttributes).map(([key, value]) => logAttributeToSerializedLogAttribute(key, value)),
311-
timeUnixNano: `${new Date().getTime().toString()}000000`,
312-
traceId: traceContext?.trace_id,
313-
severityNumber: severityNumber ?? SEVERITY_TEXT_TO_SEVERITY_NUMBER[level],
314-
};
315-
316-
this._logsBuffer.push(serializedLog);
317-
if (this._logsBuffer.length > MAX_LOG_BUFFER_SIZE) {
318-
this._flushLogsBuffer();
319-
}
320-
}
321-
322265
/**
323266
* Get the current Dsn.
324267
*/
@@ -358,7 +301,6 @@ export abstract class Client<O extends ClientOptions = ClientOptions> {
358301
* still events in the queue when the timeout is reached.
359302
*/
360303
public flush(timeout?: number): PromiseLike<boolean> {
361-
this._flushLogsBuffer();
362304
const transport = this._transport;
363305
if (transport) {
364306
this.emit('flush');
@@ -1200,21 +1142,6 @@ export abstract class Client<O extends ClientOptions = ClientOptions> {
12001142
this.sendEnvelope(envelope);
12011143
}
12021144

1203-
/**
1204-
* Flushes the logs buffer to Sentry.
1205-
*/
1206-
protected _flushLogsBuffer(): void {
1207-
if (this._logsBuffer.length === 0) {
1208-
return;
1209-
}
1210-
1211-
const envelope = createOtelLogEnvelope(this._logsBuffer, this._options._metadata, this._options.tunnel, this._dsn);
1212-
this._logsBuffer = [];
1213-
// sendEnvelope should not throw
1214-
// eslint-disable-next-line @typescript-eslint/no-floating-promises
1215-
this.sendEnvelope(envelope);
1216-
}
1217-
12181145
/**
12191146
* Creates an {@link Event} from all inputs to `captureException` and non-primitive inputs to `captureMessage`.
12201147
*/

‎packages/core/src/index.ts

+1
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,7 @@ export { instrumentFetchRequest } from './fetch';
113113
export { trpcMiddleware } from './trpc';
114114
export { captureFeedback } from './feedback';
115115
export type { ReportDialogOptions } from './report-dialog';
116+
export { _INTERNAL_flushLogsBuffer } from './logs';
116117

117118
// TODO: Make this structure pretty again and don't do "export *"
118119
export * from './utils-hoist/index';

‎packages/core/src/log.ts

-91
This file was deleted.

‎packages/core/src/logs/constants.ts

+10
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import type { LogSeverityLevel } from '../types-hoist';
2+
3+
export const SEVERITY_TEXT_TO_SEVERITY_NUMBER: Partial<Record<LogSeverityLevel, number>> = {
4+
trace: 1,
5+
debug: 5,
6+
info: 9,
7+
warn: 13,
8+
error: 17,
9+
fatal: 21,
10+
};

‎packages/core/src/logs/envelope.ts

+46
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import { createEnvelope } from '../utils-hoist';
2+
3+
import type { DsnComponents, SdkMetadata, SerializedOtelLog } from '../types-hoist';
4+
import type { OtelLogEnvelope, OtelLogItem } from '../types-hoist/envelope';
5+
import { dsnToString } from '../utils-hoist';
6+
7+
/**
8+
* Creates envelope item for a single log
9+
*/
10+
export function createOtelLogEnvelopeItem(log: SerializedOtelLog): OtelLogItem {
11+
const headers: OtelLogItem[0] = {
12+
type: 'otel_log',
13+
};
14+
15+
return [headers, log];
16+
}
17+
18+
/**
19+
* Records a log and sends it to sentry.
20+
*
21+
* Logs represent a message (and optionally some structured data) which provide context for a trace or error.
22+
* Ex: sentry.addLog({level: 'warning', message: `user ${user} just bought ${item}`, attributes: {user, item}}
23+
*
24+
* @params log - the log object which will be sent
25+
*/
26+
export function createOtelLogEnvelope(
27+
logs: Array<SerializedOtelLog>,
28+
metadata?: SdkMetadata,
29+
tunnel?: string,
30+
dsn?: DsnComponents,
31+
): OtelLogEnvelope {
32+
const headers: OtelLogEnvelope[0] = {};
33+
34+
if (metadata?.sdk) {
35+
headers.sdk = {
36+
name: metadata.sdk.name,
37+
version: metadata.sdk.version,
38+
};
39+
}
40+
41+
if (!!tunnel && !!dsn) {
42+
headers.dsn = dsnToString(dsn);
43+
}
44+
45+
return createEnvelope<OtelLogEnvelope>(headers, logs.map(createOtelLogEnvelopeItem));
46+
}

‎packages/core/src/logs/index.ts

+144
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
import type { Client } from '../client';
2+
import { _getTraceInfoFromScope } from '../client';
3+
import { getClient, getCurrentScope } from '../currentScopes';
4+
import { DEBUG_BUILD } from '../debug-build';
5+
import { SEVERITY_TEXT_TO_SEVERITY_NUMBER } from './constants';
6+
import type { SerializedLogAttribute, SerializedOtelLog } from '../types-hoist';
7+
import type { Log } from '../types-hoist/log';
8+
import { logger } from '../utils-hoist';
9+
import { _getSpanForScope } from '../utils/spanOnScope';
10+
import { createOtelLogEnvelope } from './envelope';
11+
12+
const MAX_LOG_BUFFER_SIZE = 100;
13+
14+
const CLIENT_TO_LOG_BUFFER_MAP = new WeakMap<Client, Array<SerializedOtelLog>>();
15+
16+
/**
17+
* Convert a log attribute to a serialized log attribute
18+
*
19+
* @param key - The key of the log attribute
20+
* @param value - The value of the log attribute
21+
* @returns The serialized log attribute
22+
*/
23+
export function logAttributeToSerializedLogAttribute(key: string, value: unknown): SerializedLogAttribute {
24+
switch (typeof value) {
25+
case 'number':
26+
return {
27+
key,
28+
value: { doubleValue: value },
29+
};
30+
case 'boolean':
31+
return {
32+
key,
33+
value: { boolValue: value },
34+
};
35+
case 'string':
36+
return {
37+
key,
38+
value: { stringValue: value },
39+
};
40+
default:
41+
return {
42+
key,
43+
value: { stringValue: JSON.stringify(value) ?? '' },
44+
};
45+
}
46+
}
47+
48+
/**
49+
* Captures a log event and sends it to Sentry.
50+
*
51+
* @param log The log event to capture.
52+
*
53+
* @experimental This method will experience breaking changes. This is not yet part of
54+
* the stable Sentry SDK API and can be changed or removed without warning.
55+
*/
56+
export function captureLog(
57+
{ level, message, attributes, severityNumber }: Log,
58+
currentScope = getCurrentScope(),
59+
client = getClient(),
60+
): void {
61+
if (!client) {
62+
DEBUG_BUILD && logger.warn('No client available to capture log.');
63+
return;
64+
}
65+
66+
const { _experiments, release, environment } = client.getOptions();
67+
if (!_experiments?.enableLogs) {
68+
DEBUG_BUILD && logger.warn('logging option not enabled, log will not be captured.');
69+
return;
70+
}
71+
72+
const [, traceContext] = _getTraceInfoFromScope(client, currentScope);
73+
74+
const logAttributes = {
75+
...attributes,
76+
};
77+
78+
if (release) {
79+
logAttributes.release = release;
80+
}
81+
82+
if (environment) {
83+
logAttributes.environment = environment;
84+
}
85+
86+
const span = _getSpanForScope(currentScope);
87+
if (span) {
88+
// Add the parent span ID to the log attributes for trace context
89+
logAttributes['sentry.trace.parent_span_id'] = span.spanContext().spanId;
90+
}
91+
92+
const serializedLog: SerializedOtelLog = {
93+
severityText: level,
94+
body: {
95+
stringValue: message,
96+
},
97+
attributes: Object.entries(logAttributes).map(([key, value]) => logAttributeToSerializedLogAttribute(key, value)),
98+
timeUnixNano: `${new Date().getTime().toString()}000000`,
99+
traceId: traceContext?.trace_id,
100+
severityNumber: severityNumber ?? SEVERITY_TEXT_TO_SEVERITY_NUMBER[level],
101+
};
102+
103+
const logBuffer = CLIENT_TO_LOG_BUFFER_MAP.get(client);
104+
if (logBuffer === undefined) {
105+
CLIENT_TO_LOG_BUFFER_MAP.set(client, [serializedLog]);
106+
// Every time we initialize a new log buffer, we start a new interval to flush the buffer
107+
return;
108+
}
109+
110+
logBuffer.push(serializedLog);
111+
if (logBuffer.length > MAX_LOG_BUFFER_SIZE) {
112+
_INTERNAL_flushLogsBuffer(client, logBuffer);
113+
}
114+
}
115+
116+
/**
117+
* Flushes the logs buffer to Sentry.
118+
*/
119+
export function _INTERNAL_flushLogsBuffer(client: Client, maybeLogBuffer?: Array<SerializedOtelLog>): void {
120+
const logBuffer = maybeLogBuffer ?? CLIENT_TO_LOG_BUFFER_MAP.get(client) ?? [];
121+
if (logBuffer.length === 0) {
122+
return;
123+
}
124+
125+
const clientOptions = client.getOptions();
126+
const envelope = createOtelLogEnvelope(logBuffer, clientOptions._metadata, clientOptions.tunnel, client.getDsn());
127+
128+
// Clear the log buffer after envelopes have been constructed.
129+
logBuffer.length = 0;
130+
131+
// sendEnvelope should not throw
132+
// eslint-disable-next-line @typescript-eslint/no-floating-promises
133+
client.sendEnvelope(envelope);
134+
}
135+
136+
/**
137+
* Returns the log buffer for a given client. Exported for testing purposes.
138+
*
139+
* @param client - The client to get the log buffer for.
140+
* @returns The log buffer for the given client.
141+
*/
142+
export function _INTERNAL_getLogBuffer(client: Client): Array<SerializedOtelLog> | undefined {
143+
return CLIENT_TO_LOG_BUFFER_MAP.get(client);
144+
}

‎packages/core/test/lib/client.test.ts

+1-126
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ declare var global: any;
3131
const clientEventFromException = vi.spyOn(TestClient.prototype, 'eventFromException');
3232
const clientProcess = vi.spyOn(TestClient.prototype as any, '_process');
3333

34-
const uuid4Spy = vi.spyOn(miscModule, 'uuid4').mockImplementation(() => '12312012123120121231201212312012');
34+
vi.spyOn(miscModule, 'uuid4').mockImplementation(() => '12312012123120121231201212312012');
3535
vi.spyOn(loggerModule, 'consoleSandbox').mockImplementation(cb => cb());
3636
vi.spyOn(stringModule, 'truncate').mockImplementation(str => str);
3737
vi.spyOn(timeModule, 'dateTimestampInSeconds').mockImplementation(() => 2020);
@@ -1723,131 +1723,6 @@ describe('Client', () => {
17231723
});
17241724
});
17251725

1726-
describe('captureLog', () => {
1727-
test('captures and sends logs', () => {
1728-
const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, _experiments: { enableLogs: true } });
1729-
const client = new TestClient(options);
1730-
1731-
client.captureLog({ level: 'info', message: 'test log message' });
1732-
1733-
expect((client as any)._logsBuffer).toHaveLength(1);
1734-
expect((client as any)._logsBuffer[0]).toEqual(
1735-
expect.objectContaining({
1736-
severityText: 'info',
1737-
body: {
1738-
stringValue: 'test log message',
1739-
},
1740-
timeUnixNano: expect.any(String),
1741-
}),
1742-
);
1743-
});
1744-
1745-
test('does not capture logs when enableLogs experiment is not enabled', () => {
1746-
const logWarnSpy = vi.spyOn(loggerModule.logger, 'warn').mockImplementation(() => undefined);
1747-
const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN });
1748-
const client = new TestClient(options);
1749-
1750-
client.captureLog({ level: 'info', message: 'test log message' });
1751-
1752-
expect(logWarnSpy).toHaveBeenCalledWith('logging option not enabled, log will not be captured.');
1753-
expect((client as any)._logsBuffer).toHaveLength(0);
1754-
1755-
logWarnSpy.mockRestore();
1756-
});
1757-
1758-
test('includes trace context when available', () => {
1759-
// Temporarily restore the original uuid4 implementation
1760-
const originalMock = uuid4Spy.getMockImplementation();
1761-
uuid4Spy.mockRestore();
1762-
1763-
const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, _experiments: { enableLogs: true } });
1764-
const client = new TestClient(options);
1765-
const scope = new Scope();
1766-
scope.setPropagationContext({
1767-
traceId: '3d9355f71e9c444b81161599adac6e29',
1768-
sampleRand: 1,
1769-
});
1770-
1771-
client.captureLog({ level: 'error', message: 'test log with trace' }, scope);
1772-
1773-
expect((client as any)._logsBuffer[0]).toEqual(
1774-
expect.objectContaining({
1775-
traceId: '3d9355f71e9c444b81161599adac6e29',
1776-
}),
1777-
);
1778-
1779-
// Restore the test-wide mock implementation
1780-
uuid4Spy.mockImplementation(originalMock!);
1781-
});
1782-
1783-
test('includes release and environment in log attributes when available', () => {
1784-
const options = getDefaultTestClientOptions({
1785-
dsn: PUBLIC_DSN,
1786-
_experiments: { enableLogs: true },
1787-
release: '1.0.0',
1788-
environment: 'test',
1789-
});
1790-
const client = new TestClient(options);
1791-
1792-
client.captureLog({ level: 'info', message: 'test log with metadata' });
1793-
1794-
const logAttributes = (client as any)._logsBuffer[0].attributes;
1795-
expect(logAttributes).toEqual(
1796-
expect.arrayContaining([
1797-
expect.objectContaining({ key: 'release', value: { stringValue: '1.0.0' } }),
1798-
expect.objectContaining({ key: 'environment', value: { stringValue: 'test' } }),
1799-
]),
1800-
);
1801-
});
1802-
1803-
test('includes custom attributes in log', () => {
1804-
const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, _experiments: { enableLogs: true } });
1805-
const client = new TestClient(options);
1806-
1807-
client.captureLog({
1808-
level: 'info',
1809-
message: 'test log with custom attributes',
1810-
attributes: { userId: '123', component: 'auth' },
1811-
});
1812-
1813-
const logAttributes = (client as any)._logsBuffer[0].attributes;
1814-
expect(logAttributes).toEqual(
1815-
expect.arrayContaining([
1816-
expect.objectContaining({ key: 'userId', value: { stringValue: '123' } }),
1817-
expect.objectContaining({ key: 'component', value: { stringValue: 'auth' } }),
1818-
]),
1819-
);
1820-
});
1821-
1822-
test('flushes logs buffer when it reaches max size', () => {
1823-
const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, _experiments: { enableLogs: true } });
1824-
const client = new TestClient(options);
1825-
const mockFlushLogsBuffer = vi.spyOn(client as any, '_flushLogsBuffer').mockImplementation(() => {});
1826-
1827-
// Fill the buffer to max size (100 is the MAX_LOG_BUFFER_SIZE constant in client.ts)
1828-
for (let i = 0; i < 100; i++) {
1829-
client.captureLog({ level: 'info', message: `log message ${i}` });
1830-
}
1831-
1832-
expect(mockFlushLogsBuffer).not.toHaveBeenCalled();
1833-
1834-
// Add one more to trigger flush
1835-
client.captureLog({ level: 'info', message: 'trigger flush' });
1836-
1837-
expect(mockFlushLogsBuffer).toHaveBeenCalledTimes(1);
1838-
1839-
mockFlushLogsBuffer.mockRestore();
1840-
});
1841-
1842-
test('does not flush logs buffer when it is empty', () => {
1843-
const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, _experiments: { enableLogs: true } });
1844-
const client = new TestClient(options);
1845-
const mockSendEnvelope = vi.spyOn(client as any, 'sendEnvelope').mockImplementation(() => {});
1846-
client['_flushLogsBuffer']();
1847-
expect(mockSendEnvelope).not.toHaveBeenCalled();
1848-
});
1849-
});
1850-
18511726
describe('integrations', () => {
18521727
beforeEach(() => {
18531728
global.__SENTRY__ = {};

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

-249
This file was deleted.
+187
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,187 @@
1+
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest';
2+
import { createOtelLogEnvelope, createOtelLogEnvelopeItem } from '../../../src/logs/envelope';
3+
import type { DsnComponents, SdkMetadata, SerializedOtelLog } from '../../../src/types-hoist';
4+
import * as utilsHoist from '../../../src/utils-hoist';
5+
6+
// Mock utils-hoist functions
7+
vi.mock('../../../src/utils-hoist', () => ({
8+
createEnvelope: vi.fn((_headers, items) => [_headers, items]),
9+
dsnToString: vi.fn(dsn => `https://${dsn.publicKey}@${dsn.host}/`),
10+
}));
11+
12+
describe('createOtelLogEnvelopeItem', () => {
13+
it('creates an envelope item with correct structure', () => {
14+
const mockLog: SerializedOtelLog = {
15+
severityText: 'error',
16+
body: {
17+
stringValue: 'Test error message',
18+
},
19+
};
20+
21+
const result = createOtelLogEnvelopeItem(mockLog);
22+
23+
expect(result).toHaveLength(2);
24+
expect(result[0]).toEqual({ type: 'otel_log' });
25+
expect(result[1]).toBe(mockLog);
26+
});
27+
});
28+
29+
describe('createOtelLogEnvelope', () => {
30+
beforeEach(() => {
31+
vi.useFakeTimers();
32+
vi.setSystemTime(new Date('2023-01-01T12:00:00Z'));
33+
34+
// Reset mocks
35+
vi.mocked(utilsHoist.createEnvelope).mockClear();
36+
vi.mocked(utilsHoist.dsnToString).mockClear();
37+
});
38+
39+
afterEach(() => {
40+
vi.useRealTimers();
41+
});
42+
43+
it('creates an envelope with basic headers', () => {
44+
const mockLogs: SerializedOtelLog[] = [
45+
{
46+
severityText: 'info',
47+
body: { stringValue: 'Test log message' },
48+
},
49+
];
50+
51+
const result = createOtelLogEnvelope(mockLogs);
52+
53+
expect(result[0]).toEqual({});
54+
55+
// Verify createEnvelope was called with the right parameters
56+
expect(utilsHoist.createEnvelope).toHaveBeenCalledWith({}, expect.any(Array));
57+
});
58+
59+
it('includes SDK info when metadata is provided', () => {
60+
const mockLogs: SerializedOtelLog[] = [
61+
{
62+
severityText: 'info',
63+
body: { stringValue: 'Test log message' },
64+
},
65+
];
66+
67+
const metadata: SdkMetadata = {
68+
sdk: {
69+
name: 'sentry.javascript.node',
70+
version: '7.0.0',
71+
},
72+
};
73+
74+
const result = createOtelLogEnvelope(mockLogs, metadata);
75+
76+
expect(result[0]).toEqual({
77+
sdk: {
78+
name: 'sentry.javascript.node',
79+
version: '7.0.0',
80+
},
81+
});
82+
});
83+
84+
it('includes DSN when tunnel and DSN are provided', () => {
85+
const mockLogs: SerializedOtelLog[] = [
86+
{
87+
severityText: 'info',
88+
body: { stringValue: 'Test log message' },
89+
},
90+
];
91+
92+
const dsn: DsnComponents = {
93+
host: 'example.sentry.io',
94+
path: '/',
95+
projectId: '123',
96+
port: '',
97+
protocol: 'https',
98+
publicKey: 'abc123',
99+
};
100+
101+
const result = createOtelLogEnvelope(mockLogs, undefined, 'https://tunnel.example.com', dsn);
102+
103+
expect(result[0]).toHaveProperty('dsn');
104+
expect(utilsHoist.dsnToString).toHaveBeenCalledWith(dsn);
105+
});
106+
107+
it('maps each log to an envelope item', () => {
108+
const mockLogs: SerializedOtelLog[] = [
109+
{
110+
severityText: 'info',
111+
body: { stringValue: 'First log message' },
112+
},
113+
{
114+
severityText: 'error',
115+
body: { stringValue: 'Second log message' },
116+
},
117+
];
118+
119+
createOtelLogEnvelope(mockLogs);
120+
121+
// Check that createEnvelope was called with an array of envelope items
122+
expect(utilsHoist.createEnvelope).toHaveBeenCalledWith(
123+
expect.anything(),
124+
expect.arrayContaining([
125+
expect.arrayContaining([{ type: 'otel_log' }, mockLogs[0]]),
126+
expect.arrayContaining([{ type: 'otel_log' }, mockLogs[1]]),
127+
]),
128+
);
129+
});
130+
});
131+
132+
describe('Trace context in logs', () => {
133+
it('correctly sets parent_span_id in trace context', () => {
134+
// Create a log with trace context
135+
const mockParentSpanId = 'abcdef1234567890';
136+
const mockTraceId = '00112233445566778899aabbccddeeff';
137+
138+
const mockLog: SerializedOtelLog = {
139+
severityText: 'info',
140+
body: { stringValue: 'Test log with trace context' },
141+
traceId: mockTraceId,
142+
attributes: [
143+
{
144+
key: 'sentry.trace.parent_span_id',
145+
value: { stringValue: mockParentSpanId },
146+
},
147+
{
148+
key: 'some.other.attribute',
149+
value: { stringValue: 'test value' },
150+
},
151+
],
152+
};
153+
154+
// Create an envelope item from this log
155+
const envelopeItem = createOtelLogEnvelopeItem(mockLog);
156+
157+
// Verify the parent_span_id is preserved in the envelope item
158+
expect(envelopeItem[1]).toBe(mockLog);
159+
expect(envelopeItem[1].traceId).toBe(mockTraceId);
160+
expect(envelopeItem[1].attributes).toContainEqual({
161+
key: 'sentry.trace.parent_span_id',
162+
value: { stringValue: mockParentSpanId },
163+
});
164+
165+
// Create an envelope with this log
166+
createOtelLogEnvelope([mockLog]);
167+
168+
// Verify the envelope preserves the trace information
169+
expect(utilsHoist.createEnvelope).toHaveBeenCalledWith(
170+
expect.anything(),
171+
expect.arrayContaining([
172+
expect.arrayContaining([
173+
{ type: 'otel_log' },
174+
expect.objectContaining({
175+
traceId: mockTraceId,
176+
attributes: expect.arrayContaining([
177+
{
178+
key: 'sentry.trace.parent_span_id',
179+
value: { stringValue: mockParentSpanId },
180+
},
181+
]),
182+
}),
183+
]),
184+
]),
185+
);
186+
});
187+
});
+190
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,190 @@
1+
import { describe, expect, it, vi } from 'vitest';
2+
import {
3+
_INTERNAL_flushLogsBuffer,
4+
_INTERNAL_getLogBuffer,
5+
captureLog,
6+
logAttributeToSerializedLogAttribute,
7+
} from '../../../src/logs';
8+
import { TestClient, getDefaultTestClientOptions } from '../../mocks/client';
9+
import * as loggerModule from '../../../src/utils-hoist/logger';
10+
import { Scope } from '../../../src';
11+
12+
const PUBLIC_DSN = 'https://username@domain/123';
13+
14+
describe('logAttributeToSerializedLogAttribute', () => {
15+
it('serializes number values', () => {
16+
const result = logAttributeToSerializedLogAttribute('count', 42);
17+
expect(result).toEqual({
18+
key: 'count',
19+
value: { doubleValue: 42 },
20+
});
21+
});
22+
23+
it('serializes boolean values', () => {
24+
const result = logAttributeToSerializedLogAttribute('enabled', true);
25+
expect(result).toEqual({
26+
key: 'enabled',
27+
value: { boolValue: true },
28+
});
29+
});
30+
31+
it('serializes string values', () => {
32+
const result = logAttributeToSerializedLogAttribute('username', 'john_doe');
33+
expect(result).toEqual({
34+
key: 'username',
35+
value: { stringValue: 'john_doe' },
36+
});
37+
});
38+
39+
it('serializes object values as JSON strings', () => {
40+
const obj = { name: 'John', age: 30 };
41+
const result = logAttributeToSerializedLogAttribute('user', obj);
42+
expect(result).toEqual({
43+
key: 'user',
44+
value: { stringValue: JSON.stringify(obj) },
45+
});
46+
});
47+
48+
it('serializes array values as JSON strings', () => {
49+
const array = [1, 2, 3, 'test'];
50+
const result = logAttributeToSerializedLogAttribute('items', array);
51+
expect(result).toEqual({
52+
key: 'items',
53+
value: { stringValue: JSON.stringify(array) },
54+
});
55+
});
56+
57+
it('serializes undefined values as empty strings', () => {
58+
const result = logAttributeToSerializedLogAttribute('missing', undefined);
59+
expect(result).toEqual({
60+
key: 'missing',
61+
value: { stringValue: '' },
62+
});
63+
});
64+
65+
it('serializes null values as JSON strings', () => {
66+
const result = logAttributeToSerializedLogAttribute('empty', null);
67+
expect(result).toEqual({
68+
key: 'empty',
69+
value: { stringValue: 'null' },
70+
});
71+
});
72+
});
73+
74+
describe('captureLog', () => {
75+
it('captures and sends logs', () => {
76+
const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, _experiments: { enableLogs: true } });
77+
const client = new TestClient(options);
78+
79+
captureLog({ level: 'info', message: 'test log message' }, undefined, client);
80+
expect(_INTERNAL_getLogBuffer(client)).toHaveLength(1);
81+
expect(_INTERNAL_getLogBuffer(client)?.[0]).toEqual(
82+
expect.objectContaining({
83+
severityText: 'info',
84+
body: {
85+
stringValue: 'test log message',
86+
},
87+
timeUnixNano: expect.any(String),
88+
}),
89+
);
90+
});
91+
92+
it('does not capture logs when enableLogs experiment is not enabled', () => {
93+
const logWarnSpy = vi.spyOn(loggerModule.logger, 'warn').mockImplementation(() => undefined);
94+
const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN });
95+
const client = new TestClient(options);
96+
97+
captureLog({ level: 'info', message: 'test log message' }, undefined, client);
98+
99+
expect(logWarnSpy).toHaveBeenCalledWith('logging option not enabled, log will not be captured.');
100+
expect(_INTERNAL_getLogBuffer(client)).toBeUndefined();
101+
102+
logWarnSpy.mockRestore();
103+
});
104+
105+
it('includes trace context when available', () => {
106+
const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, _experiments: { enableLogs: true } });
107+
const client = new TestClient(options);
108+
const scope = new Scope();
109+
scope.setPropagationContext({
110+
traceId: '3d9355f71e9c444b81161599adac6e29',
111+
sampleRand: 1,
112+
});
113+
114+
captureLog({ level: 'error', message: 'test log with trace' }, scope, client);
115+
116+
expect(_INTERNAL_getLogBuffer(client)?.[0]).toEqual(
117+
expect.objectContaining({
118+
traceId: '3d9355f71e9c444b81161599adac6e29',
119+
}),
120+
);
121+
});
122+
123+
it('includes release and environment in log attributes when available', () => {
124+
const options = getDefaultTestClientOptions({
125+
dsn: PUBLIC_DSN,
126+
_experiments: { enableLogs: true },
127+
release: '1.0.0',
128+
environment: 'test',
129+
});
130+
const client = new TestClient(options);
131+
132+
captureLog({ level: 'info', message: 'test log with metadata' }, undefined, client);
133+
134+
const logAttributes = _INTERNAL_getLogBuffer(client)?.[0]?.attributes;
135+
expect(logAttributes).toEqual(
136+
expect.arrayContaining([
137+
expect.objectContaining({ key: 'release', value: { stringValue: '1.0.0' } }),
138+
expect.objectContaining({ key: 'environment', value: { stringValue: 'test' } }),
139+
]),
140+
);
141+
});
142+
143+
it('includes custom attributes in log', () => {
144+
const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, _experiments: { enableLogs: true } });
145+
const client = new TestClient(options);
146+
147+
captureLog(
148+
{
149+
level: 'info',
150+
message: 'test log with custom attributes',
151+
attributes: { userId: '123', component: 'auth' },
152+
},
153+
undefined,
154+
client,
155+
);
156+
157+
const logAttributes = _INTERNAL_getLogBuffer(client)?.[0]?.attributes;
158+
expect(logAttributes).toEqual(
159+
expect.arrayContaining([
160+
expect.objectContaining({ key: 'userId', value: { stringValue: '123' } }),
161+
expect.objectContaining({ key: 'component', value: { stringValue: 'auth' } }),
162+
]),
163+
);
164+
});
165+
166+
it('flushes logs buffer when it reaches max size', () => {
167+
const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, _experiments: { enableLogs: true } });
168+
const client = new TestClient(options);
169+
170+
// Fill the buffer to max size (100 is the MAX_LOG_BUFFER_SIZE constant in client.ts)
171+
for (let i = 0; i < 100; i++) {
172+
captureLog({ level: 'info', message: `log message ${i}` }, undefined, client);
173+
}
174+
175+
expect(_INTERNAL_getLogBuffer(client)).toHaveLength(100);
176+
177+
// Add one more to trigger flush
178+
captureLog({ level: 'info', message: 'trigger flush' }, undefined, client);
179+
180+
expect(_INTERNAL_getLogBuffer(client)).toEqual([]);
181+
});
182+
183+
it('does not flush logs buffer when it is empty', () => {
184+
const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, _experiments: { enableLogs: true } });
185+
const client = new TestClient(options);
186+
const mockSendEnvelope = vi.spyOn(client as any, 'sendEnvelope').mockImplementation(() => {});
187+
_INTERNAL_flushLogsBuffer(client);
188+
expect(mockSendEnvelope).not.toHaveBeenCalled();
189+
});
190+
});

0 commit comments

Comments
 (0)
Please sign in to comment.