Skip to content
Merged
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 @@ -124,6 +124,7 @@ export {
getActiveSpan,
addChildSpanToSpan,
spanTimeInputToSeconds,
otelTimeInputToSeconds,
updateSpanName,
INTERNAL_setSegmentNameSourceIfSegment,
} from './utils/spanUtils';
Expand Down
2 changes: 2 additions & 0 deletions packages/core/src/tracing/idleSpan.ts
Original file line number Diff line number Diff line change
Expand Up @@ -322,6 +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' });
// NOTE: This deliberately does not work for OTEL-provider spans (which use ms timestamps)
// We do not really support OTEL in the browser, which is the only place that uses idle spans
childSpan.end(endTimestamp);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Stale provider span cancel test

Medium Severity

This test still expects a tracer-provider child to share the idle span end time, but the cancel path now passes the seconds endTimestamp through unchanged. Provider spans interpret that number as relative milliseconds, so the assertion no longer matches the intended behavior. Testing conventions require tests to cover the newly added behavior.

Fix in Cursor Fix in Web

Triggered by project rule: PR Review Guidelines for Cursor Bot

Reviewed by Cursor Bugbot for commit 7a5b50f. Configure here.

DEBUG_BUILD &&
debug.log('[Tracing] Cancelling span since span ended early', JSON.stringify(childSpan, undefined, 2));
Expand Down
13 changes: 9 additions & 4 deletions packages/core/src/tracing/sentrySpan.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ import {
getStreamedSpanLinks,
INTERNAL_setSegmentNameSourceIfSegment,
spanIsSegment,
otelTimeInputToSeconds,
spanTimeInputToSeconds,
spanToStaticSpanJSON,
spanToTransactionTraceContext,
Expand Down Expand Up @@ -219,7 +220,7 @@ export class SentrySpan implements Span {
if (this._frozen) {
return;
}
this._startTime = spanTimeInputToSeconds(timeInput);
this._startTime = timeInputToSeconds(this, timeInput);
this._startPerformanceNow = undefined;
}

Expand Down Expand Up @@ -253,7 +254,7 @@ export class SentrySpan implements Span {
return;
}

this._endTime = endTimestamp == null ? this._getEndTimeFromDuration() : spanTimeInputToSeconds(endTimestamp);
this._endTime = endTimestamp == null ? this._getEndTimeFromDuration() : timeInputToSeconds(this, endTimestamp);
logSpanEnd(this);

this._onSpanEnded();
Expand Down Expand Up @@ -336,12 +337,12 @@ export class SentrySpan implements Span {
}
DEBUG_BUILD && debug.log('[Tracing] Adding an event to span:', name);

const time = isSpanTimeInput(attributesOrStartTime) ? attributesOrStartTime : startTime || timestampInSeconds();
const time = isSpanTimeInput(attributesOrStartTime) ? attributesOrStartTime : startTime || undefined;
const attributes = isSpanTimeInput(attributesOrStartTime) ? {} : attributesOrStartTime || {};

const event: TimedEvent = {
name,
time: spanTimeInputToSeconds(time),
time: timeInputToSeconds(this, time),
attributes,
};

Expand Down Expand Up @@ -539,6 +540,10 @@ export class SentrySpan implements Span {
}
}

function timeInputToSeconds(span: SentrySpan, input: SpanTimeInput | undefined): number {
return spanIsTracerProviderSpan(span) ? otelTimeInputToSeconds(input) : spanTimeInputToSeconds(input);
}

function isSpanTimeInput(value: undefined | SpanAttributes | SpanTimeInput): value is SpanTimeInput {
return (value && typeof value === 'number') || value instanceof Date || Array.isArray(value);
}
Expand Down
19 changes: 18 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 { performanceTimeToSeconds, timestampInSeconds } from '../utils/time';
import { generateSentryTraceHeader, generateTraceparentHeader } from '../utils/tracing';
import { _getSpanForScope } from './spanOnScope';

Expand Down Expand Up @@ -164,6 +164,23 @@ function ensureTimestampInSeconds(timestamp: number): number {
return isMs ? timestamp / 1000 : timestamp;
}

/**
* Convert an OpenTelemetry time input into a timestamp in seconds.
*
* OpenTelemetry numbers are always milliseconds, either since the epoch or since the performance time origin
* (`performance.now()`).
*/
export function otelTimeInputToSeconds(input: SpanTimeInput | undefined): number {
if (typeof input !== 'number') {
return spanTimeInputToSeconds(input);
}
// Epoch milliseconds are > 1e12 for any date after 2001, `performance.now()` would need ~30 years of uptime to get there
if (input < 1e12) {
return performanceTimeToSeconds(input) ?? timestampInSeconds();
}
return input / 1000;
}

/**
* Convert a span to a static JSON representation.
*/
Expand Down
47 changes: 44 additions & 3 deletions packages/core/test/lib/tracing/sentrySpan.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ import {
TRACE_FLAG_NONE,
TRACE_FLAG_SAMPLED,
} from '../../../src/utils/spanUtils';
import { timestampInSeconds } from '../../../src/utils/time';
import { browserPerformanceTimeOrigin, timestampInSeconds } from '../../../src/utils/time';
import { getDefaultTestClientOptions, TestClient } from '../../mocks/client';
import { SENTRY_SEGMENT_NAME_SOURCE } from '@sentry/conventions/attributes';

Expand Down Expand Up @@ -169,15 +169,56 @@ 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');
expect(spanToStaticSpanJSON(span).timestamp).toBe(2);
});
});

