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
4 changes: 2 additions & 2 deletions .size-limit.js
Original file line number Diff line number Diff line change
Expand Up @@ -214,7 +214,7 @@ module.exports = [
name: 'CDN Bundle (incl. Tracing, Replay, Logs, Metrics)',
path: createCDNPath('bundle.tracing.replay.logs.metrics.min.js'),
gzip: true,
limit: '81 KB',
limit: '82 KB',
},
{
name: 'CDN Bundle (incl. Tracing, Replay, Feedback)',
Expand Down Expand Up @@ -269,7 +269,7 @@ module.exports = [
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: Shared array reference corrupts replay context

In _popEventContext(), the traceIds array is assigned by reference without creating a copy. When _clearContext() is subsequently called, it only creates a new array via slice(-1) if the length is greater than 1. For arrays with length 0 or 1, both the returned context and this._context share the same array reference. This means subsequent modifications to this._context.traceIds will affect the already-returned event context, potentially corrupting replay event data. The fix should copy the array: traceIds: [...this._context.traceIds] or traceIds: this._context.traceIds.slice().

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