diff --git a/.size-limit.js b/.size-limit.js index 32d5d19e1495..5c95cfad202f 100644 --- a/.size-limit.js +++ b/.size-limit.js @@ -40,6 +40,13 @@ module.exports = [ gzip: true, limit: '40.7 KB', }, + { + name: '@sentry/browser (incl. Tracing with Span Streaming)', + path: 'packages/browser/build/npm/esm/index.js', + import: createImport('init', 'browserTracingIntegration', 'spanStreamingIntegration'), + gzip: true, + limit: '41.5 KB', + }, { name: '@sentry/browser (incl. Tracing, Replay)', path: 'packages/browser/build/npm/esm/index.js', diff --git a/packages/browser/src/index.ts b/packages/browser/src/index.ts index 5e9924fe6da5..b6e8ca960290 100644 --- a/packages/browser/src/index.ts +++ b/packages/browser/src/index.ts @@ -76,3 +76,4 @@ export { unleashIntegration } from './integrations/featureFlags/unleash'; export { statsigIntegration } from './integrations/featureFlags/statsig'; export { diagnoseSdkConnectivity } from './diagnose-sdk'; export { webWorkerIntegration, registerWebWorker } from './integrations/webWorker'; +export { spanStreamingIntegration } from './integrations/spanstreaming'; diff --git a/packages/browser/src/integrations/spanstreaming.ts b/packages/browser/src/integrations/spanstreaming.ts new file mode 100644 index 000000000000..94b4bb0665a5 --- /dev/null +++ b/packages/browser/src/integrations/spanstreaming.ts @@ -0,0 +1,283 @@ +import type { Client, IntegrationFn, Scope, ScopeData, Span, SpanAttributes, SpanV2JSON } from '@sentry/core'; +import { + attributesFromObject, + createSpanV2Envelope, + debug, + defineIntegration, + getCapturedScopesOnSpan, + getDynamicSamplingContextFromSpan, + getGlobalScope, + getRootSpan as getSegmentSpan, + httpHeadersToSpanAttributes, + isV2BeforeSendSpanCallback, + mergeScopeData, + reparentChildSpans, + SEMANTIC_ATTRIBUTE_SENTRY_CUSTOM_SPAN_NAME, + SEMANTIC_ATTRIBUTE_SENTRY_ENVIRONMENT, + SEMANTIC_ATTRIBUTE_SENTRY_RELEASE, + SEMANTIC_ATTRIBUTE_SENTRY_SDK_NAME, + SEMANTIC_ATTRIBUTE_SENTRY_SDK_VERSION, + SEMANTIC_ATTRIBUTE_SENTRY_SEGMENT_NAME, + SEMANTIC_ATTRIBUTE_URL_FULL, + SEMANTIC_ATTRIBUTE_USER_EMAIL, + SEMANTIC_ATTRIBUTE_USER_ID, + SEMANTIC_ATTRIBUTE_USER_IP_ADDRESS, + SEMANTIC_ATTRIBUTE_USER_USERNAME, + shouldIgnoreSpan, + showSpanDropWarning, + spanToV2JSON, +} from '@sentry/core'; +import { DEBUG_BUILD } from '../debug-build'; +import { getHttpRequestData } from '../helpers'; + +export interface SpanStreamingOptions { + batchLimit: number; +} + +export const spanStreamingIntegration = defineIntegration(((userOptions?: Partial) => { + const validatedUserProvidedBatchLimit = + userOptions?.batchLimit && userOptions.batchLimit <= 1000 && userOptions.batchLimit >= 1 + ? userOptions.batchLimit + : undefined; + + if (DEBUG_BUILD && userOptions?.batchLimit && !validatedUserProvidedBatchLimit) { + debug.warn('SpanStreaming batchLimit must be between 1 and 1000, defaulting to 1000'); + } + + const options: SpanStreamingOptions = { + ...userOptions, + batchLimit: + userOptions?.batchLimit && userOptions.batchLimit <= 1000 && userOptions.batchLimit >= 1 + ? userOptions.batchLimit + : 1000, + }; + + // key: traceId-segmentSpanId + const spanTreeMap = new Map>(); + + return { + name: 'SpanStreaming', + setup(client) { + const clientOptions = client.getOptions(); + const beforeSendSpan = clientOptions.beforeSendSpan; + + const initialMessage = 'spanStreamingIntegration requires'; + const fallbackMsg = 'Falling back to static trace lifecycle.'; + + if (clientOptions.traceLifecycle !== 'stream') { + DEBUG_BUILD && debug.warn(`${initialMessage} \`traceLifecycle\` to be set to "stream"! ${fallbackMsg}`); + return; + } + + if (beforeSendSpan && !isV2BeforeSendSpanCallback(beforeSendSpan)) { + client.getOptions().traceLifecycle = 'static'; + debug.warn(`${initialMessage} a beforeSendSpan callback using \`makeV2Callback\`! ${fallbackMsg}`); + return; + } + + client.on('spanEnd', span => { + const spanTreeMapKey = getSpanTreeMapKey(span); + const spanBuffer = spanTreeMap.get(spanTreeMapKey); + if (spanBuffer) { + spanBuffer.add(span); + } else { + spanTreeMap.set(spanTreeMapKey, new Set([span])); + } + }); + + // For now, we send all spans on local segment (root) span end. + // TODO: This will change once we have more concrete ideas about a universal SDK data buffer. + client.on('segmentSpanEnd', segmentSpan => { + processAndSendSpans(segmentSpan, { + spanTreeMap: spanTreeMap, + client, + batchLimit: options.batchLimit, + beforeSendSpan, + }); + }); + }, + }; +}) satisfies IntegrationFn); + +interface SpanProcessingOptions { + client: Client; + spanTreeMap: Map>; + batchLimit: number; + beforeSendSpan: ((span: SpanV2JSON) => SpanV2JSON) | undefined; +} + +/** + * Just the traceid alone isn't enough because there can be multiple span trees with the same traceid. + */ +function getSpanTreeMapKey(span: Span): string { + return `${span.spanContext().traceId}-${getSegmentSpan(span).spanContext().spanId}`; +} + +function processAndSendSpans( + segmentSpan: Span, + { client, spanTreeMap, batchLimit, beforeSendSpan }: SpanProcessingOptions, +): void { + const traceId = segmentSpan.spanContext().traceId; + const spanTreeMapKey = getSpanTreeMapKey(segmentSpan); + const spansOfTrace = spanTreeMap.get(spanTreeMapKey); + + if (!spansOfTrace?.size) { + spanTreeMap.delete(spanTreeMapKey); + return; + } + + const segmentSpanJson = spanToV2JSON(segmentSpan); + + for (const span of spansOfTrace) { + applyCommonSpanAttributes(span, segmentSpanJson, client); + } + + applyScopeToSegmentSpan(segmentSpan, segmentSpanJson, client); + + // TODO: Apply scope data and contexts to segment span + + const { ignoreSpans } = client.getOptions(); + + // 1. Check if the entire span tree is ignored by ignoreSpans + if (ignoreSpans?.length && shouldIgnoreSpan(segmentSpanJson, ignoreSpans)) { + client.recordDroppedEvent('before_send', 'span', spansOfTrace.size); + spanTreeMap.delete(spanTreeMapKey); + return; + } + + const serializedSpans = Array.from(spansOfTrace ?? []).map(s => { + const serialized = spanToV2JSON(s); + // remove internal span attributes we don't need to send. + delete serialized.attributes?.[SEMANTIC_ATTRIBUTE_SENTRY_CUSTOM_SPAN_NAME]; + return serialized; + }); + + const processedSpans = []; + let ignoredSpanCount = 0; + + for (const span of serializedSpans) { + // 2. Check if child spans should be ignored + const isChildSpan = span.span_id !== segmentSpan.spanContext().spanId; + if (ignoreSpans?.length && isChildSpan && shouldIgnoreSpan(span, ignoreSpans)) { + reparentChildSpans(serializedSpans, span); + ignoredSpanCount++; + // drop this span by not adding it to the processedSpans array + continue; + } + + // 3. Apply beforeSendSpan callback + const processedSpan = beforeSendSpan ? applyBeforeSendSpanCallback(span, beforeSendSpan) : span; + processedSpans.push(processedSpan); + } + + if (ignoredSpanCount) { + client.recordDroppedEvent('before_send', 'span', ignoredSpanCount); + } + + const batches: SpanV2JSON[][] = []; + for (let i = 0; i < processedSpans.length; i += batchLimit) { + batches.push(processedSpans.slice(i, i + batchLimit)); + } + + DEBUG_BUILD && debug.log(`Sending trace ${traceId} in ${batches.length} batch${batches.length === 1 ? '' : 'es'}`); + + const dsc = getDynamicSamplingContextFromSpan(segmentSpan); + + for (const batch of batches) { + const envelope = createSpanV2Envelope(batch, dsc, client); + // no need to handle client reports for network errors, + // buffer overflows or rate limiting here. All of this is handled + // by client and transport. + client.sendEnvelope(envelope).then(null, reason => { + DEBUG_BUILD && debug.error('Error while sending span stream envelope:', reason); + }); + } + + spanTreeMap.delete(spanTreeMapKey); +} + +function applyCommonSpanAttributes(span: Span, serializedSegmentSpan: SpanV2JSON, client: Client): void { + const sdk = client.getSdkMetadata(); + const { release, environment, sendDefaultPii } = client.getOptions(); + + const { isolationScope: spanIsolationScope, scope: spanScope } = getCapturedScopesOnSpan(span); + + const originalAttributeKeys = Object.keys(spanToV2JSON(span).attributes ?? {}); + + const finalScopeData = getFinalScopeData(spanIsolationScope, spanScope); + + // avoid overwriting any previously set attributes (from users or potentially our SDK instrumentation) + setAttributesIfNotPresent(span, originalAttributeKeys, { + [SEMANTIC_ATTRIBUTE_SENTRY_RELEASE]: release, + [SEMANTIC_ATTRIBUTE_SENTRY_ENVIRONMENT]: environment, + [SEMANTIC_ATTRIBUTE_SENTRY_SEGMENT_NAME]: serializedSegmentSpan.name, + [SEMANTIC_ATTRIBUTE_SENTRY_SDK_NAME]: sdk?.sdk?.name, + [SEMANTIC_ATTRIBUTE_SENTRY_SDK_VERSION]: sdk?.sdk?.version, + ...(sendDefaultPii + ? { + [SEMANTIC_ATTRIBUTE_USER_ID]: finalScopeData.user?.id, + [SEMANTIC_ATTRIBUTE_USER_EMAIL]: finalScopeData.user?.email, + [SEMANTIC_ATTRIBUTE_USER_IP_ADDRESS]: finalScopeData.user?.ip_address ?? undefined, + [SEMANTIC_ATTRIBUTE_USER_USERNAME]: finalScopeData.user?.username, + } + : {}), + }); +} + +/** + * Adds span attributes frome + */ +function applyScopeToSegmentSpan(segmentSpan: Span, serializedSegmentSpan: SpanV2JSON, client: Client): void { + const { isolationScope, scope } = getCapturedScopesOnSpan(segmentSpan); + const finalScopeData = getFinalScopeData(isolationScope, scope); + + const browserRequestData = getHttpRequestData(); + + const tags = finalScopeData.tags ?? {}; + + let contextAttributes = {}; + Object.keys(finalScopeData.contexts).forEach(key => { + if (finalScopeData.contexts[key]) { + contextAttributes = { ...contextAttributes, ...attributesFromObject(finalScopeData.contexts[key]) }; + } + }); + + const extraAttributes = attributesFromObject(finalScopeData.extra); + + setAttributesIfNotPresent(segmentSpan, Object.keys(serializedSegmentSpan.attributes ?? {}), { + [SEMANTIC_ATTRIBUTE_URL_FULL]: browserRequestData.url, + ...httpHeadersToSpanAttributes(browserRequestData.headers, client.getOptions().sendDefaultPii ?? false), + ...tags, + ...contextAttributes, + ...extraAttributes, + }); +} + +function applyBeforeSendSpanCallback(span: SpanV2JSON, beforeSendSpan: (span: SpanV2JSON) => SpanV2JSON): SpanV2JSON { + const modifedSpan = beforeSendSpan(span); + if (!modifedSpan) { + showSpanDropWarning(); + return span; + } + return modifedSpan; +} + +function setAttributesIfNotPresent(span: Span, originalAttributeKeys: string[], newAttributes: SpanAttributes): void { + Object.keys(newAttributes).forEach(key => { + if (!originalAttributeKeys.includes(key)) { + span.setAttribute(key, newAttributes[key]); + } + }); +} + +// TODO: Extract this to a helper in core. It's used in multiple places. +function getFinalScopeData(isolationScope: Scope | undefined, scope: Scope | undefined): ScopeData { + const finalScopeData = getGlobalScope().getScopeData(); + if (isolationScope) { + mergeScopeData(finalScopeData, isolationScope.getScopeData()); + } + if (scope) { + mergeScopeData(finalScopeData, scope.getScopeData()); + } + return finalScopeData; +} diff --git a/packages/core/src/client.ts b/packages/core/src/client.ts index 365b4f42d078..63de96fd16b3 100644 --- a/packages/core/src/client.ts +++ b/packages/core/src/client.ts @@ -33,6 +33,7 @@ import type { SeverityLevel } from './types-hoist/severity'; import type { Span, SpanAttributes, SpanContextData, SpanJSON } from './types-hoist/span'; import type { StartSpanOptions } from './types-hoist/startSpanOptions'; import type { Transport, TransportMakeRequestResponse } from './types-hoist/transport'; +import { isV2BeforeSendSpanCallback } from './utils/beforeSendSpan'; import { createClientReportEnvelope } from './utils/clientreport'; import { debug } from './utils/debug-logger'; import { dsnToString, makeDsn } from './utils/dsn'; @@ -509,6 +510,14 @@ export abstract class Client { */ public on(hook: 'spanEnd', callback: (span: Span) => void): () => void; + /** + * Register a callback for after a span is ended. + * NOTE: The span cannot be mutated anymore in this callback. + * Receives the span as argument. + * @returns {() => void} A function that, when executed, removes the registered callback. + */ + public on(hook: 'segmentSpanEnd', callback: (span: Span) => void): () => void; + /** * Register a callback for when an idle span is allowed to auto-finish. * @returns {() => void} A function that, when executed, removes the registered callback. @@ -742,6 +751,9 @@ export abstract class Client { /** Fire a hook whenever a span ends. */ public emit(hook: 'spanEnd', span: Span): void; + /** Fire a hook whenever a segment span ends. */ + public emit(hook: 'segmentSpanEnd', span: Span): void; + /** * Fire a hook indicating that an idle span is allowed to auto finish. */ @@ -1316,13 +1328,17 @@ function _validateBeforeSendResult( /** * Process the matching `beforeSendXXX` callback. */ +// eslint-disable-next-line complexity function processBeforeSend( client: Client, options: ClientOptions, event: Event, hint: EventHint, ): PromiseLike | Event | null { - const { beforeSend, beforeSendTransaction, beforeSendSpan, ignoreSpans } = options; + const { beforeSend, beforeSendTransaction, ignoreSpans } = options; + + const beforeSendSpan = !isV2BeforeSendSpanCallback(options.beforeSendSpan) && options.beforeSendSpan; + let processedEvent = event; if (isErrorEvent(processedEvent) && beforeSend) { diff --git a/packages/core/src/envelope.ts b/packages/core/src/envelope.ts index 875056890e0e..515ff4fde859 100644 --- a/packages/core/src/envelope.ts +++ b/packages/core/src/envelope.ts @@ -11,13 +11,17 @@ import type { RawSecurityItem, SessionEnvelope, SessionItem, + SpanContainerItem, SpanEnvelope, SpanItem, + SpanV2Envelope, } from './types-hoist/envelope'; import type { Event } from './types-hoist/event'; import type { SdkInfo } from './types-hoist/sdkinfo'; import type { SdkMetadata } from './types-hoist/sdkmetadata'; import type { Session, SessionAggregates } from './types-hoist/session'; +import { SpanV2JSON } from './types-hoist/span'; +import { isV2BeforeSendSpanCallback } from './utils/beforeSendSpan'; import { dsnToString } from './utils/dsn'; import { createEnvelope, @@ -120,10 +124,6 @@ export function createEventEnvelope( * Takes an optional client and runs spans through `beforeSendSpan` if available. */ export function createSpanEnvelope(spans: [SentrySpan, ...SentrySpan[]], client?: Client): SpanEnvelope { - function dscHasRequiredProps(dsc: Partial): dsc is DynamicSamplingContext { - return !!dsc.trace_id && !!dsc.public_key; - } - // For the moment we'll obtain the DSC from the first span in the array // This might need to be changed if we permit sending multiple spans from // different segments in one envelope @@ -138,7 +138,8 @@ export function createSpanEnvelope(spans: [SentrySpan, ...SentrySpan[]], client? ...(!!tunnel && dsn && { dsn: dsnToString(dsn) }), }; - const { beforeSendSpan, ignoreSpans } = client?.getOptions() || {}; + const options = client?.getOptions(); + const ignoreSpans = options?.ignoreSpans; const filteredSpans = ignoreSpans?.length ? spans.filter(span => !shouldIgnoreSpan(spanToJSON(span), ignoreSpans)) @@ -149,10 +150,14 @@ export function createSpanEnvelope(spans: [SentrySpan, ...SentrySpan[]], client? client?.recordDroppedEvent('before_send', 'span', droppedSpans); } - const convertToSpanJSON = beforeSendSpan + // checking against traceLifeCycle so that TS can infer the correct type for + // beforeSendSpan. This is a workaround for now as most likely, this entire function + // will be removed in the future (once we send standalone spans as spans v2) + const convertToSpanJSON = options?.beforeSendSpan ? (span: SentrySpan) => { const spanJson = spanToJSON(span); - const processedSpan = beforeSendSpan(spanJson); + const processedSpan = + !isV2BeforeSendSpanCallback(options?.beforeSendSpan) && options?.beforeSendSpan?.(spanJson); if (!processedSpan) { showSpanDropWarning(); @@ -174,6 +179,33 @@ export function createSpanEnvelope(spans: [SentrySpan, ...SentrySpan[]], client? return createEnvelope(headers, items); } +/** + * Creates a span v2 envelope + */ +export function createSpanV2Envelope( + serializedSpans: SpanV2JSON[], + dsc: Partial, + client: Client, +): SpanV2Envelope { + const dsn = client?.getDsn(); + const tunnel = client?.getOptions().tunnel; + const sdk = client?.getOptions()._metadata?.sdk; + + const headers: SpanV2Envelope[0] = { + sent_at: new Date().toISOString(), + ...(dscHasRequiredProps(dsc) && { trace: dsc }), + ...(sdk && { sdk: sdk }), + ...(!!tunnel && dsn && { dsn: dsnToString(dsn) }), + }; + + const spanContainer: SpanContainerItem = [ + { type: 'span', item_count: serializedSpans.length, content_type: 'application/vnd.sentry.items.span.v2+json' }, + { items: serializedSpans }, + ]; + + return createEnvelope(headers, [spanContainer]); +} + /** * Create an Envelope from a CSP report. */ @@ -196,3 +228,7 @@ export function createRawSecurityEnvelope( return createEnvelope(envelopeHeaders, [eventItem]); } + +function dscHasRequiredProps(dsc: Partial): dsc is DynamicSamplingContext { + return !!dsc.trace_id && !!dsc.public_key; +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index e0daefd54d76..2977087ba7fd 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -9,7 +9,7 @@ export type { IntegrationIndex } from './integration'; export * from './tracing'; export * from './semanticAttributes'; -export { createEventEnvelope, createSessionEnvelope, createSpanEnvelope } from './envelope'; +export { createEventEnvelope, createSessionEnvelope, createSpanEnvelope, createSpanV2Envelope } from './envelope'; export { captureCheckIn, withMonitor, @@ -76,11 +76,15 @@ export { getSpanDescendants, getStatusMessage, getRootSpan, + getSegmentSpan, getActiveSpan, addChildSpanToSpan, spanTimeInputToSeconds, updateSpanName, + spanToV2JSON, + showSpanDropWarning, } from './utils/spanUtils'; +export { attributesFromObject } from './utils/attributes'; export { _setSpanForScope as _INTERNAL_setSpanForScope } from './utils/spanOnScope'; export { parseSampleRate } from './utils/parseSampleRate'; export { applySdkMetadata } from './utils/sdkMetadata'; @@ -302,6 +306,8 @@ export { flushIfServerless } from './utils/flushIfServerless'; export { SDK_VERSION } from './utils/version'; export { getDebugImagesForResources, getFilenameToDebugIdMap } from './utils/debug-ids'; export { escapeStringForRegex } from './vendor/escapeStringForRegex'; +export { isV2BeforeSendSpanCallback, makeV2Callback } from './utils/beforeSendSpan'; +export { shouldIgnoreSpan, reparentChildSpans } from './utils/should-ignore-span'; export type { Attachment } from './types-hoist/attachment'; export type { @@ -353,6 +359,7 @@ export type { ProfileChunkEnvelope, ProfileChunkItem, SpanEnvelope, + SpanV2Envelope, SpanItem, LogEnvelope, } from './types-hoist/envelope'; @@ -413,6 +420,7 @@ export type { SpanJSON, SpanContextData, TraceFlag, + SpanV2JSON, } from './types-hoist/span'; export type { SpanStatus } from './types-hoist/spanStatus'; export type { Log, LogSeverityLevel } from './types-hoist/log'; diff --git a/packages/core/src/semanticAttributes.ts b/packages/core/src/semanticAttributes.ts index 9b90809c0091..df43f510aaaf 100644 --- a/packages/core/src/semanticAttributes.ts +++ b/packages/core/src/semanticAttributes.ts @@ -77,3 +77,33 @@ export const SEMANTIC_ATTRIBUTE_URL_FULL = 'url.full'; * @see https://develop.sentry.dev/sdk/telemetry/traces/span-links/#link-types */ export const SEMANTIC_LINK_ATTRIBUTE_LINK_TYPE = 'sentry.link.type'; + +// some attributes for now exclusively used for span streaming +// @see https://develop.sentry.dev/sdk/telemetry/spans/span-protocol/#common-attribute-keys + +/** The release version of the application */ +export const SEMANTIC_ATTRIBUTE_SENTRY_RELEASE = 'sentry.release'; +/** The environment name (e.g., "production", "staging", "development") */ +export const SEMANTIC_ATTRIBUTE_SENTRY_ENVIRONMENT = 'sentry.environment'; +/** The segment name (e.g., "GET /users") */ +export const SEMANTIC_ATTRIBUTE_SENTRY_SEGMENT_NAME = 'sentry.segment_name'; +/** The operating system name (e.g., "Linux", "Windows", "macOS") */ +export const SEMANTIC_ATTRIBUTE_OS_NAME = 'os.name'; +/** The browser name (e.g., "Chrome", "Firefox", "Safari") */ +export const SEMANTIC_ATTRIBUTE_BROWSER_VERSION = 'browser.name'; +/** The user ID (gated by sendDefaultPii) */ +export const SEMANTIC_ATTRIBUTE_USER_ID = 'user.id'; +/** The user email (gated by sendDefaultPii) */ +export const SEMANTIC_ATTRIBUTE_USER_EMAIL = 'user.email'; +/** The user IP address (gated by sendDefaultPii) */ +export const SEMANTIC_ATTRIBUTE_USER_IP_ADDRESS = 'user.ip_address'; +/** The user username (gated by sendDefaultPii) */ +export const SEMANTIC_ATTRIBUTE_USER_USERNAME = 'user.username'; +/** The thread ID */ +export const SEMANTIC_ATTRIBUTE_THREAD_ID = 'thread.id'; +/** The thread name */ +export const SEMANTIC_ATTRIBUTE_THREAD_NAME = 'thread.name'; +/** The name of the Sentry SDK (e.g., "sentry.php", "sentry.javascript") */ +export const SEMANTIC_ATTRIBUTE_SENTRY_SDK_NAME = 'sentry.sdk.name'; +/** The version of the Sentry SDK */ +export const SEMANTIC_ATTRIBUTE_SENTRY_SDK_VERSION = 'sentry.sdk.version'; diff --git a/packages/core/src/tracing/sentrySpan.ts b/packages/core/src/tracing/sentrySpan.ts index 9bd98b9741c6..6a4eaefb21f5 100644 --- a/packages/core/src/tracing/sentrySpan.ts +++ b/packages/core/src/tracing/sentrySpan.ts @@ -1,3 +1,4 @@ +/* eslint-disable max-lines */ import { getClient, getCurrentScope } from '../currentScopes'; import { DEBUG_BUILD } from '../debug-build'; import { createSpanEnvelope } from '../envelope'; @@ -21,6 +22,7 @@ import type { SpanJSON, SpanOrigin, SpanTimeInput, + SpanV2JSON, } from '../types-hoist/span'; import type { SpanStatus } from '../types-hoist/spanStatus'; import type { TimedEvent } from '../types-hoist/timedEvent'; @@ -31,6 +33,9 @@ import { getRootSpan, getSpanDescendants, getStatusMessage, + getV2Attributes, + getV2SpanLinks, + getV2StatusMessage, spanTimeInputToSeconds, spanToJSON, spanToTransactionTraceContext, @@ -241,6 +246,31 @@ export class SentrySpan implements Span { }; } + /** + * Get SpanV2JSON representation of this span. + * + * @hidden + * @internal This method is purely for internal purposes and should not be used outside + * of SDK code. If you need to get a JSON representation of a span, + * use `spanToV2JSON(span)` instead. + */ + public getSpanV2JSON(): SpanV2JSON { + return { + name: this._name ?? '', + span_id: this._spanId, + trace_id: this._traceId, + parent_span_id: this._parentSpanId, + start_timestamp: this._startTime, + // just in case _endTime is not set, we use the start time (i.e. duration 0) + end_timestamp: this._endTime ?? this._startTime, + is_remote: false, // TODO: This has to be inferred from attributes SentrySpans. `false` is the default. + kind: 'internal', // TODO: This has to be inferred from attributes SentrySpans. `internal` is the default. + status: getV2StatusMessage(this._status), + attributes: getV2Attributes(this._attributes), + links: getV2SpanLinks(this._links), + }; + } + /** @inheritdoc */ public isRecording(): boolean { return !this._endTime && !!this._sampled; @@ -310,6 +340,10 @@ export class SentrySpan implements Span { } } return; + } else if (client?.getOptions().traceLifecycle === 'stream') { + // TODO (spans): Remove standalone span custom logic in favor of sending simple v2 web vital spans + client?.emit('segmentSpanEnd', this); + return; } const transactionEvent = this._convertSpanToTransaction(); diff --git a/packages/core/src/types-hoist/attributes.ts b/packages/core/src/types-hoist/attributes.ts new file mode 100644 index 000000000000..56b3658f8c20 --- /dev/null +++ b/packages/core/src/types-hoist/attributes.ts @@ -0,0 +1,20 @@ +export type SerializedAttributes = Record; +export type SerializedAttribute = ( + | { + type: 'string'; + value: string; + } + | { + type: 'integer'; + value: number; + } + | { + type: 'double'; + value: number; + } + | { + type: 'boolean'; + value: boolean; + } +) & { unit?: 'ms' | 's' | 'bytes' | 'count' | 'percent' }; +export type SerializedAttributeType = 'string' | 'integer' | 'double' | 'boolean'; diff --git a/packages/core/src/types-hoist/envelope.ts b/packages/core/src/types-hoist/envelope.ts index 58671c1eba70..9ba5509a8ca2 100644 --- a/packages/core/src/types-hoist/envelope.ts +++ b/packages/core/src/types-hoist/envelope.ts @@ -10,7 +10,7 @@ import type { Profile, ProfileChunk } from './profiling'; import type { ReplayEvent, ReplayRecordingData } from './replay'; import type { SdkInfo } from './sdkinfo'; import type { SerializedSession, SessionAggregates } from './session'; -import type { SpanJSON } from './span'; +import type { SerializedSpanContainer, SpanJSON } from './span'; // Based on: https://develop.sentry.dev/sdk/envelopes/ @@ -88,6 +88,21 @@ type CheckInItemHeaders = { type: 'check_in' }; type ProfileItemHeaders = { type: 'profile' }; type ProfileChunkItemHeaders = { type: 'profile_chunk' }; type SpanItemHeaders = { type: 'span' }; +type SpanContainerItemHeaders = { + /** + * Same as v1 span item type but this envelope is distinguished by {@link SpanContainerItemHeaders.content_type}. + */ + type: 'span'; + /** + * The number of span items in the container. This must be the same as the number of span items in the payload. + */ + item_count: number; + /** + * The content type of the span items. This must be `application/vnd.sentry.items.span.v2+json`. + * (the presence of this field also distinguishes the span item from the v1 span item) + */ + content_type: 'application/vnd.sentry.items.span.v2+json'; +}; type LogContainerItemHeaders = { type: 'log'; /** @@ -115,6 +130,7 @@ export type FeedbackItem = BaseEnvelopeItem; export type ProfileItem = BaseEnvelopeItem; export type ProfileChunkItem = BaseEnvelopeItem; export type SpanItem = BaseEnvelopeItem>; +export type SpanContainerItem = BaseEnvelopeItem; export type LogContainerItem = BaseEnvelopeItem; export type RawSecurityItem = BaseEnvelopeItem; @@ -124,6 +140,7 @@ type CheckInEnvelopeHeaders = { trace?: DynamicSamplingContext }; type ClientReportEnvelopeHeaders = BaseEnvelopeHeaders; type ReplayEnvelopeHeaders = BaseEnvelopeHeaders; type SpanEnvelopeHeaders = BaseEnvelopeHeaders & { trace?: DynamicSamplingContext }; +type SpanV2EnvelopeHeaders = BaseEnvelopeHeaders & { trace?: DynamicSamplingContext }; type LogEnvelopeHeaders = BaseEnvelopeHeaders; export type EventEnvelope = BaseEnvelope< EventEnvelopeHeaders, @@ -134,6 +151,7 @@ export type ClientReportEnvelope = BaseEnvelope; export type SpanEnvelope = BaseEnvelope; +export type SpanV2Envelope = BaseEnvelope; export type ProfileChunkEnvelope = BaseEnvelope; export type RawSecurityEnvelope = BaseEnvelope; export type LogEnvelope = BaseEnvelope; @@ -146,6 +164,7 @@ export type Envelope = | ReplayEnvelope | CheckInEnvelope | SpanEnvelope + | SpanV2Envelope | RawSecurityEnvelope | LogEnvelope; export type EnvelopeItem = Envelope[1][number]; diff --git a/packages/core/src/types-hoist/link.ts b/packages/core/src/types-hoist/link.ts index a330dc108b00..9a117258200b 100644 --- a/packages/core/src/types-hoist/link.ts +++ b/packages/core/src/types-hoist/link.ts @@ -22,9 +22,9 @@ export interface SpanLink { * Link interface for the event envelope item. It's a flattened representation of `SpanLink`. * Can include additional fields defined by OTel. */ -export interface SpanLinkJSON extends Record { +export interface SpanLinkJSON extends Record { span_id: string; trace_id: string; sampled?: boolean; - attributes?: SpanLinkAttributes; + attributes?: TAttributes; } diff --git a/packages/core/src/types-hoist/options.ts b/packages/core/src/types-hoist/options.ts index 43946c3d08e0..e7acdcd21c9c 100644 --- a/packages/core/src/types-hoist/options.ts +++ b/packages/core/src/types-hoist/options.ts @@ -5,7 +5,7 @@ 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'; +import type { SpanJSON, SpanV2JSON } from './span'; import type { StackLineParser, StackParser } from './stacktrace'; import type { TracePropagationTargets } from './tracing'; import type { BaseTransportOptions, Transport } from './transport'; @@ -282,6 +282,14 @@ export interface ClientOptions SpanJSON; + beforeSendSpan?: ((span: SpanJSON) => SpanJSON) | SpanV2CompatibleBeforeSendSpanCallback; /** * An event-processing callback for transaction events, guaranteed to be invoked after all other event @@ -444,6 +462,12 @@ export interface ClientOptions Breadcrumb | null; } +/** + * A callback that is known to be compatible with actually receiving and returning a span v2 JSON object. + * Only useful in conjunction with the {@link CoreOptions.traceLifecycle} option. + */ +export type SpanV2CompatibleBeforeSendSpanCallback = ((span: SpanV2JSON) => SpanV2JSON) & { _v2: true }; + /** Base configuration options for every SDK. */ export interface CoreOptions extends Omit>, 'integrations' | 'transport' | 'stackParser'> { diff --git a/packages/core/src/types-hoist/span.ts b/packages/core/src/types-hoist/span.ts index d82463768b7f..762d3519d0fe 100644 --- a/packages/core/src/types-hoist/span.ts +++ b/packages/core/src/types-hoist/span.ts @@ -1,3 +1,4 @@ +import type { SerializedAttributes } from './attributes'; import type { SpanLink, SpanLinkJSON } from './link'; import type { Measurements } from './measurement'; import type { HrTime } from './opentelemetry'; @@ -34,6 +35,24 @@ export type SpanAttributes = Partial<{ /** This type is aligned with the OpenTelemetry TimeInput type. */ export type SpanTimeInput = HrTime | number | Date; +export interface SpanV2JSON { + trace_id: string; + parent_span_id?: string; + span_id: string; + name: string; + start_timestamp: number; + end_timestamp: number; + status: 'ok' | 'error'; + kind: 'server' | 'client' | 'internal' | 'consumer' | 'producer'; + is_remote: boolean; + attributes?: SerializedAttributes; + links?: SpanLinkJSON[]; +} + +export type SerializedSpanContainer = { + items: Array; +}; + /** A JSON representation of a span. */ export interface SpanJSON { data: SpanAttributes; diff --git a/packages/core/src/utils/attributes.ts b/packages/core/src/utils/attributes.ts new file mode 100644 index 000000000000..d24e949df693 --- /dev/null +++ b/packages/core/src/utils/attributes.ts @@ -0,0 +1,100 @@ +import { normalize } from '..'; +import type { SerializedAttribute } from '../types-hoist/attributes'; +import { Primitive } from '../types-hoist/misc'; +import type { SpanAttributes, SpanAttributeValue } from '../types-hoist/span'; +import { isPrimitive } from './is'; + +/** + * Converts an attribute value to a serialized attribute value object, containing + * a type descriptor as well as the value. + * + * TODO: dedupe this with the logs version of the function (didn't do this yet to avoid + * dependance on logs/spans for the open questions RE array and object attribute types) + * + * @param value - The value of the log attribute. + * @returns The serialized log attribute. + */ +export function attributeValueToSerializedAttribute(value: unknown): SerializedAttribute { + switch (typeof value) { + case 'number': + if (Number.isInteger(value)) { + return { + value, + type: 'integer', + }; + } + return { + value, + type: 'double', + }; + case 'boolean': + return { + value, + type: 'boolean', + }; + case 'string': + return { + value, + type: 'string', + }; + default: { + let stringValue = ''; + try { + stringValue = JSON.stringify(value) ?? ''; + } catch { + // Do nothing + } + return { + value: stringValue, + type: 'string', + }; + } + } +} + +/** + * Given an object that might contain keys with primitive, array, or object values, + * return a SpanAttributes object that flattens the object into a single level. + * - Nested keys are separated by '.'. + * - arrays are stringified (TODO: might change, depending on how we support array attributes) + * - objects are flattened + * - primitives are added directly + * - nullish values are ignored + * - maxDepth is the maximum depth to flatten the object to + * + * @param obj - The object to flatten into span attributes + * @returns The span attribute object + */ +export function attributesFromObject(obj: Record, maxDepth = 3): SpanAttributes { + const result: Record = {}; + + function primitiveOrToString(current: unknown): number | boolean | string { + if (typeof current === 'number' || typeof current === 'boolean' || typeof current === 'string') { + return current; + } + return String(current); + } + + function flatten(current: unknown, prefix: string, depth: number): void { + if (current == null) { + return; + } else if (depth >= maxDepth) { + result[prefix] = primitiveOrToString(current); + return; + } else if (Array.isArray(current)) { + result[prefix] = JSON.stringify(current); + } else if (typeof current === 'number' || typeof current === 'string' || typeof current === 'boolean') { + result[prefix] = current; + } else if (typeof current === 'object' && current !== null && !Array.isArray(current) && depth < maxDepth) { + for (const [key, value] of Object.entries(current as Record)) { + flatten(value, prefix ? `${prefix}.${key}` : key, depth + 1); + } + } + } + + const normalizedObj = normalize(obj, maxDepth); + + flatten(normalizedObj, '', 0); + + return result; +} diff --git a/packages/core/src/utils/beforeSendSpan.ts b/packages/core/src/utils/beforeSendSpan.ts new file mode 100644 index 000000000000..7f04bc269b3b --- /dev/null +++ b/packages/core/src/utils/beforeSendSpan.ts @@ -0,0 +1,32 @@ +import type { ClientOptions, SpanV2CompatibleBeforeSendSpanCallback } from '../types-hoist/options'; +import type { SpanV2JSON } from '../types-hoist/span'; +import { addNonEnumerableProperty } from './object'; + +/** + * A wrapper to use the new span format in your `beforeSendSpan` callback. + * + * @example + * + * Sentry.init({ + * beforeSendSpan: makeV2Callback((span) => { + * return span; + * }), + * }); + * + * @param callback + * @returns + */ +export function makeV2Callback(callback: (span: SpanV2JSON) => SpanV2JSON): SpanV2CompatibleBeforeSendSpanCallback { + addNonEnumerableProperty(callback, '_v2', true); + // type-casting here because TS can't infer the type correctly + return callback as SpanV2CompatibleBeforeSendSpanCallback; +} + +/** + * Typesafe check to identify the expected span json format of the `beforeSendSpan` callback. + */ +export function isV2BeforeSendSpanCallback( + callback: ClientOptions['beforeSendSpan'], +): callback is SpanV2CompatibleBeforeSendSpanCallback { + return !!callback && '_v2' in callback && !!callback._v2; +} diff --git a/packages/core/src/utils/should-ignore-span.ts b/packages/core/src/utils/should-ignore-span.ts index a8d3ac0211c7..f05f0dc5402e 100644 --- a/packages/core/src/utils/should-ignore-span.ts +++ b/packages/core/src/utils/should-ignore-span.ts @@ -1,28 +1,47 @@ import { DEBUG_BUILD } from '../debug-build'; +import { SEMANTIC_ATTRIBUTE_SENTRY_OP } from '../semanticAttributes'; import type { ClientOptions } from '../types-hoist/options'; -import type { SpanJSON } from '../types-hoist/span'; +import type { SpanJSON, SpanV2JSON } from '../types-hoist/span'; import { debug } from './debug-logger'; import { isMatchingPattern } from './string'; -function logIgnoredSpan(droppedSpan: Pick): void { - debug.log(`Ignoring span ${droppedSpan.op} - ${droppedSpan.description} because it matches \`ignoreSpans\`.`); +function logIgnoredSpan(spanName: string, spanOp: string | undefined): void { + debug.log(`Ignoring span ${spanOp ? `${spanOp} - ` : ''}${spanName} because it matches \`ignoreSpans\`.`); } /** * Check if a span should be ignored based on the ignoreSpans configuration. */ export function shouldIgnoreSpan( - span: Pick, + span: Pick | Pick, ignoreSpans: Required['ignoreSpans'], ): boolean { - if (!ignoreSpans?.length || !span.description) { + if (!ignoreSpans?.length) { + return false; + } + + const { spanName, spanOp: spanOpAttributeOrString } = + 'description' in span + ? { spanName: span.description, spanOp: span.op } + : 'name' in span + ? { spanName: span.name, spanOp: span.attributes?.[SEMANTIC_ATTRIBUTE_SENTRY_OP] } + : { spanName: '', spanOp: '' }; + + const spanOp = + typeof spanOpAttributeOrString === 'string' + ? spanOpAttributeOrString + : spanOpAttributeOrString?.type === 'string' + ? spanOpAttributeOrString.value + : undefined; + + if (!spanName) { return false; } for (const pattern of ignoreSpans) { if (isStringOrRegExp(pattern)) { - if (isMatchingPattern(span.description, pattern)) { - DEBUG_BUILD && logIgnoredSpan(span); + if (isMatchingPattern(spanName, pattern)) { + DEBUG_BUILD && logIgnoredSpan(spanName, spanOp); return true; } continue; @@ -32,15 +51,15 @@ export function shouldIgnoreSpan( continue; } - const nameMatches = pattern.name ? isMatchingPattern(span.description, pattern.name) : true; - const opMatches = pattern.op ? span.op && isMatchingPattern(span.op, pattern.op) : true; + const nameMatches = pattern.name ? isMatchingPattern(spanName, pattern.name) : true; + const opMatches = pattern.op ? spanOp && isMatchingPattern(spanOp, pattern.op) : true; // This check here is only correct because we can guarantee that we ran `isMatchingPattern` // for at least one of `nameMatches` and `opMatches`. So in contrary to how this looks, // not both op and name actually have to match. This is the most efficient way to check // for all combinations of name and op patterns. if (nameMatches && opMatches) { - DEBUG_BUILD && logIgnoredSpan(span); + DEBUG_BUILD && logIgnoredSpan(spanName, spanOp); return true; } } @@ -52,7 +71,10 @@ export function shouldIgnoreSpan( * Takes a list of spans, and a span that was dropped, and re-parents the child spans of the dropped span to the parent of the dropped span, if possible. * This mutates the spans array in place! */ -export function reparentChildSpans(spans: SpanJSON[], dropSpan: SpanJSON): void { +export function reparentChildSpans( + spans: Pick[], + dropSpan: Pick, +): void { const droppedSpanParentId = dropSpan.parent_span_id; const droppedSpanId = dropSpan.span_id; diff --git a/packages/core/src/utils/spanUtils.ts b/packages/core/src/utils/spanUtils.ts index 6e7c62c7631a..32eadacd5351 100644 --- a/packages/core/src/utils/spanUtils.ts +++ b/packages/core/src/utils/spanUtils.ts @@ -8,16 +8,18 @@ import { SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, } from '../semanticAttributes'; import type { SentrySpan } from '../tracing/sentrySpan'; -import { SPAN_STATUS_OK, SPAN_STATUS_UNSET } from '../tracing/spanstatus'; +import { SPAN_STATUS_ERROR, SPAN_STATUS_OK, SPAN_STATUS_UNSET } from '../tracing/spanstatus'; import { getCapturedScopesOnSpan } from '../tracing/utils'; +import type { SerializedAttributes } from '../types-hoist/attributes'; import type { TraceContext } from '../types-hoist/context'; import type { SpanLink, SpanLinkJSON } from '../types-hoist/link'; -import type { Span, SpanAttributes, SpanJSON, SpanOrigin, SpanTimeInput } from '../types-hoist/span'; +import type { Span, SpanAttributes, SpanJSON, SpanOrigin, SpanTimeInput, SpanV2JSON } from '../types-hoist/span'; import type { SpanStatus } from '../types-hoist/spanStatus'; import { addNonEnumerableProperty } from '../utils/object'; import { generateSpanId } from '../utils/propagationContext'; import { timestampInSeconds } from '../utils/time'; import { generateSentryTraceHeader, generateTraceparentHeader } from '../utils/tracing'; +import { attributeValueToSerializedAttribute } from './attributes'; import { consoleSandbox } from './debug-logger'; import { _getSpanForScope } from './spanOnScope'; @@ -92,7 +94,7 @@ export function spanToTraceparentHeader(span: Span): string { * If the links array is empty, it returns `undefined` so the empty value can be dropped before it's sent. */ export function convertSpanLinksForEnvelope(links?: SpanLink[]): SpanLinkJSON[] | undefined { - if (links && links.length > 0) { + if (links?.length) { return links.map(({ context: { spanId, traceId, traceFlags, ...restContext }, attributes }) => ({ span_id: spanId, trace_id: traceId, @@ -104,6 +106,24 @@ export function convertSpanLinksForEnvelope(links?: SpanLink[]): SpanLinkJSON[] return undefined; } } +/** + * + * @param links + * @returns + */ +export function getV2SpanLinks(links?: SpanLink[]): SpanLinkJSON[] | undefined { + if (links?.length) { + return links.map(({ context: { spanId, traceId, traceFlags, ...restContext }, attributes }) => ({ + span_id: spanId, + trace_id: traceId, + sampled: traceFlags === TRACE_FLAG_SAMPLED, + ...(attributes && { attributes: getV2Attributes(attributes) }), + ...restContext, + })); + } else { + return undefined; + } +} /** * Convert a span time input into a timestamp in seconds. @@ -187,6 +207,61 @@ export function spanToJSON(span: Span): SpanJSON { }; } +/** + * Convert a span to a SpanV2JSON representation. + * @returns + */ +export function spanToV2JSON(span: Span): SpanV2JSON { + if (spanIsSentrySpan(span)) { + return span.getSpanV2JSON(); + } + + const { spanId: span_id, traceId: trace_id, isRemote } = span.spanContext(); + + // Handle a span from @opentelemetry/sdk-base-trace's `Span` class + if (spanIsOpenTelemetrySdkTraceBaseSpan(span)) { + const { attributes, startTime, name, endTime, status, links } = span; + + // In preparation for the next major of OpenTelemetry, we want to support + // looking up the parent span id according to the new API + // In OTel v1, the parent span id is accessed as `parentSpanId` + // In OTel v2, the parent span id is accessed as `spanId` on the `parentSpanContext` + const parentSpanId = + 'parentSpanId' in span + ? span.parentSpanId + : 'parentSpanContext' in span + ? (span.parentSpanContext as { spanId?: string } | undefined)?.spanId + : undefined; + + return { + name, + span_id, + trace_id, + parent_span_id: parentSpanId, + start_timestamp: spanTimeInputToSeconds(startTime), + end_timestamp: spanTimeInputToSeconds(endTime), + is_remote: isRemote || false, + kind: 'internal', // TODO: Figure out how to get this from the OTel span as it's not publicly exposed + status: getV2StatusMessage(status), + attributes: getV2Attributes(attributes), + links: getV2SpanLinks(links), + }; + } + + // Finally, as a fallback, at least we have `spanContext()`.... + // This should not actually happen in reality, but we need to handle it for type safety. + return { + span_id, + trace_id, + start_timestamp: 0, + name: '', + end_timestamp: 0, + status: 'ok', + kind: 'internal', + is_remote: isRemote || false, + }; +} + function spanIsOpenTelemetrySdkTraceBaseSpan(span: Span): span is OpenTelemetrySdkTraceBaseSpan { const castSpan = span as Partial; return !!castSpan.attributes && !!castSpan.startTime && !!castSpan.name && !!castSpan.endTime && !!castSpan.status; @@ -237,6 +312,27 @@ export function getStatusMessage(status: SpanStatus | undefined): string | undef return status.message || 'unknown_error'; } +/** + * Convert the various statuses to the ones expected by Sentry ('ok' is default) + */ +export function getV2StatusMessage(status: SpanStatus | undefined): 'ok' | 'error' { + return !status || + status.code === SPAN_STATUS_UNSET || + (status.code === SPAN_STATUS_ERROR && status.message === 'unknown_error') + ? 'ok' + : 'error'; +} + +/** + * Convert the attributes to the ones expected by Sentry, including the type annotation + */ +export function getV2Attributes(attributes: SpanAttributes): SerializedAttributes { + return Object.entries(attributes).reduce((acc, [key, value]) => { + acc[key] = attributeValueToSerializedAttribute(value); + return acc; + }, {} as SerializedAttributes); +} + const CHILD_SPANS_FIELD = '_sentryChildSpans'; const ROOT_SPAN_FIELD = '_sentryRootSpan'; @@ -298,7 +394,12 @@ export function getSpanDescendants(span: SpanWithPotentialChildren): Span[] { /** * Returns the root span of a given span. */ -export function getRootSpan(span: SpanWithPotentialChildren): Span { +export const getRootSpan = getSegmentSpan; + +/** + * Returns the segment span of a given span. + */ +export function getSegmentSpan(span: SpanWithPotentialChildren): Span { return span[ROOT_SPAN_FIELD] || span; } diff --git a/packages/core/test/lib/utils/attributes.test.ts b/packages/core/test/lib/utils/attributes.test.ts new file mode 100644 index 000000000000..9dd05e0e5b28 --- /dev/null +++ b/packages/core/test/lib/utils/attributes.test.ts @@ -0,0 +1,99 @@ +import { describe, expect, it } from 'vitest'; +import { attributesFromObject } from '../../../src/utils/attributes'; + +describe('attributesFromObject', () => { + it('flattens an object', () => { + const context = { + a: 1, + b: { c: { d: 2 } }, + }; + + const result = attributesFromObject(context); + + expect(result).toEqual({ + a: 1, + 'b.c.d': 2, + }); + }); + + it('flattens an object with a max depth', () => { + const context = { + a: 1, + b: { c: { d: 2 } }, + }; + + const result = attributesFromObject(context, 2); + + expect(result).toEqual({ + a: 1, + 'b.c': '[Object]', + }); + }); + + it('flattens an object an array', () => { + const context = { + a: 1, + b: { c: { d: 2 } }, + integrations: ['foo', 'bar'], + }; + + const result = attributesFromObject(context); + + expect(result).toEqual({ + a: 1, + 'b.c.d': 2, + integrations: '["foo","bar"]', + }); + }); + + it('handles a circular object', () => { + const context = { + a: 1, + b: { c: { d: 2 } }, + }; + context.b.c.e = context.b; + + const result = attributesFromObject(context, 5); + + expect(result).toEqual({ + a: 1, + 'b.c.d': 2, + 'b.c.e': '[Circular ~]', + }); + }); + + it('handles a circular object in an array', () => { + const context = { + a: 1, + b: { c: { d: 2 } }, + integrations: ['foo', 'bar'], + }; + + // @ts-expect-error - this is fine + context.integrations[0] = context.integrations; + + const result = attributesFromObject(context, 5); + + expect(result).toEqual({ + a: 1, + 'b.c.d': 2, + integrations: '["[Circular ~]","bar"]', + }); + }); + + it('handles objects in arrays', () => { + const context = { + a: 1, + b: { c: { d: 2 } }, + integrations: [{ name: 'foo' }, { name: 'bar' }], + }; + + const result = attributesFromObject(context); + + expect(result).toEqual({ + a: 1, + 'b.c.d': 2, + integrations: '[{"name":"foo"},{"name":"bar"}]', + }); + }); +});