describe('tracer-provider span timestamps', () => {
it('reads numbers as OpenTelemetry milliseconds', () => {
const origin = browserPerformanceTimeOrigin()!;
const span = new SentrySpan({ name: 'span' });
markSpanAsTracerProviderSpan(span);

span.updateStartTime(1_000);
span.addEvent('event', 1_010);
span.end(1_025);

const json = spanToStaticSpanJSON(span);
expect(json.start_timestamp).toBe((origin + 1_000) / 1000);
expect(json.timestamp).toBe((origin + 1_025) / 1000);
expect(span['_events'][0]?.time).toBe((origin + 1_010) / 1000);
});

it('defaults an event without a time to now', () => {
const span = new SentrySpan({ name: 'span' });
markSpanAsTracerProviderSpan(span);

const before = timestampInSeconds();
span.addEvent('event');

expect(span['_events'][0]?.time).toBeGreaterThanOrEqual(before);
expect(span['_events'][0]?.time).toBeLessThanOrEqual(timestampInSeconds());
});

it('keeps reading numbers as seconds on other spans', () => {
const span = new SentrySpan({ name: 'span' });

span.updateStartTime(1_000);
span.addEvent('event', 1_010);
span.end(1_025);

const json = spanToStaticSpanJSON(span);
expect(json.start_timestamp).toBe(1_000);
expect(json.timestamp).toBe(1_025);
expect(span['_events'][0]?.time).toBe(1_010);
});
});

describe('child span retention', () => {
it('stops tracking children on a segment span once it has been captured', () => {
const client = new TestClient(getDefaultTestClientOptions({ tracesSampleRate: 1 }));
Expand Down
44 changes: 43 additions & 1 deletion packages/core/test/lib/utils/spanUtils.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { SENTRY_LINK_TYPE, SENTRY_STATUS_MESSAGE, SENTRY_OP, SENTRY_ORIGIN } from '@sentry/conventions/attributes';
import { beforeEach, describe, expect, it, test } from 'vitest';
import { afterEach, beforeEach, describe, expect, it, test, vi } from 'vitest';
import {
browserPerformanceTimeOrigin,
convertSpanLinksForEnvelope,
getCurrentScope,
Scope,
Expand Down Expand Up @@ -28,6 +29,7 @@ import {
getRootSpan,
getSpanDescendants,
spanIsSampled,
otelTimeInputToSeconds,
spanTimeInputToSeconds,
spanToStaticSpanJSON,
spanToJSON,
Expand Down Expand Up @@ -317,6 +319,46 @@ describe('spanTimeInputToSeconds', () => {
});
});

describe('otelTimeInputToSeconds', () => {
afterEach(() => {
vi.unstubAllGlobals();
vi.resetModules();
});

it('treats epoch milliseconds as milliseconds', () => {
const timestamp = Date.now();
expect(otelTimeInputToSeconds(timestamp)).toEqual(timestamp / 1000);
});

it('treats small numbers as `performance.now()` milliseconds', () => {
const origin = browserPerformanceTimeOrigin()!;
expect(otelTimeInputToSeconds(1_234.5)).toEqual((origin + 1_234.5) / 1000);
});

it('treats `performance.now()` milliseconds after a long uptime as relative', () => {
// 30 days of uptime, which overlaps with the range of epoch seconds
const performanceNow = 30 * 24 * 60 * 60 * 1000;
const origin = browserPerformanceTimeOrigin()!;
expect(otelTimeInputToSeconds(performanceNow)).toEqual((origin + performanceNow) / 1000);
});

it('treats a `performance.now()` value that is still in the future as relative', () => {
const performanceNow = performance.now() + 25;
const origin = browserPerformanceTimeOrigin()!;
expect(otelTimeInputToSeconds(performanceNow)).toEqual((origin + performanceNow) / 1000);
});

it('falls back to the current time if the Performance API is unavailable', async () => {
vi.stubGlobal('performance', undefined);
const { otelTimeInputToSeconds } = await import('../../../src/utils/spanUtils');

const before = Date.now() / 1000;
const seconds = otelTimeInputToSeconds(1_234.5);
expect(seconds).toBeGreaterThanOrEqual(before);
expect(seconds).toBeLessThanOrEqual(Date.now() / 1000);
});
});

describe('spanToStaticSpanJSON', () => {
describe('SentrySpan', () => {
it('works with a simple span', () => {
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 @@ -14,6 +14,7 @@ import {
setCapturedScopesOnSpan,
spanIsIgnored,
spanKindToName,
otelTimeInputToSeconds,
startNewTrace,
withScope,
} from '@sentry/core';
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 != null ? otelTimeInputToSeconds(options.startTime) : undefined,
};

// Convert otel kind to our sentry.kind attribtue
Expand Down
21 changes: 21 additions & 0 deletions packages/opentelemetry/test/tracerProvider.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,27 @@ describe('SentryTracerProvider', () => {
mockSdkInit({ tracesSampleRate: 1 });
});

it('converts `performance.now()` start and end times', () => {
const start = performance.now();
const span = trace.getTracer('test').startSpan('span', { startTime: start });
span.end(start + 25);

const { start_timestamp, end_timestamp } = spanToJSON(span as Span);
expect(start_timestamp).toBeCloseTo(Date.now() / 1000, 0);
expect(end_timestamp! - start_timestamp).toBeCloseTo(0.025, 6);
});

it('converts epoch millisecond start and end times', () => {
const start = Date.now();
const span = trace.getTracer('test').startSpan('span', { startTime: start });
span.end(start + 25);

expect(spanToJSON(span as Span)).toMatchObject({
start_timestamp: start / 1000,
end_timestamp: (start + 25) / 1000,
});
});

it('creates Sentry spans from the global OpenTelemetry tracer', () => {
const span = trace.getTracer('test').startSpan('SELECT users', {
attributes: {
Expand Down
Loading