Skip to content
Open
6 changes: 3 additions & 3 deletions .size-limit.js
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ module.exports = [
path: 'packages/browser/build/npm/esm/prod/index.js',
import: createImport('init', 'browserTracingIntegration', 'replayIntegration', 'replayCanvasIntegration'),
gzip: true,
limit: '86 KB',
limit: '87 KB',
},
{
name: '@sentry/browser (incl. Tracing, Replay, Feedback)',
Expand Down Expand Up @@ -262,14 +262,14 @@ module.exports = [
path: createCDNPath('bundle.replay.logs.metrics.min.js'),
gzip: false,
brotli: false,
limit: '209 KB',
limit: '210 KB',
},
{
name: 'CDN Bundle (incl. Tracing, Replay) - uncompressed',
path: createCDNPath('bundle.tracing.replay.min.js'),
gzip: false,
brotli: false,
limit: '245 KB',
limit: '246 KB',
},
{
name: 'CDN Bundle (incl. Tracing, Replay, Logs, Metrics) - uncompressed',
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { expect } from '@playwright/test';
import { SDK_VERSION } from '@sentry/browser';
import { sentryTest, TEST_HOST } from '../../../utils/fixtures';
import { sentryTest } from '../../../utils/fixtures';
import { getExpectedReplayEvent } from '../../../utils/replayEventTemplates';
import { getReplayEvent, shouldSkipReplayTest, waitForReplayRequest } from '../../../utils/replayHelpers';

sentryTest('should capture replays (@sentry/browser export)', async ({ getLocalTestUrl, page }) => {
Expand All @@ -20,98 +20,17 @@ sentryTest('should capture replays (@sentry/browser export)', async ({ getLocalT
const replayEvent1 = getReplayEvent(await reqPromise1);

expect(replayEvent0).toBeDefined();
expect(replayEvent0).toEqual({
type: 'replay_event',
timestamp: expect.any(Number),
error_ids: [],
trace_ids: [],
urls: [`${TEST_HOST}/index.html`],
replay_id: expect.stringMatching(/\w{32}/),
replay_start_timestamp: expect.any(Number),
segment_id: 0,
replay_type: 'session',
event_id: expect.stringMatching(/\w{32}/),
environment: 'production',
contexts: {
culture: {
locale: expect.any(String),
timezone: expect.any(String),
calendar: expect.any(String),
},
},
sdk: {
integrations: expect.arrayContaining([
'InboundFilters',
'FunctionToString',
'BrowserApiErrors',
'Breadcrumbs',
'GlobalHandlers',
'LinkedErrors',
'Dedupe',
'HttpContext',
'BrowserSession',
'Replay',
]),
version: SDK_VERSION,
name: 'sentry.javascript.browser',
settings: {
infer_ip: 'never',
},
},
request: {
url: `${TEST_HOST}/index.html`,
headers: {
'User-Agent': expect.stringContaining(''),
},
},
platform: 'javascript',
});
expect(replayEvent0).toEqual(
getExpectedReplayEvent({
segment_id: 0,
}),
);

expect(replayEvent1).toBeDefined();
expect(replayEvent1).toEqual({
type: 'replay_event',
timestamp: expect.any(Number),
error_ids: [],
trace_ids: [],
urls: [],
replay_id: expect.stringMatching(/\w{32}/),
replay_start_timestamp: expect.any(Number),
segment_id: 1,
replay_type: 'session',
event_id: expect.stringMatching(/\w{32}/),
environment: 'production',
contexts: {
culture: {
locale: expect.any(String),
timezone: expect.any(String),
calendar: expect.any(String),
},
},
sdk: {
integrations: expect.arrayContaining([
'InboundFilters',
'FunctionToString',
'BrowserApiErrors',
'Breadcrumbs',
'GlobalHandlers',
'LinkedErrors',
'Dedupe',
'HttpContext',
'BrowserSession',
'Replay',
]),
version: SDK_VERSION,
name: 'sentry.javascript.browser',
settings: {
infer_ip: 'never',
},
},
request: {
url: `${TEST_HOST}/index.html`,
headers: {
'User-Agent': expect.stringContaining(''),
},
},
platform: 'javascript',
});
expect(replayEvent1).toEqual(
getExpectedReplayEvent({
segment_id: 1,
urls: [],
}),
);
});
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { expect } from '@playwright/test';
import { SDK_VERSION } from '@sentry/browser';
import { sentryTest, TEST_HOST } from '../../../utils/fixtures';
import { sentryTest } from '../../../utils/fixtures';
import { getExpectedReplayEvent } from '../../../utils/replayEventTemplates';
import { getReplayEvent, shouldSkipReplayTest, waitForReplayRequest } from '../../../utils/replayHelpers';

sentryTest('should capture replays (@sentry-internal/replay export)', async ({ getLocalTestUrl, page }) => {
Expand All @@ -20,98 +20,17 @@ sentryTest('should capture replays (@sentry-internal/replay export)', async ({ g
const replayEvent1 = getReplayEvent(await reqPromise1);

expect(replayEvent0).toBeDefined();
expect(replayEvent0).toEqual({
type: 'replay_event',
timestamp: expect.any(Number),
error_ids: [],
trace_ids: [],
urls: [`${TEST_HOST}/index.html`],
replay_id: expect.stringMatching(/\w{32}/),
replay_start_timestamp: expect.any(Number),
segment_id: 0,
replay_type: 'session',
event_id: expect.stringMatching(/\w{32}/),
environment: 'production',
contexts: {
culture: {
locale: expect.any(String),
timezone: expect.any(String),
calendar: expect.any(String),
},
},
sdk: {
integrations: expect.arrayContaining([
'InboundFilters',
'FunctionToString',
'BrowserApiErrors',
'Breadcrumbs',
'GlobalHandlers',
'LinkedErrors',
'Dedupe',
'HttpContext',
'BrowserSession',
'Replay',
]),
version: SDK_VERSION,
name: 'sentry.javascript.browser',
settings: {
infer_ip: 'never',
},
},
request: {
url: `${TEST_HOST}/index.html`,
headers: {
'User-Agent': expect.stringContaining(''),
},
},
platform: 'javascript',
});
expect(replayEvent0).toEqual(
getExpectedReplayEvent({
segment_id: 0,
}),
);

expect(replayEvent1).toBeDefined();
expect(replayEvent1).toEqual({
type: 'replay_event',
timestamp: expect.any(Number),
error_ids: [],
trace_ids: [],
urls: [],
replay_id: expect.stringMatching(/\w{32}/),
replay_start_timestamp: expect.any(Number),
segment_id: 1,
replay_type: 'session',
event_id: expect.stringMatching(/\w{32}/),
environment: 'production',
contexts: {
culture: {
locale: expect.any(String),
timezone: expect.any(String),
calendar: expect.any(String),
},
},
sdk: {
integrations: expect.arrayContaining([
'InboundFilters',
'FunctionToString',
'BrowserApiErrors',
'Breadcrumbs',
'GlobalHandlers',
'LinkedErrors',
'Dedupe',
'HttpContext',
'BrowserSession',
'Replay',
]),
version: SDK_VERSION,
name: 'sentry.javascript.browser',
settings: {
infer_ip: 'never',
},
},
request: {
url: `${TEST_HOST}/index.html`,
headers: {
'User-Agent': expect.stringContaining(''),
},
},
platform: 'javascript',
});
expect(replayEvent1).toEqual(
getExpectedReplayEvent({
urls: [],
segment_id: 1,
}),
);
});
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@ const DEFAULT_REPLAY_EVENT = {
type: 'replay_event',
timestamp: expect.any(Number),
error_ids: [],
trace_ids: [],
trace_ids: [expect.any(String)],
traces_by_timestamp: [[expect.any(Number), expect.any(String)]],
urls: [expect.stringContaining('/index.html')],
replay_id: expect.stringMatching(/\w{32}/),
replay_start_timestamp: expect.any(Number),
Expand Down
2 changes: 2 additions & 0 deletions packages/core/src/types-hoist/replay.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ export interface ReplayEvent extends Event {
replay_start_timestamp?: number;
error_ids: string[];
trace_ids: string[];
// Data expected is [timestamp (seconds), trace id]
traces_by_timestamp: [number, string][];
replay_id: string;
segment_id: number;
replay_type: ReplayRecordingMode;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,8 +37,8 @@ function handleTransactionEvent(replay: ReplayContainer, event: TransactionEvent
// Collect traceIds in _context regardless of `recordingMode`
// In error mode, _context gets cleared on every checkout
// We limit to max. 100 transactions linked
if (event.contexts?.trace?.trace_id && replayContext.traceIds.size < 100) {
replayContext.traceIds.add(event.contexts.trace.trace_id);
if (event.contexts?.trace?.trace_id && event.start_timestamp && replayContext.traceIds.length < 100) {
replayContext.traceIds.push([event.start_timestamp, event.contexts.trace.trace_id]);
}
}

Expand Down
34 changes: 30 additions & 4 deletions packages/replay-internal/src/replay.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,13 @@
/* eslint-disable max-lines */ // TODO: We might want to split this file up
import type { ReplayRecordingMode, Span } from '@sentry/core';
import { getActiveSpan, getClient, getRootSpan, SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, spanToJSON } from '@sentry/core';
import {
getActiveSpan,
getClient,
getCurrentScope,
getRootSpan,
SEMANTIC_ATTRIBUTE_SENTRY_SOURCE,
spanToJSON,
} from '@sentry/core';
import { EventType, record } from '@sentry-internal/rrweb';
import {
BUFFER_CHECKOUT_TIME,
Expand Down Expand Up @@ -192,7 +199,7 @@ export class ReplayContainer implements ReplayContainerInterface {
this._hasInitializedCoreListeners = false;
this._context = {
errorIds: new Set(),
traceIds: new Set(),
traceIds: [],
urls: [],
initialTimestamp: Date.now(),
initialUrl: '',
Expand Down Expand Up @@ -1098,7 +1105,11 @@ export class ReplayContainer implements ReplayContainerInterface {
private _clearContext(): void {
// XXX: `initialTimestamp` and `initialUrl` do not get cleared
this._context.errorIds.clear();
this._context.traceIds.clear();
// We want to preserve the most recent trace id for the next replay segment.
// This is so that we can associate replay events w/ the trace.
if (this._context.traceIds.length > 1) {
this._context.traceIds = this._context.traceIds.slice(-1);
}
this._context.urls = [];
}

Expand Down Expand Up @@ -1126,11 +1137,26 @@ export class ReplayContainer implements ReplayContainerInterface {
* Return and clear _context
*/
private _popEventContext(): PopEventContext {
if (this._context.traceIds.length === 0) {
const currentTraceId = getCurrentScope().getPropagationContext().traceId;
if (currentTraceId) {
// Previously, in order to associate a replay with a trace, sdk waits
// until after a `type: transaction` event is sent successfully. it's
// possible that the replay integration is loaded after this event is
// sent and never gets associated with any trace. in this case, use the
// trace from current scope and propagation context. We associate the
// current trace w/ the earliest timestamp in event buffer.
//
// This is in seconds to be consistent with how we normally collect
// trace ids from the SDK hook event
this._context.traceIds.push([this._context.initialTimestamp / 1000, currentTraceId]);
}
}
const _context = {
initialTimestamp: this._context.initialTimestamp,
initialUrl: this._context.initialUrl,
errorIds: Array.from(this._context.errorIds),
traceIds: Array.from(this._context.traceIds),
traceIds: this._context.traceIds,
Copy link

Choose a reason for hiding this comment

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

Bug: A race condition exists because _popEventContext returns a direct reference to the traceIds array, allowing it to be mutated during the async sendReplay operation.
Severity: MEDIUM

Suggested Fix

In _popEventContext, return a shallow copy of the traceIds array instead of a direct reference. This can be achieved by using the spread syntax [...this._context.traceIds] or the slice() method.

Prompt for AI Agent
Review the code at the location below. A potential bug has been identified by an AI
agent.
Verify if this is a real issue. If it is, propose a fix; if not, explain why it's not
valid.

Location: packages/replay-internal/src/replay.ts#L1159

Potential issue: The `_popEventContext` method returns a context object containing a
direct reference to the internal `_context.traceIds` array, not a copy. When a segment
flush is triggered with one or zero trace IDs, the subsequent call to `_clearContext`
does not reassign this array. This creates a race condition: during the `await` for the
asynchronous `sendReplay` operation, a new transaction event can trigger
`handleAfterSendEvent`, which pushes a new trace ID onto the shared array. As a result,
the replay segment being sent is mutated to include a trace ID that was captured after
the segment was finalized, leading to incorrect trace linkage.

Did we get this right? 👍 / 👎 to inform future reviews.

Copy link

Choose a reason for hiding this comment

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

Shared mutable array reference causes race condition during flush

Medium Severity

In _popEventContext, traceIds is assigned as a direct reference (traceIds: this._context.traceIds) instead of a copy. The old code used Array.from(this._context.traceIds). When _clearContext runs and traceIds.length <= 1, the array is not reassigned — so the returned context and this._context share the same mutable array. In _runFlush, there's an await this.eventBuffer.finish() between _popEventContext() and sendReplay(), during which new transaction events can .push() into the shared array, corrupting the data being sent.

Additional Locations (1)

Fix in Cursor Fix in Web

urls: this._context.urls,
};

Expand Down
6 changes: 3 additions & 3 deletions packages/replay-internal/src/types/replay.ts
Original file line number Diff line number Diff line change
Expand Up @@ -352,7 +352,7 @@ export interface PopEventContext extends CommonEventContext {
/**
* List of Sentry trace ids that have occurred during a replay segment
*/
traceIds: Array<string>;
traceIds: Array<[number, string]>;
}

/**
Expand All @@ -365,9 +365,9 @@ export interface InternalEventContext extends CommonEventContext {
errorIds: Set<string>;

/**
* Set of Sentry trace ids that have occurred during a replay segment
* List of <timestamp, trace_id> for Sentry traces that have occurred during a replay segment
*/
traceIds: Set<string>;
traceIds: Array<[number, string]>;
}

export type Sampled = false | 'session' | 'buffer';
Expand Down
4 changes: 3 additions & 1 deletion packages/replay-internal/src/util/sendReplayRequest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,12 +37,14 @@ export async function sendReplayRequest({
return Promise.resolve({});
}

const uniqueTraceIds = Array.from(new Set(traceIds.map(([_ts, traceId]) => traceId)));
const baseEvent: ReplayEvent = {
type: REPLAY_EVENT_NAME,
replay_start_timestamp: initialTimestamp / 1000,
timestamp: timestamp / 1000,
error_ids: errorIds,
trace_ids: traceIds,
trace_ids: uniqueTraceIds,
traces_by_timestamp: traceIds.map(([ts, traceId]) => [ts, traceId]),
urls,
replay_id: replayId,
segment_id,
Expand Down
Loading
Loading