Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,7 @@ export {
getActiveSpan,
addChildSpanToSpan,
spanTimeInputToSeconds,
otelTimeInputToSeconds,
updateSpanName,
INTERNAL_setSegmentNameSourceIfSegment,
} from './utils/spanUtils';
Expand Down
3 changes: 2 additions & 1 deletion packages/core/src/tracing/idleSpan.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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));
}
Expand Down
8 changes: 7 additions & 1 deletion packages/core/src/tracing/sentrySpan.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ import {
INTERNAL_setSegmentNameSourceIfSegment,
spanIsSegment,
spanTimeInputToSeconds,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

h: instead of all of this, can we not just adjust spanTimeInputToSeconds to handle this case as well? that should already be called for started spans and when calling end, so this single point should be enough to tweak I believe?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The tricky bit is that Sentry accepts numbers in seconds, while OTel uses milliseconds, including performance.now(). Those values can overlap, so guessing in the shared helper could break existing Sentry timestamps. We could consolidate the helpers with an explicit input mode, but would still need to distinguish the two.

otelTimeInputToSeconds,
spanToStaticSpanJSON,
spanToTransactionTraceContext,
TRACE_FLAG_NONE,
Expand Down Expand Up @@ -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();
Expand Down
18 changes: 17 additions & 1 deletion packages/core/src/utils/spanUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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.
*/
Expand Down
12 changes: 12 additions & 0 deletions packages/core/test/lib/tracing/idleSpan.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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);
Expand Down
4 changes: 2 additions & 2 deletions packages/core/test/lib/tracing/sentrySpan.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
3 changes: 2 additions & 1 deletion packages/opentelemetry/src/tracer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
getCurrentScope,
getIsolationScope,
markSpanAsTracerProviderSpan,
otelTimeInputToSeconds,
SentryNonRecordingSpan,
setCapturedScopesOnSpan,
spanIsIgnored,
Expand Down Expand Up @@ -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
Expand Down
37 changes: 37 additions & 0 deletions packages/opentelemetry/test/tracerProvider.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
Loading