Skip to content

Commit 7a64c80

Browse files
committed
feat(core): Add client.captureLog method
1 parent 16e40f4 commit 7a64c80

File tree

9 files changed

+608
-43
lines changed

9 files changed

+608
-43
lines changed

packages/browser/src/client.ts

+6
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,12 @@ export class BrowserClient extends Client<BrowserClientOptions> {
8989
});
9090
}
9191

92+
if (opts._experiments?.enableLogs) {
93+
setInterval(() => {
94+
this._flushLogsBuffer();
95+
}, 5000);
96+
}
97+
9298
if (this._options.sendDefaultPii) {
9399
this.on('postprocessEvent', addAutoIpAddressToUser);
94100
this.on('beforeSendSession', addAutoIpAddressToSession);

packages/core/src/client.ts

+98-2
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import type {
2828
SpanContextData,
2929
SpanJSON,
3030
StartSpanOptions,
31+
TraceContext,
3132
TransactionEvent,
3233
Transport,
3334
TransportMakeRequestResponse,
@@ -44,7 +45,10 @@ import { afterSetupIntegrations } from './integration';
4445
import { setupIntegration, setupIntegrations } from './integration';
4546
import type { Scope } from './scope';
4647
import { updateSession } from './session';
47-
import { getDynamicSamplingContextFromScope } from './tracing/dynamicSamplingContext';
48+
import {
49+
getDynamicSamplingContextFromScope,
50+
getDynamicSamplingContextFromSpan,
51+
} from './tracing/dynamicSamplingContext';
4852
import { createClientReportEnvelope } from './utils-hoist/clientreport';
4953
import { dsnToString, makeDsn } from './utils-hoist/dsn';
5054
import { addItemToEnvelope, createAttachmentEnvelopeItem } from './utils-hoist/envelope';
@@ -57,11 +61,15 @@ import { getPossibleEventMessages } from './utils/eventUtils';
5761
import { merge } from './utils/merge';
5862
import { parseSampleRate } from './utils/parseSampleRate';
5963
import { prepareEvent } from './utils/prepareEvent';
60-
import { showSpanDropWarning } from './utils/spanUtils';
64+
import { showSpanDropWarning, spanToTraceContext } from './utils/spanUtils';
6165
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';
68+
import { _getSpanForScope } from './utils/spanOnScope';
6269

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

6674
/**
6775
* Base implementation for all JavaScript SDK clients.
@@ -117,6 +125,8 @@ export abstract class Client<O extends ClientOptions = ClientOptions> {
117125
// eslint-disable-next-line @typescript-eslint/ban-types
118126
private _hooks: Record<string, Function[]>;
119127

128+
private _logsBuffer: Array<SerializedOtelLog>;
129+
120130
/**
121131
* Initializes this client instance.
122132
*
@@ -129,6 +139,7 @@ export abstract class Client<O extends ClientOptions = ClientOptions> {
129139
this._outcomes = {};
130140
this._hooks = {};
131141
this._eventProcessors = [];
142+
this._logsBuffer = [];
132143

133144
if (options.dsn) {
134145
this._dsn = makeDsn(options.dsn);
@@ -256,6 +267,58 @@ export abstract class Client<O extends ClientOptions = ClientOptions> {
256267
*/
257268
public captureCheckIn?(checkIn: CheckIn, monitorConfig?: MonitorConfig, scope?: Scope): string;
258269

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+
259322
/**
260323
* Get the current Dsn.
261324
*/
@@ -295,6 +358,7 @@ export abstract class Client<O extends ClientOptions = ClientOptions> {
295358
* still events in the queue when the timeout is reached.
296359
*/
297360
public flush(timeout?: number): PromiseLike<boolean> {
361+
this._flushLogsBuffer();
298362
const transport = this._transport;
299363
if (transport) {
300364
this.emit('flush');
@@ -1136,6 +1200,21 @@ export abstract class Client<O extends ClientOptions = ClientOptions> {
11361200
this.sendEnvelope(envelope);
11371201
}
11381202

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+
11391218
/**
11401219
* Creates an {@link Event} from all inputs to `captureException` and non-primitive inputs to `captureMessage`.
11411220
*/
@@ -1256,3 +1335,20 @@ function isErrorEvent(event: Event): event is ErrorEvent {
12561335
function isTransactionEvent(event: Event): event is TransactionEvent {
12571336
return event.type === 'transaction';
12581337
}
1338+
1339+
/** Extract trace information from scope */
1340+
export function _getTraceInfoFromScope(
1341+
client: Client,
1342+
scope: Scope | undefined,
1343+
): [dynamicSamplingContext: Partial<DynamicSamplingContext> | undefined, traceContext: TraceContext | undefined] {
1344+
if (!scope) {
1345+
return [undefined, undefined];
1346+
}
1347+
1348+
const span = _getSpanForScope(scope);
1349+
const traceContext = span ? spanToTraceContext(span) : getTraceContextFromScope(scope);
1350+
const dynamicSamplingContext = span
1351+
? getDynamicSamplingContextFromSpan(span)
1352+
: getDynamicSamplingContextFromScope(client, scope);
1353+
return [dynamicSamplingContext, traceContext];
1354+
}

packages/core/src/log.ts

+91
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
import type {
2+
DsnComponents,
3+
LogSeverityLevel,
4+
SdkMetadata,
5+
SerializedLogAttribute,
6+
SerializedOtelLog,
7+
} from './types-hoist';
8+
import type { OtelLogItem, OtelLogEnvelope } from './types-hoist/envelope';
9+
import { createEnvelope, dsnToString } from './utils-hoist';
10+
11+
export const SEVERITY_TEXT_TO_SEVERITY_NUMBER: Partial<Record<LogSeverityLevel, number>> = {
12+
trace: 1,
13+
debug: 5,
14+
info: 9,
15+
warn: 13,
16+
error: 17,
17+
fatal: 21,
18+
};
19+
20+
/**
21+
* Convert a log attribute to a serialized log attribute
22+
*
23+
* @param key - The key of the log attribute
24+
* @param value - The value of the log attribute
25+
* @returns The serialized log attribute
26+
*/
27+
export function logAttributeToSerializedLogAttribute(key: string, value: unknown): SerializedLogAttribute {
28+
switch (typeof value) {
29+
case 'number':
30+
return {
31+
key,
32+
value: { doubleValue: value },
33+
};
34+
case 'boolean':
35+
return {
36+
key,
37+
value: { boolValue: value },
38+
};
39+
case 'string':
40+
return {
41+
key,
42+
value: { stringValue: value },
43+
};
44+
default:
45+
return {
46+
key,
47+
value: { stringValue: JSON.stringify(value) ?? '' },
48+
};
49+
}
50+
}
51+
52+
/**
53+
* Creates envelope item for a single log
54+
*/
55+
export function createOtelLogEnvelopeItem(log: SerializedOtelLog): OtelLogItem {
56+
const headers: OtelLogItem[0] = {
57+
type: 'otel_log',
58+
};
59+
60+
return [headers, log];
61+
}
62+
63+
/**
64+
* Records a log and sends it to sentry.
65+
*
66+
* Logs represent a message (and optionally some structured data) which provide context for a trace or error.
67+
* Ex: sentry.addLog({level: 'warning', message: `user ${user} just bought ${item}`, attributes: {user, item}}
68+
*
69+
* @params log - the log object which will be sent
70+
*/
71+
export function createOtelLogEnvelope(
72+
logs: Array<SerializedOtelLog>,
73+
metadata?: SdkMetadata,
74+
tunnel?: string,
75+
dsn?: DsnComponents,
76+
): OtelLogEnvelope {
77+
const headers: OtelLogEnvelope[0] = {};
78+
79+
if (metadata?.sdk) {
80+
headers.sdk = {
81+
name: metadata.sdk.name,
82+
version: metadata.sdk.version,
83+
};
84+
}
85+
86+
if (!!tunnel && !!dsn) {
87+
headers.dsn = dsnToString(dsn);
88+
}
89+
90+
return createEnvelope<OtelLogEnvelope>(headers, logs.map(createOtelLogEnvelopeItem));
91+
}

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

+4-29
Original file line numberDiff line numberDiff line change
@@ -2,32 +2,24 @@ import type {
22
BaseTransportOptions,
33
CheckIn,
44
ClientOptions,
5-
DynamicSamplingContext,
65
Event,
76
EventHint,
87
MonitorConfig,
98
ParameterizedString,
109
SerializedCheckIn,
1110
SeverityLevel,
12-
TraceContext,
1311
} from './types-hoist';
1412

1513
import { createCheckInEnvelope } from './checkin';
16-
import { Client } from './client';
17-
import { getIsolationScope, getTraceContextFromScope } from './currentScopes';
14+
import { Client, _getTraceInfoFromScope } from './client';
15+
import { getIsolationScope } from './currentScopes';
1816
import { DEBUG_BUILD } from './debug-build';
1917
import type { Scope } from './scope';
20-
import {
21-
getDynamicSamplingContextFromScope,
22-
getDynamicSamplingContextFromSpan,
23-
registerSpanErrorInstrumentation,
24-
} from './tracing';
18+
import { registerSpanErrorInstrumentation } from './tracing';
2519
import { eventFromMessage, eventFromUnknownInput } from './utils-hoist/eventbuilder';
2620
import { logger } from './utils-hoist/logger';
2721
import { uuid4 } from './utils-hoist/misc';
2822
import { resolvedSyncPromise } from './utils-hoist/syncpromise';
29-
import { _getSpanForScope } from './utils/spanOnScope';
30-
import { spanToTraceContext } from './utils/spanUtils';
3123

3224
export interface ServerRuntimeClientOptions extends ClientOptions<BaseTransportOptions> {
3325
platform?: string;
@@ -136,7 +128,7 @@ export class ServerRuntimeClient<
136128
};
137129
}
138130

139-
const [dynamicSamplingContext, traceContext] = this._getTraceInfoFromScope(scope);
131+
const [dynamicSamplingContext, traceContext] = _getTraceInfoFromScope(this, scope);
140132
if (traceContext) {
141133
serializedCheckIn.contexts = {
142134
trace: traceContext,
@@ -186,23 +178,6 @@ export class ServerRuntimeClient<
186178

187179
return super._prepareEvent(event, hint, currentScope, isolationScope);
188180
}
189-
190-
/** Extract trace information from scope */
191-
protected _getTraceInfoFromScope(
192-
scope: Scope | undefined,
193-
): [dynamicSamplingContext: Partial<DynamicSamplingContext> | undefined, traceContext: TraceContext | undefined] {
194-
if (!scope) {
195-
return [undefined, undefined];
196-
}
197-
198-
const span = _getSpanForScope(scope);
199-
200-
const traceContext = span ? spanToTraceContext(span) : getTraceContextFromScope(scope);
201-
const dynamicSamplingContext = span
202-
? getDynamicSamplingContextFromSpan(span)
203-
: getDynamicSamplingContextFromScope(this, scope);
204-
return [dynamicSamplingContext, traceContext];
205-
}
206181
}
207182

208183
function setCurrentRequestSessionErroredOrCrashed(eventHint?: EventHint): void {

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

+6-1
Original file line numberDiff line numberDiff line change
@@ -110,7 +110,12 @@ export type {
110110
TraceFlag,
111111
} from './span';
112112
export type { SpanStatus } from './spanStatus';
113-
export type { SerializedOtelLog, LogAttribute, LogSeverityLevel, LogAttributeValueType } from './log';
113+
export type {
114+
LogSeverityLevel,
115+
SerializedOtelLog,
116+
SerializedLogAttribute,
117+
SerializedLogAttributeValueType,
118+
} from './log';
114119
export type { TimedEvent } from './timedEvent';
115120
export type { StackFrame } from './stackframe';
116121
export type { Stacktrace, StackParser, StackLineParser, StackLineParserFn } from './stacktrace';

0 commit comments

Comments
 (0)