diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 1eb1d0ff4d72..c8608fde93b4 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -125,6 +125,7 @@ export { getActiveSpan, addChildSpanToSpan, spanTimeInputToSeconds, + otelTimeInputToSeconds, updateSpanName, INTERNAL_setSegmentNameSourceIfSegment, } from './utils/spanUtils'; diff --git a/packages/core/src/tracing/idleSpan.ts b/packages/core/src/tracing/idleSpan.ts index 32e23ee357ba..e34d522813db 100644 --- a/packages/core/src/tracing/idleSpan.ts +++ b/packages/core/src/tracing/idleSpan.ts @@ -322,7 +322,8 @@ export function startIdleSpan(startSpanOptions: StartSpanOptions, options: Parti // We cancel all pending spans with status "cancelled" to indicate the idle span was finished early if (childSpan.isRecording()) { childSpan.setStatus({ code: SPAN_STATUS_ERROR, message: 'cancelled' }); - childSpan.end(endTimestamp); + // Provider-created descendants use OTel's millisecond-based numeric timestamps. + childSpan.end([Math.floor(endTimestamp), (endTimestamp % 1) * 1e9]); DEBUG_BUILD && debug.log('[Tracing] Cancelling span since span ended early', JSON.stringify(childSpan, undefined, 2)); } diff --git a/packages/core/src/tracing/sentrySpan.ts b/packages/core/src/tracing/sentrySpan.ts index 42d91f6057d6..d71069f4fcb5 100644 --- a/packages/core/src/tracing/sentrySpan.ts +++ b/packages/core/src/tracing/sentrySpan.ts @@ -38,6 +38,7 @@ import { INTERNAL_setSegmentNameSourceIfSegment, spanIsSegment, spanTimeInputToSeconds, + otelTimeInputToSeconds, spanToStaticSpanJSON, spanToTransactionTraceContext, TRACE_FLAG_NONE, @@ -253,7 +254,12 @@ export class SentrySpan implements Span { return; } - this._endTime = endTimestamp == null ? this._getEndTimeFromDuration() : spanTimeInputToSeconds(endTimestamp); + this._endTime = + endTimestamp == null + ? this._getEndTimeFromDuration() + : spanIsTracerProviderSpan(this) + ? otelTimeInputToSeconds(endTimestamp) + : spanTimeInputToSeconds(endTimestamp); logSpanEnd(this); this._onSpanEnded(); diff --git a/packages/core/src/utils/spanUtils.ts b/packages/core/src/utils/spanUtils.ts index e1615b9bf5c4..4a9af57eed14 100644 --- a/packages/core/src/utils/spanUtils.ts +++ b/packages/core/src/utils/spanUtils.ts @@ -30,7 +30,7 @@ import type { import type { SpanStatus } from '../types/spanStatus'; import { addNonEnumerableProperty } from '../utils/object'; import { generateSpanId } from '../utils/propagationContext'; -import { timestampInSeconds } from '../utils/time'; +import { browserPerformanceTimeOrigin, timestampInSeconds } from '../utils/time'; import { generateSentryTraceHeader, generateTraceparentHeader } from '../utils/tracing'; import { _getSpanForScope } from './spanOnScope'; @@ -156,6 +156,22 @@ export function spanTimeInputToSeconds(input: SpanTimeInput | undefined): number return timestampInSeconds(); } +/** Convert OpenTelemetry TimeInput, whose numeric values are milliseconds, to Unix seconds. */ +export function otelTimeInputToSeconds(input: SpanTimeInput | undefined): number { + if (typeof input === 'number') { + const origin = browserPerformanceTimeOrigin(input); + // Match OTel's distinction between epoch milliseconds and performance.now(), + // including epoch values just before the process's time origin. + return (origin !== undefined && input < origin / 2 ? origin + input : input) / 1000; + } + + if (input instanceof Date) { + return input.getTime() / 1000; + } + + return spanTimeInputToSeconds(input); +} + /** * Converts a timestamp to second, if it was in milliseconds, or keeps it as second. */ diff --git a/packages/core/test/lib/tracing/idleSpan.test.ts b/packages/core/test/lib/tracing/idleSpan.test.ts index a129c35e6a13..4c761b005eae 100644 --- a/packages/core/test/lib/tracing/idleSpan.test.ts +++ b/packages/core/test/lib/tracing/idleSpan.test.ts @@ -17,6 +17,7 @@ import { startSpanManual, } from '../../../src'; import { startIdleSpan, TRACING_DEFAULTS } from '../../../src/tracing/idleSpan'; +import { markSpanAsTracerProviderSpan } from '../../../src/tracing/utils'; import type { Event } from '../../../src/types/event'; import type { Span } from '../../../src/types/span'; import { getDefaultTestClientOptions, TestClient } from '../../mocks/client'; @@ -58,6 +59,17 @@ describe('startIdleSpan', () => { expect(getActiveSpan()).toBe(undefined); }); + it('cancels a provider-created child using the idle span end timestamp', () => { + const endTimestamp = Date.now() / 1000 + 1; + const idleSpan = startIdleSpan({ name: 'idle span' }); + const child = startInactiveSpan({ name: 'OpenTelemetry child' }); + markSpanAsTracerProviderSpan(child); + + idleSpan.end(endTimestamp); + + expect(spanToJSON(child).end_timestamp).toBe(endTimestamp); + }); + it('returns non recording span if tracing is disabled', () => { const options = getDefaultTestClientOptions({ dsn }); const client = new TestClient(options); diff --git a/packages/core/test/lib/tracing/sentrySpan.test.ts b/packages/core/test/lib/tracing/sentrySpan.test.ts index ad49179d7ce2..c26895e4d0ce 100644 --- a/packages/core/test/lib/tracing/sentrySpan.test.ts +++ b/packages/core/test/lib/tracing/sentrySpan.test.ts @@ -169,8 +169,8 @@ describe('SentrySpan', () => { }); markSpanAsTracerProviderSpan(span); - span.end(2); - span.end(3); + span.end([2, 0]); + span.end([3, 0]); span.setAttribute('key', 'after'); expect(spanToStaticSpanJSON(span).data?.['key']).toBe('before'); diff --git a/packages/opentelemetry/src/tracer.ts b/packages/opentelemetry/src/tracer.ts index 3b93f4fe8e51..34e0b932c13c 100644 --- a/packages/opentelemetry/src/tracer.ts +++ b/packages/opentelemetry/src/tracer.ts @@ -10,6 +10,7 @@ import { getCurrentScope, getIsolationScope, markSpanAsTracerProviderSpan, + otelTimeInputToSeconds, SentryNonRecordingSpan, setCapturedScopesOnSpan, spanIsIgnored, @@ -136,7 +137,7 @@ export class SentryTracer implements Tracer { name, attributes: (options.attributes as SpanAttributes) || {}, links: convertSpanLinkType(options.links), - startTime: options.startTime, + startTime: options.startTime === undefined ? undefined : otelTimeInputToSeconds(options.startTime), }; // Convert otel kind to our sentry.kind attribtue diff --git a/packages/opentelemetry/test/tracerProvider.test.ts b/packages/opentelemetry/test/tracerProvider.test.ts index 878c878a1af1..17f14213d012 100644 --- a/packages/opentelemetry/test/tracerProvider.test.ts +++ b/packages/opentelemetry/test/tracerProvider.test.ts @@ -45,6 +45,43 @@ describe('SentryTracerProvider', () => { }); }); + it('anchors performance timestamps to the Unix epoch and preserves millisecond durations', () => { + const before = Date.now() / 1000; + const start = performance.now(); + const span = trace.getTracer('test').startSpan('NextNodeServer.clientComponentLoading', { startTime: start }); + + span.end(start + 25); + + const json = spanToJSON(span as Span); + expect(json.start_timestamp).toBeGreaterThanOrEqual(before - 1); + expect(json.start_timestamp).toBeLessThanOrEqual(Date.now() / 1000 + 1); + expect(json.end_timestamp! - json.start_timestamp).toBeCloseTo(0.025, 5); + }); + + it('accepts zero as a performance timestamp', () => { + const span = trace.getTracer('test').startSpan('performance origin', { startTime: 0 }); + + span.end(25); + + const json = spanToJSON(span as Span); + expect(json.start_timestamp).toBeCloseTo(performance.timeOrigin / 1000, 2); + expect(json.end_timestamp! - json.start_timestamp).toBeCloseTo(0.025, 5); + }); + + it.each([ + { startTime: 1700000000000, endTime: 1700000000025 }, + { startTime: new Date(1700000000000), endTime: new Date(1700000000025) }, + { startTime: [1700000000, 0] as [number, number], endTime: [1700000000, 25000000] as [number, number] }, + ])('preserves absolute OpenTelemetry timestamps ($startTime)', ({ startTime, endTime }) => { + const span = trace.getTracer('test').startSpan('absolute timestamp', { startTime }); + + span.end(endTime); + + const json = spanToJSON(span as Span); + expect(json.start_timestamp).toBe(1700000000); + expect(json.end_timestamp).toBe(1700000000.025); + }); + it('parents inactive spans to the active OpenTelemetry span', () => { trace.getTracer('test').startActiveSpan('parent', parent => { const child = trace.getTracer('test').startSpan('child');