diff --git a/packages/browser/src/client.ts b/packages/browser/src/client.ts index 0e5b3fb6214c..bc3f53bf5b45 100644 --- a/packages/browser/src/client.ts +++ b/packages/browser/src/client.ts @@ -89,6 +89,12 @@ export class BrowserClient extends Client { }); } + if (opts._experiments?.enableLogs) { + setInterval(() => { + this._flushLogsBuffer(); + }, 5000); + } + if (this._options.sendDefaultPii) { this.on('postprocessEvent', addAutoIpAddressToUser); this.on('beforeSendSession', addAutoIpAddressToSession); diff --git a/packages/core/src/client.ts b/packages/core/src/client.ts index b3021ce087c9..7702eca23b38 100644 --- a/packages/core/src/client.ts +++ b/packages/core/src/client.ts @@ -28,6 +28,7 @@ import type { SpanContextData, SpanJSON, StartSpanOptions, + TraceContext, TransactionEvent, Transport, TransportMakeRequestResponse, @@ -44,7 +45,10 @@ import { afterSetupIntegrations } from './integration'; import { setupIntegration, setupIntegrations } from './integration'; import type { Scope } from './scope'; import { updateSession } from './session'; -import { getDynamicSamplingContextFromScope } from './tracing/dynamicSamplingContext'; +import { + getDynamicSamplingContextFromScope, + getDynamicSamplingContextFromSpan, +} from './tracing/dynamicSamplingContext'; import { createClientReportEnvelope } from './utils-hoist/clientreport'; import { dsnToString, makeDsn } from './utils-hoist/dsn'; import { addItemToEnvelope, createAttachmentEnvelopeItem } from './utils-hoist/envelope'; @@ -57,11 +61,15 @@ import { getPossibleEventMessages } from './utils/eventUtils'; import { merge } from './utils/merge'; import { parseSampleRate } from './utils/parseSampleRate'; import { prepareEvent } from './utils/prepareEvent'; -import { showSpanDropWarning } from './utils/spanUtils'; +import { showSpanDropWarning, spanToTraceContext } from './utils/spanUtils'; import { convertSpanJsonToTransactionEvent, convertTransactionEventToSpanJson } from './utils/transactionEvent'; +import type { Log, SerializedOtelLog } from './types-hoist/log'; +import { SEVERITY_TEXT_TO_SEVERITY_NUMBER, createOtelLogEnvelope, logAttributeToSerializedLogAttribute } from './log'; +import { _getSpanForScope } from './utils/spanOnScope'; const ALREADY_SEEN_ERROR = "Not capturing exception because it's already been captured."; const MISSING_RELEASE_FOR_SESSION_ERROR = 'Discarded session because of missing or non-string release'; +const MAX_LOG_BUFFER_SIZE = 100; /** * Base implementation for all JavaScript SDK clients. @@ -117,6 +125,8 @@ export abstract class Client { // eslint-disable-next-line @typescript-eslint/ban-types private _hooks: Record; + private _logsBuffer: Array; + /** * Initializes this client instance. * @@ -129,6 +139,7 @@ export abstract class Client { this._outcomes = {}; this._hooks = {}; this._eventProcessors = []; + this._logsBuffer = []; if (options.dsn) { this._dsn = makeDsn(options.dsn); @@ -256,6 +267,58 @@ export abstract class Client { */ public captureCheckIn?(checkIn: CheckIn, monitorConfig?: MonitorConfig, scope?: Scope): string; + /** + * Captures a log event and sends it to Sentry. + * + * @param log The log event to capture. + * + * @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. + */ + public captureLog({ level, message, attributes, severityNumber }: Log, currentScope = getCurrentScope()): void { + const { _experiments, release, environment } = this.getOptions(); + if (!_experiments?.enableLogs) { + DEBUG_BUILD && logger.warn('logging option not enabled, log will not be captured.'); + return; + } + + const [, traceContext] = _getTraceInfoFromScope(this, currentScope); + + const logAttributes = { + ...attributes, + }; + + if (release) { + logAttributes.release = release; + } + + if (environment) { + logAttributes.environment = environment; + } + + const span = _getSpanForScope(currentScope); + if (span) { + // Add the parent span ID to the log attributes for trace context + logAttributes['sentry.trace.parent_span_id'] = span.spanContext().spanId; + } + + const serializedLog: SerializedOtelLog = { + severityText: level, + body: { + stringValue: message, + }, + attributes: Object.entries(logAttributes).map(([key, value]) => logAttributeToSerializedLogAttribute(key, value)), + timeUnixNano: `${new Date().getTime().toString()}000000`, + traceId: traceContext?.trace_id, + severityNumber: severityNumber ?? SEVERITY_TEXT_TO_SEVERITY_NUMBER[level], + }; + + this._logsBuffer.push(serializedLog); + if (this._logsBuffer.length > MAX_LOG_BUFFER_SIZE) { + this._flushLogsBuffer(); + } + } + /** * Get the current Dsn. */ @@ -295,6 +358,7 @@ export abstract class Client { * still events in the queue when the timeout is reached. */ public flush(timeout?: number): PromiseLike { + this._flushLogsBuffer(); const transport = this._transport; if (transport) { this.emit('flush'); @@ -1136,6 +1200,21 @@ export abstract class Client { this.sendEnvelope(envelope); } + /** + * Flushes the logs buffer to Sentry. + */ + protected _flushLogsBuffer(): void { + if (this._logsBuffer.length === 0) { + return; + } + + const envelope = createOtelLogEnvelope(this._logsBuffer, this._options._metadata, this._options.tunnel, this._dsn); + this._logsBuffer = []; + // sendEnvelope should not throw + // eslint-disable-next-line @typescript-eslint/no-floating-promises + this.sendEnvelope(envelope); + } + /** * Creates an {@link Event} from all inputs to `captureException` and non-primitive inputs to `captureMessage`. */ @@ -1256,3 +1335,20 @@ function isErrorEvent(event: Event): event is ErrorEvent { function isTransactionEvent(event: Event): event is TransactionEvent { return event.type === 'transaction'; } + +/** Extract trace information from scope */ +export function _getTraceInfoFromScope( + client: Client, + scope: Scope | undefined, +): [dynamicSamplingContext: Partial | undefined, traceContext: TraceContext | undefined] { + if (!scope) { + return [undefined, undefined]; + } + + const span = _getSpanForScope(scope); + const traceContext = span ? spanToTraceContext(span) : getTraceContextFromScope(scope); + const dynamicSamplingContext = span + ? getDynamicSamplingContextFromSpan(span) + : getDynamicSamplingContextFromScope(client, scope); + return [dynamicSamplingContext, traceContext]; +} diff --git a/packages/core/src/log.ts b/packages/core/src/log.ts new file mode 100644 index 000000000000..ffddbd3a0370 --- /dev/null +++ b/packages/core/src/log.ts @@ -0,0 +1,91 @@ +import type { + DsnComponents, + LogSeverityLevel, + SdkMetadata, + SerializedLogAttribute, + SerializedOtelLog, +} from './types-hoist'; +import type { OtelLogItem, OtelLogEnvelope } from './types-hoist/envelope'; +import { createEnvelope, dsnToString } from './utils-hoist'; + +export const SEVERITY_TEXT_TO_SEVERITY_NUMBER: Partial> = { + trace: 1, + debug: 5, + info: 9, + warn: 13, + error: 17, + fatal: 21, +}; + +/** + * Convert a log attribute to a serialized log attribute + * + * @param key - The key of the log attribute + * @param value - The value of the log attribute + * @returns The serialized log attribute + */ +export function logAttributeToSerializedLogAttribute(key: string, value: unknown): SerializedLogAttribute { + switch (typeof value) { + case 'number': + return { + key, + value: { doubleValue: value }, + }; + case 'boolean': + return { + key, + value: { boolValue: value }, + }; + case 'string': + return { + key, + value: { stringValue: value }, + }; + default: + return { + key, + value: { stringValue: JSON.stringify(value) ?? '' }, + }; + } +} + +/** + * Creates envelope item for a single log + */ +export function createOtelLogEnvelopeItem(log: SerializedOtelLog): OtelLogItem { + const headers: OtelLogItem[0] = { + type: 'otel_log', + }; + + return [headers, log]; +} + +/** + * Records a log and sends it to sentry. + * + * Logs represent a message (and optionally some structured data) which provide context for a trace or error. + * Ex: sentry.addLog({level: 'warning', message: `user ${user} just bought ${item}`, attributes: {user, item}} + * + * @params log - the log object which will be sent + */ +export function createOtelLogEnvelope( + logs: Array, + metadata?: SdkMetadata, + tunnel?: string, + dsn?: DsnComponents, +): OtelLogEnvelope { + const headers: OtelLogEnvelope[0] = {}; + + if (metadata?.sdk) { + headers.sdk = { + name: metadata.sdk.name, + version: metadata.sdk.version, + }; + } + + if (!!tunnel && !!dsn) { + headers.dsn = dsnToString(dsn); + } + + return createEnvelope(headers, logs.map(createOtelLogEnvelopeItem)); +} diff --git a/packages/core/src/server-runtime-client.ts b/packages/core/src/server-runtime-client.ts index 8bb07b976d65..61db792b901e 100644 --- a/packages/core/src/server-runtime-client.ts +++ b/packages/core/src/server-runtime-client.ts @@ -2,32 +2,24 @@ import type { BaseTransportOptions, CheckIn, ClientOptions, - DynamicSamplingContext, Event, EventHint, MonitorConfig, ParameterizedString, SerializedCheckIn, SeverityLevel, - TraceContext, } from './types-hoist'; import { createCheckInEnvelope } from './checkin'; -import { Client } from './client'; -import { getIsolationScope, getTraceContextFromScope } from './currentScopes'; +import { Client, _getTraceInfoFromScope } from './client'; +import { getIsolationScope } from './currentScopes'; import { DEBUG_BUILD } from './debug-build'; import type { Scope } from './scope'; -import { - getDynamicSamplingContextFromScope, - getDynamicSamplingContextFromSpan, - registerSpanErrorInstrumentation, -} from './tracing'; +import { registerSpanErrorInstrumentation } from './tracing'; import { eventFromMessage, eventFromUnknownInput } from './utils-hoist/eventbuilder'; import { logger } from './utils-hoist/logger'; import { uuid4 } from './utils-hoist/misc'; import { resolvedSyncPromise } from './utils-hoist/syncpromise'; -import { _getSpanForScope } from './utils/spanOnScope'; -import { spanToTraceContext } from './utils/spanUtils'; export interface ServerRuntimeClientOptions extends ClientOptions { platform?: string; @@ -136,7 +128,7 @@ export class ServerRuntimeClient< }; } - const [dynamicSamplingContext, traceContext] = this._getTraceInfoFromScope(scope); + const [dynamicSamplingContext, traceContext] = _getTraceInfoFromScope(this, scope); if (traceContext) { serializedCheckIn.contexts = { trace: traceContext, @@ -186,23 +178,6 @@ export class ServerRuntimeClient< return super._prepareEvent(event, hint, currentScope, isolationScope); } - - /** Extract trace information from scope */ - protected _getTraceInfoFromScope( - scope: Scope | undefined, - ): [dynamicSamplingContext: Partial | undefined, traceContext: TraceContext | undefined] { - if (!scope) { - return [undefined, undefined]; - } - - const span = _getSpanForScope(scope); - - const traceContext = span ? spanToTraceContext(span) : getTraceContextFromScope(scope); - const dynamicSamplingContext = span - ? getDynamicSamplingContextFromSpan(span) - : getDynamicSamplingContextFromScope(this, scope); - return [dynamicSamplingContext, traceContext]; - } } function setCurrentRequestSessionErroredOrCrashed(eventHint?: EventHint): void { diff --git a/packages/core/src/types-hoist/index.ts b/packages/core/src/types-hoist/index.ts index 742b7ffe2346..29f46a51c524 100644 --- a/packages/core/src/types-hoist/index.ts +++ b/packages/core/src/types-hoist/index.ts @@ -110,7 +110,12 @@ export type { TraceFlag, } from './span'; export type { SpanStatus } from './spanStatus'; -export type { SerializedOtelLog, LogAttribute, LogSeverityLevel, LogAttributeValueType } from './log'; +export type { + LogSeverityLevel, + SerializedOtelLog, + SerializedLogAttribute, + SerializedLogAttributeValueType, +} from './log'; export type { TimedEvent } from './timedEvent'; export type { StackFrame } from './stackframe'; export type { Stacktrace, StackParser, StackLineParser, StackLineParserFn } from './stacktrace'; diff --git a/packages/core/src/types-hoist/log.ts b/packages/core/src/types-hoist/log.ts index b1588dc4efc4..a313b493306c 100644 --- a/packages/core/src/types-hoist/log.ts +++ b/packages/core/src/types-hoist/log.ts @@ -1,6 +1,6 @@ export type LogSeverityLevel = 'trace' | 'debug' | 'info' | 'warn' | 'error' | 'fatal' | 'critical'; -export type LogAttributeValueType = +export type SerializedLogAttributeValueType = | { stringValue: string; } @@ -16,12 +16,12 @@ export type LogAttributeValueType = doubleValue: number; }; -export type LogAttribute = { +export type SerializedLogAttribute = { key: string; - value: LogAttributeValueType; + value: SerializedLogAttributeValueType; }; -export interface SerializedOtelLog { +export interface Log { /** * The severity level of the log. * @@ -31,29 +31,42 @@ export interface SerializedOtelLog { * The log level changes how logs are filtered and displayed. * Critical level logs are emphasized more than trace level logs. */ - severityText?: LogSeverityLevel; + level: LogSeverityLevel; + + /** + * The message to be logged - for example, 'hello world' would become a log like '[INFO] hello world' + */ + message: string; + + /** + * Arbitrary structured data that stores information about the log - e.g., userId: 100. + */ + attributes?: Record>; /** * The severity number - generally higher severity are levels like 'error' and lower are levels like 'debug' */ severityNumber?: number; +} + +export interface SerializedOtelLog { + severityText?: Log['level']; /** * The trace ID for this log */ traceId?: string; - /** - * The message to be logged - for example, 'hello world' would become a log like '[INFO] hello world' - */ + severityNumber?: Log['severityNumber']; + body: { - stringValue: string; + stringValue: Log['message']; }; /** * Arbitrary structured data that stores information about the log - e.g., userId: 100. */ - attributes?: LogAttribute[]; + attributes?: SerializedLogAttribute[]; /** * This doesn't have to be explicitly specified most of the time. If you need to set it, the value diff --git a/packages/core/src/types-hoist/options.ts b/packages/core/src/types-hoist/options.ts index 8e52b32eacf7..d0474b959fa9 100644 --- a/packages/core/src/types-hoist/options.ts +++ b/packages/core/src/types-hoist/options.ts @@ -182,7 +182,12 @@ export interface ClientOptions '12312012123120121231201212312012'); +const uuid4Spy = vi.spyOn(miscModule, 'uuid4').mockImplementation(() => '12312012123120121231201212312012'); vi.spyOn(loggerModule, 'consoleSandbox').mockImplementation(cb => cb()); vi.spyOn(stringModule, 'truncate').mockImplementation(str => str); vi.spyOn(timeModule, 'dateTimestampInSeconds').mockImplementation(() => 2020); @@ -1723,6 +1723,131 @@ describe('Client', () => { }); }); + describe('captureLog', () => { + test('captures and sends logs', () => { + const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, _experiments: { enableLogs: true } }); + const client = new TestClient(options); + + client.captureLog({ level: 'info', message: 'test log message' }); + + expect((client as any)._logsBuffer).toHaveLength(1); + expect((client as any)._logsBuffer[0]).toEqual( + expect.objectContaining({ + severityText: 'info', + body: { + stringValue: 'test log message', + }, + timeUnixNano: expect.any(String), + }), + ); + }); + + test('does not capture logs when enableLogs experiment is not enabled', () => { + const logWarnSpy = vi.spyOn(loggerModule.logger, 'warn').mockImplementation(() => undefined); + const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN }); + const client = new TestClient(options); + + client.captureLog({ level: 'info', message: 'test log message' }); + + expect(logWarnSpy).toHaveBeenCalledWith('logging option not enabled, log will not be captured.'); + expect((client as any)._logsBuffer).toHaveLength(0); + + logWarnSpy.mockRestore(); + }); + + test('includes trace context when available', () => { + // Temporarily restore the original uuid4 implementation + const originalMock = uuid4Spy.getMockImplementation(); + uuid4Spy.mockRestore(); + + const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, _experiments: { enableLogs: true } }); + const client = new TestClient(options); + const scope = new Scope(); + scope.setPropagationContext({ + traceId: '3d9355f71e9c444b81161599adac6e29', + sampleRand: 1, + }); + + client.captureLog({ level: 'error', message: 'test log with trace' }, scope); + + expect((client as any)._logsBuffer[0]).toEqual( + expect.objectContaining({ + traceId: '3d9355f71e9c444b81161599adac6e29', + }), + ); + + // Restore the test-wide mock implementation + uuid4Spy.mockImplementation(originalMock!); + }); + + test('includes release and environment in log attributes when available', () => { + const options = getDefaultTestClientOptions({ + dsn: PUBLIC_DSN, + _experiments: { enableLogs: true }, + release: '1.0.0', + environment: 'test', + }); + const client = new TestClient(options); + + client.captureLog({ level: 'info', message: 'test log with metadata' }); + + const logAttributes = (client as any)._logsBuffer[0].attributes; + expect(logAttributes).toEqual( + expect.arrayContaining([ + expect.objectContaining({ key: 'release', value: { stringValue: '1.0.0' } }), + expect.objectContaining({ key: 'environment', value: { stringValue: 'test' } }), + ]), + ); + }); + + test('includes custom attributes in log', () => { + const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, _experiments: { enableLogs: true } }); + const client = new TestClient(options); + + client.captureLog({ + level: 'info', + message: 'test log with custom attributes', + attributes: { userId: '123', component: 'auth' }, + }); + + const logAttributes = (client as any)._logsBuffer[0].attributes; + expect(logAttributes).toEqual( + expect.arrayContaining([ + expect.objectContaining({ key: 'userId', value: { stringValue: '123' } }), + expect.objectContaining({ key: 'component', value: { stringValue: 'auth' } }), + ]), + ); + }); + + test('flushes logs buffer when it reaches max size', () => { + const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, _experiments: { enableLogs: true } }); + const client = new TestClient(options); + const mockFlushLogsBuffer = vi.spyOn(client as any, '_flushLogsBuffer').mockImplementation(() => {}); + + // Fill the buffer to max size (100 is the MAX_LOG_BUFFER_SIZE constant in client.ts) + for (let i = 0; i < 100; i++) { + client.captureLog({ level: 'info', message: `log message ${i}` }); + } + + expect(mockFlushLogsBuffer).not.toHaveBeenCalled(); + + // Add one more to trigger flush + client.captureLog({ level: 'info', message: 'trigger flush' }); + + expect(mockFlushLogsBuffer).toHaveBeenCalledTimes(1); + + mockFlushLogsBuffer.mockRestore(); + }); + + test('does not flush logs buffer when it is empty', () => { + const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, _experiments: { enableLogs: true } }); + const client = new TestClient(options); + const mockSendEnvelope = vi.spyOn(client as any, 'sendEnvelope').mockImplementation(() => {}); + client['_flushLogsBuffer'](); + expect(mockSendEnvelope).not.toHaveBeenCalled(); + }); + }); + describe('integrations', () => { beforeEach(() => { global.__SENTRY__ = {}; diff --git a/packages/core/test/lib/log.test.ts b/packages/core/test/lib/log.test.ts new file mode 100644 index 000000000000..24d20f71a6c4 --- /dev/null +++ b/packages/core/test/lib/log.test.ts @@ -0,0 +1,249 @@ +import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'; +import { createOtelLogEnvelope, createOtelLogEnvelopeItem, logAttributeToSerializedLogAttribute } from '../../src/log'; +import type { DsnComponents, SdkMetadata, SerializedOtelLog } from '../../src/types-hoist'; +import * as utilsHoist from '../../src/utils-hoist'; + +// Mock utils-hoist functions +vi.mock('../../src/utils-hoist', () => ({ + createEnvelope: vi.fn((_headers, items) => [_headers, items]), + dsnToString: vi.fn(dsn => `https://${dsn.publicKey}@${dsn.host}/`), +})); + +describe('log.ts', () => { + describe('logAttributeToSerializedLogAttribute', () => { + it('serializes number values', () => { + const result = logAttributeToSerializedLogAttribute('count', 42); + expect(result).toEqual({ + key: 'count', + value: { doubleValue: 42 }, + }); + }); + + it('serializes boolean values', () => { + const result = logAttributeToSerializedLogAttribute('enabled', true); + expect(result).toEqual({ + key: 'enabled', + value: { boolValue: true }, + }); + }); + + it('serializes string values', () => { + const result = logAttributeToSerializedLogAttribute('username', 'john_doe'); + expect(result).toEqual({ + key: 'username', + value: { stringValue: 'john_doe' }, + }); + }); + + it('serializes object values as JSON strings', () => { + const obj = { name: 'John', age: 30 }; + const result = logAttributeToSerializedLogAttribute('user', obj); + expect(result).toEqual({ + key: 'user', + value: { stringValue: JSON.stringify(obj) }, + }); + }); + + it('serializes array values as JSON strings', () => { + const array = [1, 2, 3, 'test']; + const result = logAttributeToSerializedLogAttribute('items', array); + expect(result).toEqual({ + key: 'items', + value: { stringValue: JSON.stringify(array) }, + }); + }); + + it('serializes undefined values as empty strings', () => { + const result = logAttributeToSerializedLogAttribute('missing', undefined); + expect(result).toEqual({ + key: 'missing', + value: { stringValue: '' }, + }); + }); + + it('serializes null values as JSON strings', () => { + const result = logAttributeToSerializedLogAttribute('empty', null); + expect(result).toEqual({ + key: 'empty', + value: { stringValue: 'null' }, + }); + }); + }); + + describe('createOtelLogEnvelopeItem', () => { + it('creates an envelope item with correct structure', () => { + const mockLog: SerializedOtelLog = { + severityText: 'error', + body: { + stringValue: 'Test error message', + }, + }; + + const result = createOtelLogEnvelopeItem(mockLog); + + expect(result).toHaveLength(2); + expect(result[0]).toEqual({ type: 'otel_log' }); + expect(result[1]).toBe(mockLog); + }); + }); + + describe('createOtelLogEnvelope', () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2023-01-01T12:00:00Z')); + + // Reset mocks + vi.mocked(utilsHoist.createEnvelope).mockClear(); + vi.mocked(utilsHoist.dsnToString).mockClear(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('creates an envelope with basic headers', () => { + const mockLogs: SerializedOtelLog[] = [ + { + severityText: 'info', + body: { stringValue: 'Test log message' }, + }, + ]; + + const result = createOtelLogEnvelope(mockLogs); + + expect(result[0]).toEqual({}); + + // Verify createEnvelope was called with the right parameters + expect(utilsHoist.createEnvelope).toHaveBeenCalledWith({}, expect.any(Array)); + }); + + it('includes SDK info when metadata is provided', () => { + const mockLogs: SerializedOtelLog[] = [ + { + severityText: 'info', + body: { stringValue: 'Test log message' }, + }, + ]; + + const metadata: SdkMetadata = { + sdk: { + name: 'sentry.javascript.node', + version: '7.0.0', + }, + }; + + const result = createOtelLogEnvelope(mockLogs, metadata); + + expect(result[0]).toEqual({ + sdk: { + name: 'sentry.javascript.node', + version: '7.0.0', + }, + }); + }); + + it('includes DSN when tunnel and DSN are provided', () => { + const mockLogs: SerializedOtelLog[] = [ + { + severityText: 'info', + body: { stringValue: 'Test log message' }, + }, + ]; + + const dsn: DsnComponents = { + host: 'example.sentry.io', + path: '/', + projectId: '123', + port: '', + protocol: 'https', + publicKey: 'abc123', + }; + + const result = createOtelLogEnvelope(mockLogs, undefined, 'https://tunnel.example.com', dsn); + + expect(result[0]).toHaveProperty('dsn'); + expect(utilsHoist.dsnToString).toHaveBeenCalledWith(dsn); + }); + + it('maps each log to an envelope item', () => { + const mockLogs: SerializedOtelLog[] = [ + { + severityText: 'info', + body: { stringValue: 'First log message' }, + }, + { + severityText: 'error', + body: { stringValue: 'Second log message' }, + }, + ]; + + createOtelLogEnvelope(mockLogs); + + // Check that createEnvelope was called with an array of envelope items + expect(utilsHoist.createEnvelope).toHaveBeenCalledWith( + expect.anything(), + expect.arrayContaining([ + expect.arrayContaining([{ type: 'otel_log' }, mockLogs[0]]), + expect.arrayContaining([{ type: 'otel_log' }, mockLogs[1]]), + ]), + ); + }); + }); + + describe('Trace context in logs', () => { + it('correctly sets parent_span_id in trace context', () => { + // Create a log with trace context + const mockParentSpanId = 'abcdef1234567890'; + const mockTraceId = '00112233445566778899aabbccddeeff'; + + const mockLog: SerializedOtelLog = { + severityText: 'info', + body: { stringValue: 'Test log with trace context' }, + traceId: mockTraceId, + attributes: [ + { + key: 'sentry.trace.parent_span_id', + value: { stringValue: mockParentSpanId }, + }, + { + key: 'some.other.attribute', + value: { stringValue: 'test value' }, + }, + ], + }; + + // Create an envelope item from this log + const envelopeItem = createOtelLogEnvelopeItem(mockLog); + + // Verify the parent_span_id is preserved in the envelope item + expect(envelopeItem[1]).toBe(mockLog); + expect(envelopeItem[1].traceId).toBe(mockTraceId); + expect(envelopeItem[1].attributes).toContainEqual({ + key: 'sentry.trace.parent_span_id', + value: { stringValue: mockParentSpanId }, + }); + + // Create an envelope with this log + createOtelLogEnvelope([mockLog]); + + // Verify the envelope preserves the trace information + expect(utilsHoist.createEnvelope).toHaveBeenCalledWith( + expect.anything(), + expect.arrayContaining([ + expect.arrayContaining([ + { type: 'otel_log' }, + expect.objectContaining({ + traceId: mockTraceId, + attributes: expect.arrayContaining([ + { + key: 'sentry.trace.parent_span_id', + value: { stringValue: mockParentSpanId }, + }, + ]), + }), + ]), + ]), + ); + }); + }); +});