',
+ transaction: 'test-transaction',
+ attributes: {
+ 'sentry.origin': 'auto.http.browser.cls',
+ 'sentry.op': 'ui.webvital.cls',
+ 'sentry.exclusive_time': 0,
+ 'sentry.pageload.span_id': '123',
+ 'sentry.report_event': 'navigation',
+ 'cls.source.1': '
',
+ },
+ startTime: 1.1, // (1000 + 100) / 1000
+ });
+
+ expect(mockSpan.addEvent).toHaveBeenCalledWith('cls', {
+ 'sentry.measurement_unit': '',
+ 'sentry.measurement_value': 0.1,
+ });
+
+ expect(mockSpan.end).toHaveBeenCalledWith(1.1);
+ });
+
+ it('sends a standalone CLS span without entry data', () => {
+ const clsValue = 0;
+ const pageloadSpanId = '456';
+ const reportEvent = 'pagehide';
+
+ _sendStandaloneClsSpan(clsValue, undefined, pageloadSpanId, reportEvent);
+
+ expect(SentryCore.timestampInSeconds).toHaveBeenCalled();
+ expect(SentryCore.browserPerformanceTimeOrigin).not.toHaveBeenCalled();
+
+ expect(WebVitalUtils.startStandaloneWebVitalSpan).toHaveBeenCalledWith({
+ name: 'Layout shift',
+ transaction: 'test-transaction',
+ attributes: {
+ 'sentry.origin': 'auto.http.browser.cls',
+ 'sentry.op': 'ui.webvital.cls',
+ 'sentry.exclusive_time': 0,
+ 'sentry.pageload.span_id': pageloadSpanId,
+ 'sentry.report_event': 'pagehide',
+ },
+ startTime: 1.5,
+ });
+
+ expect(mockSpan.end).toHaveBeenCalledWith(1.5);
+ expect(mockSpan.addEvent).toHaveBeenCalledWith('cls', {
+ 'sentry.measurement_unit': '',
+ 'sentry.measurement_value': 0,
+ });
+ });
+
+ it('handles entry with multiple sources', () => {
+ const clsValue = 0.15;
+ const mockEntry: LayoutShift = {
+ name: 'layout-shift',
+ entryType: 'layout-shift',
+ startTime: 200,
+ duration: 0,
+ value: clsValue,
+ hadRecentInput: false,
+ sources: [
+ // @ts-expect-error - other properties are irrelevant
+ {
+ node: { tagName: 'div' } as Element,
+ },
+ // @ts-expect-error - other properties are irrelevant
+ {
+ node: { tagName: 'span' } as Element,
+ },
+ ],
+ toJSON: vi.fn(),
+ };
+ const pageloadSpanId = '789';
+
+ vi.mocked(SentryCore.htmlTreeAsString)
+ .mockReturnValueOnce('
') // for the name
+ .mockReturnValueOnce('
') // for source 1
+ .mockReturnValueOnce('
'); // for source 2
+
+ _sendStandaloneClsSpan(clsValue, mockEntry, pageloadSpanId, 'navigation');
+
+ expect(SentryCore.htmlTreeAsString).toHaveBeenCalledTimes(3);
+ expect(WebVitalUtils.startStandaloneWebVitalSpan).toHaveBeenCalledWith({
+ name: '',
+ transaction: 'test-transaction',
+ attributes: {
+ 'sentry.origin': 'auto.http.browser.cls',
+ 'sentry.op': 'ui.webvital.cls',
+ 'sentry.exclusive_time': 0,
+ 'sentry.pageload.span_id': '789',
+ 'sentry.report_event': 'navigation',
+ 'cls.source.1': '
',
+ 'cls.source.2': '
',
+ },
+ startTime: 1.2, // (1000 + 200) / 1000
+ });
+ });
+
+ it('handles entry without sources', () => {
+ const clsValue = 0.05;
+ const mockEntry: LayoutShift = {
+ name: 'layout-shift',
+ entryType: 'layout-shift',
+ startTime: 50,
+ duration: 0,
+ value: clsValue,
+ hadRecentInput: false,
+ sources: [],
+ toJSON: vi.fn(),
+ };
+ const pageloadSpanId = '101';
+
+ _sendStandaloneClsSpan(clsValue, mockEntry, pageloadSpanId, 'navigation');
+
+ expect(WebVitalUtils.startStandaloneWebVitalSpan).toHaveBeenCalledWith({
+ name: '',
+ transaction: 'test-transaction',
+ attributes: {
+ 'sentry.origin': 'auto.http.browser.cls',
+ 'sentry.op': 'ui.webvital.cls',
+ 'sentry.exclusive_time': 0,
+ 'sentry.pageload.span_id': '101',
+ 'sentry.report_event': 'navigation',
+ },
+ startTime: 1.05, // (1000 + 50) / 1000
+ });
+ });
+
+ it('handles when startStandaloneWebVitalSpan returns undefined', () => {
+ vi.spyOn(WebVitalUtils, 'startStandaloneWebVitalSpan').mockReturnValue(undefined);
+
+ const clsValue = 0.1;
+ const pageloadSpanId = '123';
+
+ expect(() => {
+ _sendStandaloneClsSpan(clsValue, undefined, pageloadSpanId, 'navigation');
+ }).not.toThrow();
+
+ expect(mockSpan.addEvent).not.toHaveBeenCalled();
+ expect(mockSpan.end).not.toHaveBeenCalled();
+ });
+
+ it('handles when browserPerformanceTimeOrigin returns null', () => {
+ vi.mocked(SentryCore.browserPerformanceTimeOrigin).mockReturnValue(undefined);
+
+ const clsValue = 0.1;
+ const mockEntry: LayoutShift = {
+ name: 'layout-shift',
+ entryType: 'layout-shift',
+ startTime: 200,
+ duration: 0,
+ value: clsValue,
+ hadRecentInput: false,
+ sources: [],
+ toJSON: vi.fn(),
+ };
+ const pageloadSpanId = '123';
+
+ _sendStandaloneClsSpan(clsValue, mockEntry, pageloadSpanId, 'navigation');
+
+ expect(WebVitalUtils.startStandaloneWebVitalSpan).toHaveBeenCalledWith(
+ expect.objectContaining({
+ startTime: 0.2,
+ }),
+ );
+ });
+});
diff --git a/packages/core/src/logs/internal.ts b/packages/core/src/logs/internal.ts
index adcbf0dfb737..b3bda05d97f7 100644
--- a/packages/core/src/logs/internal.ts
+++ b/packages/core/src/logs/internal.ts
@@ -151,8 +151,20 @@ export function _INTERNAL_captureLog(
setLogAttribute(processedLogAttributes, 'sentry.sdk.name', name);
setLogAttribute(processedLogAttributes, 'sentry.sdk.version', version);
- const replay = client.getIntegrationByName
string }>('Replay');
- setLogAttribute(processedLogAttributes, 'sentry.replay_id', replay?.getReplayId());
+ const replay = client.getIntegrationByName<
+ Integration & {
+ getReplayId: (onlyIfSampled?: boolean) => string;
+ getRecordingMode: () => 'session' | 'buffer' | undefined;
+ }
+ >('Replay');
+
+ const replayId = replay?.getReplayId(true);
+ setLogAttribute(processedLogAttributes, 'sentry.replay_id', replayId);
+
+ if (replayId && replay?.getRecordingMode() === 'buffer') {
+ // We send this so we can identify cases where the replayId is attached but the replay itself might not have been sent to Sentry
+ setLogAttribute(processedLogAttributes, 'sentry._internal.replay_is_buffering', true);
+ }
const beforeLogMessage = beforeLog.message;
if (isParameterizedString(beforeLogMessage)) {
diff --git a/packages/core/src/utils/anthropic-ai/index.ts b/packages/core/src/utils/anthropic-ai/index.ts
index 8cc44c188671..8e77dd76b34e 100644
--- a/packages/core/src/utils/anthropic-ai/index.ts
+++ b/packages/core/src/utils/anthropic-ai/index.ts
@@ -25,7 +25,7 @@ import {
} from '../ai/gen-ai-attributes';
import { buildMethodPath, getFinalOperationName, getSpanOperation, setTokenUsageAttributes } from '../ai/utils';
import { handleCallbackErrors } from '../handleCallbackErrors';
-import { instrumentStream } from './streaming';
+import { instrumentAsyncIterableStream, instrumentMessageStream } from './streaming';
import type {
AnthropicAiInstrumentedMethod,
AnthropicAiOptions,
@@ -194,6 +194,74 @@ function addResponseAttributes(span: Span, response: AnthropicAiResponse, record
addMetadataAttributes(span, response);
}
+/**
+ * Handle common error catching and reporting for streaming requests
+ */
+function handleStreamingError(error: unknown, span: Span, methodPath: string): never {
+ captureException(error, {
+ mechanism: { handled: false, type: 'auto.ai.anthropic', data: { function: methodPath } },
+ });
+
+ if (span.isRecording()) {
+ span.setStatus({ code: SPAN_STATUS_ERROR, message: 'internal_error' });
+ span.end();
+ }
+ throw error;
+}
+
+/**
+ * Handle streaming cases with common logic
+ */
+function handleStreamingRequest(
+ originalMethod: (...args: T) => Promise,
+ target: (...args: T) => Promise,
+ context: unknown,
+ args: T,
+ requestAttributes: Record,
+ operationName: string,
+ methodPath: string,
+ params: Record | undefined,
+ options: AnthropicAiOptions,
+ isStreamRequested: boolean,
+): Promise {
+ const model = requestAttributes[GEN_AI_REQUEST_MODEL_ATTRIBUTE] ?? 'unknown';
+ const spanConfig = {
+ name: `${operationName} ${model} stream-response`,
+ op: getSpanOperation(methodPath),
+ attributes: requestAttributes as Record,
+ };
+
+ if (isStreamRequested) {
+ return startSpanManual(spanConfig, async span => {
+ try {
+ if (options.recordInputs && params) {
+ addPrivateRequestAttributes(span, params);
+ }
+ const result = await originalMethod.apply(context, args);
+ return instrumentAsyncIterableStream(
+ result as AsyncIterable,
+ span,
+ options.recordOutputs ?? false,
+ ) as unknown as R;
+ } catch (error) {
+ return handleStreamingError(error, span, methodPath);
+ }
+ });
+ } else {
+ return startSpanManual(spanConfig, span => {
+ try {
+ if (options.recordInputs && params) {
+ addPrivateRequestAttributes(span, params);
+ }
+ const messageStream = target.apply(context, args);
+ return instrumentMessageStream(messageStream, span, options.recordOutputs ?? false);
+ } catch (error) {
+ return handleStreamingError(error, span, methodPath);
+ }
+ });
+ }
+}
+
/**
* Instrument a method with Sentry spans
* Following Sentry AI Agents Manual Instrumentation conventions
@@ -205,82 +273,62 @@ function instrumentMethod(
context: unknown,
options: AnthropicAiOptions,
): (...args: T) => Promise {
- return async function instrumentedMethod(...args: T): Promise {
- const requestAttributes = extractRequestAttributes(args, methodPath);
- const model = requestAttributes[GEN_AI_REQUEST_MODEL_ATTRIBUTE] ?? 'unknown';
- const operationName = getFinalOperationName(methodPath);
+ return new Proxy(originalMethod, {
+ apply(target, thisArg, args: T): Promise {
+ const requestAttributes = extractRequestAttributes(args, methodPath);
+ const model = requestAttributes[GEN_AI_REQUEST_MODEL_ATTRIBUTE] ?? 'unknown';
+ const operationName = getFinalOperationName(methodPath);
- const params = typeof args[0] === 'object' ? (args[0] as Record) : undefined;
- const isStreamRequested = Boolean(params?.stream);
- const isStreamingMethod = methodPath === 'messages.stream';
+ const params = typeof args[0] === 'object' ? (args[0] as Record) : undefined;
+ const isStreamRequested = Boolean(params?.stream);
+ const isStreamingMethod = methodPath === 'messages.stream';
- if (isStreamRequested || isStreamingMethod) {
- return startSpanManual(
+ if (isStreamRequested || isStreamingMethod) {
+ return handleStreamingRequest(
+ originalMethod,
+ target,
+ context,
+ args,
+ requestAttributes,
+ operationName,
+ methodPath,
+ params,
+ options,
+ isStreamRequested,
+ );
+ }
+
+ return startSpan(
{
- name: `${operationName} ${model} stream-response`,
+ name: `${operationName} ${model}`,
op: getSpanOperation(methodPath),
attributes: requestAttributes as Record,
},
- async span => {
- try {
- if (options.recordInputs && params) {
- addPrivateRequestAttributes(span, params);
- }
-
- const result = await originalMethod.apply(context, args);
- return instrumentStream(
- result as AsyncIterable,
- span,
- options.recordOutputs ?? false,
- ) as unknown as R;
- } catch (error) {
- span.setStatus({ code: SPAN_STATUS_ERROR, message: 'internal_error' });
- captureException(error, {
- mechanism: {
- handled: false,
- type: 'auto.ai.anthropic',
- data: {
- function: methodPath,
- },
- },
- });
- span.end();
- throw error;
+ span => {
+ if (options.recordInputs && params) {
+ addPrivateRequestAttributes(span, params);
}
- },
- );
- }
- return startSpan(
- {
- name: `${operationName} ${model}`,
- op: getSpanOperation(methodPath),
- attributes: requestAttributes as Record,
- },
- span => {
- if (options.recordInputs && params) {
- addPrivateRequestAttributes(span, params);
- }
-
- return handleCallbackErrors(
- () => originalMethod.apply(context, args),
- error => {
- captureException(error, {
- mechanism: {
- handled: false,
- type: 'auto.ai.anthropic',
- data: {
- function: methodPath,
+ return handleCallbackErrors(
+ () => target.apply(context, args),
+ error => {
+ captureException(error, {
+ mechanism: {
+ handled: false,
+ type: 'auto.ai.anthropic',
+ data: {
+ function: methodPath,
+ },
},
- },
- });
- },
- () => {},
- result => addResponseAttributes(span, result as AnthropicAiResponse, options.recordOutputs),
- );
- },
- );
- };
+ });
+ },
+ () => {},
+ result => addResponseAttributes(span, result as AnthropicAiResponse, options.recordOutputs),
+ );
+ },
+ );
+ },
+ }) as (...args: T) => Promise;
}
/**
diff --git a/packages/core/src/utils/anthropic-ai/streaming.ts b/packages/core/src/utils/anthropic-ai/streaming.ts
index cd30d99ad09e..b542cbfda75a 100644
--- a/packages/core/src/utils/anthropic-ai/streaming.ts
+++ b/packages/core/src/utils/anthropic-ai/streaming.ts
@@ -15,7 +15,6 @@ import type { AnthropicAiStreamingEvent } from './types';
/**
* State object used to accumulate information from a stream of Anthropic AI events.
*/
-
interface StreamingState {
/** Collected response text fragments (for output recording). */
responseTexts: string[];
@@ -183,7 +182,6 @@ function handleContentBlockStop(event: AnthropicAiStreamingEvent, state: Streami
* @param recordOutputs - Whether to record outputs
* @param span - The span to update
*/
-
function processEvent(
event: AnthropicAiStreamingEvent,
state: StreamingState,
@@ -209,12 +207,66 @@ function processEvent(
handleContentBlockStop(event, state);
}
+/**
+ * Finalizes span attributes when stream processing completes
+ */
+function finalizeStreamSpan(state: StreamingState, span: Span, recordOutputs: boolean): void {
+ if (!span.isRecording()) {
+ return;
+ }
+
+ // Set common response attributes if available
+ if (state.responseId) {
+ span.setAttributes({
+ [GEN_AI_RESPONSE_ID_ATTRIBUTE]: state.responseId,
+ });
+ }
+ if (state.responseModel) {
+ span.setAttributes({
+ [GEN_AI_RESPONSE_MODEL_ATTRIBUTE]: state.responseModel,
+ });
+ }
+
+ setTokenUsageAttributes(
+ span,
+ state.promptTokens,
+ state.completionTokens,
+ state.cacheCreationInputTokens,
+ state.cacheReadInputTokens,
+ );
+
+ span.setAttributes({
+ [GEN_AI_RESPONSE_STREAMING_ATTRIBUTE]: true,
+ });
+
+ if (state.finishReasons.length > 0) {
+ span.setAttributes({
+ [GEN_AI_RESPONSE_FINISH_REASONS_ATTRIBUTE]: JSON.stringify(state.finishReasons),
+ });
+ }
+
+ if (recordOutputs && state.responseTexts.length > 0) {
+ span.setAttributes({
+ [GEN_AI_RESPONSE_TEXT_ATTRIBUTE]: state.responseTexts.join(''),
+ });
+ }
+
+ // Set tool calls if any were captured
+ if (recordOutputs && state.toolCalls.length > 0) {
+ span.setAttributes({
+ [GEN_AI_RESPONSE_TOOL_CALLS_ATTRIBUTE]: JSON.stringify(state.toolCalls),
+ });
+ }
+
+ span.end();
+}
+
/**
* Instruments an async iterable stream of Anthropic events, updates the span with
* streaming attributes and (optionally) the aggregated output text, and yields
* each event from the input stream unchanged.
*/
-export async function* instrumentStream(
+export async function* instrumentAsyncIterableStream(
stream: AsyncIterable,
span: Span,
recordOutputs: boolean,
@@ -284,3 +336,51 @@ export async function* instrumentStream(
span.end();
}
}
+
+/**
+ * Instruments a MessageStream by registering event handlers and preserving the original stream API.
+ */
+export function instrumentMessageStream void }>(
+ stream: R,
+ span: Span,
+ recordOutputs: boolean,
+): R {
+ const state: StreamingState = {
+ responseTexts: [],
+ finishReasons: [],
+ responseId: '',
+ responseModel: '',
+ promptTokens: undefined,
+ completionTokens: undefined,
+ cacheCreationInputTokens: undefined,
+ cacheReadInputTokens: undefined,
+ toolCalls: [],
+ activeToolBlocks: {},
+ };
+
+ stream.on('streamEvent', (event: unknown) => {
+ processEvent(event as AnthropicAiStreamingEvent, state, recordOutputs, span);
+ });
+
+ // The event fired when a message is done being streamed by the API. Corresponds to the message_stop SSE event.
+ // @see https://github.com/anthropics/anthropic-sdk-typescript/blob/d3be31f5a4e6ebb4c0a2f65dbb8f381ae73a9166/helpers.md?plain=1#L42-L44
+ stream.on('message', () => {
+ finalizeStreamSpan(state, span, recordOutputs);
+ });
+
+ stream.on('error', (error: unknown) => {
+ captureException(error, {
+ mechanism: {
+ handled: false,
+ type: 'auto.ai.anthropic.stream_error',
+ },
+ });
+
+ if (span.isRecording()) {
+ span.setStatus({ code: SPAN_STATUS_ERROR, message: 'stream_error' });
+ span.end();
+ }
+ });
+
+ return stream;
+}
diff --git a/packages/core/src/utils/promisebuffer.ts b/packages/core/src/utils/promisebuffer.ts
index 2830e8897129..f66077a76fd5 100644
--- a/packages/core/src/utils/promisebuffer.ts
+++ b/packages/core/src/utils/promisebuffer.ts
@@ -1,9 +1,9 @@
-import { rejectedSyncPromise, resolvedSyncPromise, SyncPromise } from './syncpromise';
+import { rejectedSyncPromise, resolvedSyncPromise } from './syncpromise';
export interface PromiseBuffer {
// exposes the internal array so tests can assert on the state of it.
// XXX: this really should not be public api.
- $: Array>;
+ $: PromiseLike[];
add(taskProducer: () => PromiseLike): PromiseLike;
drain(timeout?: number): PromiseLike;
}
@@ -14,11 +14,11 @@ export const SENTRY_BUFFER_FULL_ERROR = Symbol.for('SentryBufferFullError');
* Creates an new PromiseBuffer object with the specified limit
* @param limit max number of promises that can be stored in the buffer
*/
-export function makePromiseBuffer(limit?: number): PromiseBuffer {
- const buffer: Array> = [];
+export function makePromiseBuffer(limit: number = 100): PromiseBuffer {
+ const buffer: Set> = new Set();
function isReady(): boolean {
- return limit === undefined || buffer.length < limit;
+ return buffer.size < limit;
}
/**
@@ -27,8 +27,8 @@ export function makePromiseBuffer(limit?: number): PromiseBuffer {
* @param task Can be any PromiseLike
* @returns Removed promise.
*/
- function remove(task: PromiseLike): PromiseLike {
- return buffer.splice(buffer.indexOf(task), 1)[0] || Promise.resolve(undefined);
+ function remove(task: PromiseLike): void {
+ buffer.delete(task);
}
/**
@@ -48,19 +48,11 @@ export function makePromiseBuffer(limit?: number): PromiseBuffer {
// start the task and add its promise to the queue
const task = taskProducer();
- if (buffer.indexOf(task) === -1) {
- buffer.push(task);
- }
- void task
- .then(() => remove(task))
- // Use `then(null, rejectionHandler)` rather than `catch(rejectionHandler)` so that we can use `PromiseLike`
- // rather than `Promise`. `PromiseLike` doesn't have a `.catch` method, making its polyfill smaller. (ES5 didn't
- // have promises, so TS has to polyfill when down-compiling.)
- .then(null, () =>
- remove(task).then(null, () => {
- // We have to add another catch here because `remove()` starts a new promise chain.
- }),
- );
+ buffer.add(task);
+ void task.then(
+ () => remove(task),
+ () => remove(task),
+ );
return task;
}
@@ -74,34 +66,28 @@ export function makePromiseBuffer(limit?: number): PromiseBuffer {
* `false` otherwise
*/
function drain(timeout?: number): PromiseLike {
- return new SyncPromise((resolve, reject) => {
- let counter = buffer.length;
+ if (!buffer.size) {
+ return resolvedSyncPromise(true);
+ }
- if (!counter) {
- return resolve(true);
- }
+ // We want to resolve even if one of the promises rejects
+ const drainPromise = Promise.allSettled(Array.from(buffer)).then(() => true);
+
+ if (!timeout) {
+ return drainPromise;
+ }
- // wait for `timeout` ms and then resolve to `false` (if not cancelled first)
- const capturedSetTimeout = setTimeout(() => {
- if (timeout && timeout > 0) {
- resolve(false);
- }
- }, timeout);
+ const promises = [drainPromise, new Promise(resolve => setTimeout(() => resolve(false), timeout))];
- // if all promises resolve in time, cancel the timer and resolve to `true`
- buffer.forEach(item => {
- void resolvedSyncPromise(item).then(() => {
- if (!--counter) {
- clearTimeout(capturedSetTimeout);
- resolve(true);
- }
- }, reject);
- });
- });
+ // Promise.race will resolve to the first promise that resolves or rejects
+ // So if the drainPromise resolves, the timeout promise will be ignored
+ return Promise.race(promises);
}
return {
- $: buffer,
+ get $(): PromiseLike[] {
+ return Array.from(buffer);
+ },
add,
drain,
};
diff --git a/packages/core/test/lib/logs/internal.test.ts b/packages/core/test/lib/logs/internal.test.ts
index 49339e72b6b1..dbb2966dc076 100644
--- a/packages/core/test/lib/logs/internal.test.ts
+++ b/packages/core/test/lib/logs/internal.test.ts
@@ -411,6 +411,384 @@ describe('_INTERNAL_captureLog', () => {
beforeCaptureLogSpy.mockRestore();
});
+ describe('replay integration with onlyIfSampled', () => {
+ it('includes replay ID for sampled sessions', () => {
+ const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, enableLogs: true });
+ const client = new TestClient(options);
+ const scope = new Scope();
+ scope.setClient(client);
+
+ // Mock replay integration with sampled session
+ const mockReplayIntegration = {
+ getReplayId: vi.fn((onlyIfSampled?: boolean) => {
+ // Simulate behavior: return ID for sampled sessions
+ return onlyIfSampled ? 'sampled-replay-id' : 'any-replay-id';
+ }),
+ getRecordingMode: vi.fn(() => 'session'),
+ };
+
+ vi.spyOn(client, 'getIntegrationByName').mockReturnValue(mockReplayIntegration as any);
+
+ _INTERNAL_captureLog({ level: 'info', message: 'test log with sampled replay' }, scope);
+
+ // Verify getReplayId was called with onlyIfSampled=true
+ expect(mockReplayIntegration.getReplayId).toHaveBeenCalledWith(true);
+
+ const logAttributes = _INTERNAL_getLogBuffer(client)?.[0]?.attributes;
+ expect(logAttributes).toEqual({
+ 'sentry.replay_id': {
+ value: 'sampled-replay-id',
+ type: 'string',
+ },
+ });
+ });
+
+ it('excludes replay ID for unsampled sessions when onlyIfSampled=true', () => {
+ const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, enableLogs: true });
+ const client = new TestClient(options);
+ const scope = new Scope();
+ scope.setClient(client);
+
+ // Mock replay integration with unsampled session
+ const mockReplayIntegration = {
+ getReplayId: vi.fn((onlyIfSampled?: boolean) => {
+ // Simulate behavior: return undefined for unsampled when onlyIfSampled=true
+ return onlyIfSampled ? undefined : 'unsampled-replay-id';
+ }),
+ };
+
+ vi.spyOn(client, 'getIntegrationByName').mockReturnValue(mockReplayIntegration as any);
+
+ _INTERNAL_captureLog({ level: 'info', message: 'test log with unsampled replay' }, scope);
+
+ // Verify getReplayId was called with onlyIfSampled=true
+ expect(mockReplayIntegration.getReplayId).toHaveBeenCalledWith(true);
+
+ const logAttributes = _INTERNAL_getLogBuffer(client)?.[0]?.attributes;
+ // Should not include sentry.replay_id attribute
+ expect(logAttributes).toEqual({});
+ });
+
+ it('includes replay ID for buffer mode sessions', () => {
+ const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, enableLogs: true });
+ const client = new TestClient(options);
+ const scope = new Scope();
+ scope.setClient(client);
+
+ // Mock replay integration with buffer mode session
+ const mockReplayIntegration = {
+ getReplayId: vi.fn((_onlyIfSampled?: boolean) => {
+ // Buffer mode should still return ID even with onlyIfSampled=true
+ return 'buffer-replay-id';
+ }),
+ getRecordingMode: vi.fn(() => 'buffer'),
+ };
+
+ vi.spyOn(client, 'getIntegrationByName').mockReturnValue(mockReplayIntegration as any);
+
+ _INTERNAL_captureLog({ level: 'info', message: 'test log with buffer replay' }, scope);
+
+ expect(mockReplayIntegration.getReplayId).toHaveBeenCalledWith(true);
+
+ const logAttributes = _INTERNAL_getLogBuffer(client)?.[0]?.attributes;
+ expect(logAttributes).toEqual({
+ 'sentry.replay_id': {
+ value: 'buffer-replay-id',
+ type: 'string',
+ },
+ 'sentry._internal.replay_is_buffering': {
+ value: true,
+ type: 'boolean',
+ },
+ });
+ });
+
+ it('handles missing replay integration gracefully', () => {
+ const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, enableLogs: true });
+ const client = new TestClient(options);
+ const scope = new Scope();
+ scope.setClient(client);
+
+ // Mock no replay integration found
+ vi.spyOn(client, 'getIntegrationByName').mockReturnValue(undefined);
+
+ _INTERNAL_captureLog({ level: 'info', message: 'test log without replay' }, scope);
+
+ const logAttributes = _INTERNAL_getLogBuffer(client)?.[0]?.attributes;
+ // Should not include sentry.replay_id attribute
+ expect(logAttributes).toEqual({});
+ });
+
+ it('combines replay ID with other log attributes', () => {
+ const options = getDefaultTestClientOptions({
+ dsn: PUBLIC_DSN,
+ enableLogs: true,
+ release: '1.0.0',
+ environment: 'test',
+ });
+ const client = new TestClient(options);
+ const scope = new Scope();
+ scope.setClient(client);
+
+ // Mock replay integration
+ const mockReplayIntegration = {
+ getReplayId: vi.fn(() => 'test-replay-id'),
+ getRecordingMode: vi.fn(() => 'session'),
+ };
+
+ vi.spyOn(client, 'getIntegrationByName').mockReturnValue(mockReplayIntegration as any);
+
+ _INTERNAL_captureLog(
+ {
+ level: 'info',
+ message: 'test log with replay and other attributes',
+ attributes: { component: 'auth', action: 'login' },
+ },
+ scope,
+ );
+
+ const logAttributes = _INTERNAL_getLogBuffer(client)?.[0]?.attributes;
+ expect(logAttributes).toEqual({
+ component: {
+ value: 'auth',
+ type: 'string',
+ },
+ action: {
+ value: 'login',
+ type: 'string',
+ },
+ 'sentry.release': {
+ value: '1.0.0',
+ type: 'string',
+ },
+ 'sentry.environment': {
+ value: 'test',
+ type: 'string',
+ },
+ 'sentry.replay_id': {
+ value: 'test-replay-id',
+ type: 'string',
+ },
+ });
+ });
+
+ it('does not set replay ID attribute when getReplayId returns null or undefined', () => {
+ const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, enableLogs: true });
+ const client = new TestClient(options);
+ const scope = new Scope();
+ scope.setClient(client);
+
+ const testCases = [null, undefined];
+
+ testCases.forEach(returnValue => {
+ // Clear buffer for each test
+ _INTERNAL_getLogBuffer(client)?.splice(0);
+
+ const mockReplayIntegration = {
+ getReplayId: vi.fn(() => returnValue),
+ };
+
+ vi.spyOn(client, 'getIntegrationByName').mockReturnValue(mockReplayIntegration as any);
+
+ _INTERNAL_captureLog({ level: 'info', message: `test log with replay returning ${returnValue}` }, scope);
+
+ const logAttributes = _INTERNAL_getLogBuffer(client)?.[0]?.attributes;
+ expect(logAttributes).toEqual({});
+ expect(logAttributes).not.toHaveProperty('sentry.replay_id');
+ });
+ });
+
+ it('sets replay_is_buffering attribute when replay is in buffer mode', () => {
+ const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, enableLogs: true });
+ const client = new TestClient(options);
+ const scope = new Scope();
+ scope.setClient(client);
+
+ // Mock replay integration with buffer mode
+ const mockReplayIntegration = {
+ getReplayId: vi.fn(() => 'buffer-replay-id'),
+ getRecordingMode: vi.fn(() => 'buffer'),
+ };
+
+ vi.spyOn(client, 'getIntegrationByName').mockReturnValue(mockReplayIntegration as any);
+
+ _INTERNAL_captureLog({ level: 'info', message: 'test log with buffered replay' }, scope);
+
+ expect(mockReplayIntegration.getReplayId).toHaveBeenCalledWith(true);
+ expect(mockReplayIntegration.getRecordingMode).toHaveBeenCalled();
+
+ const logAttributes = _INTERNAL_getLogBuffer(client)?.[0]?.attributes;
+ expect(logAttributes).toEqual({
+ 'sentry.replay_id': {
+ value: 'buffer-replay-id',
+ type: 'string',
+ },
+ 'sentry._internal.replay_is_buffering': {
+ value: true,
+ type: 'boolean',
+ },
+ });
+ });
+
+ it('does not set replay_is_buffering attribute when replay is in session mode', () => {
+ const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, enableLogs: true });
+ const client = new TestClient(options);
+ const scope = new Scope();
+ scope.setClient(client);
+
+ // Mock replay integration with session mode
+ const mockReplayIntegration = {
+ getReplayId: vi.fn(() => 'session-replay-id'),
+ getRecordingMode: vi.fn(() => 'session'),
+ };
+
+ vi.spyOn(client, 'getIntegrationByName').mockReturnValue(mockReplayIntegration as any);
+
+ _INTERNAL_captureLog({ level: 'info', message: 'test log with session replay' }, scope);
+
+ expect(mockReplayIntegration.getReplayId).toHaveBeenCalledWith(true);
+ expect(mockReplayIntegration.getRecordingMode).toHaveBeenCalled();
+
+ const logAttributes = _INTERNAL_getLogBuffer(client)?.[0]?.attributes;
+ expect(logAttributes).toEqual({
+ 'sentry.replay_id': {
+ value: 'session-replay-id',
+ type: 'string',
+ },
+ });
+ expect(logAttributes).not.toHaveProperty('sentry._internal.replay_is_buffering');
+ });
+
+ it('does not set replay_is_buffering attribute when replay is undefined mode', () => {
+ const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, enableLogs: true });
+ const client = new TestClient(options);
+ const scope = new Scope();
+ scope.setClient(client);
+
+ // Mock replay integration with undefined mode (replay stopped/disabled)
+ const mockReplayIntegration = {
+ getReplayId: vi.fn(() => 'stopped-replay-id'),
+ getRecordingMode: vi.fn(() => undefined),
+ };
+
+ vi.spyOn(client, 'getIntegrationByName').mockReturnValue(mockReplayIntegration as any);
+
+ _INTERNAL_captureLog({ level: 'info', message: 'test log with stopped replay' }, scope);
+
+ expect(mockReplayIntegration.getReplayId).toHaveBeenCalledWith(true);
+ expect(mockReplayIntegration.getRecordingMode).toHaveBeenCalled();
+
+ const logAttributes = _INTERNAL_getLogBuffer(client)?.[0]?.attributes;
+ expect(logAttributes).toEqual({
+ 'sentry.replay_id': {
+ value: 'stopped-replay-id',
+ type: 'string',
+ },
+ });
+ expect(logAttributes).not.toHaveProperty('sentry._internal.replay_is_buffering');
+ });
+
+ it('does not set replay_is_buffering attribute when no replay ID is available', () => {
+ const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, enableLogs: true });
+ const client = new TestClient(options);
+ const scope = new Scope();
+ scope.setClient(client);
+
+ // Mock replay integration that returns no replay ID but has buffer mode
+ const mockReplayIntegration = {
+ getReplayId: vi.fn(() => undefined),
+ getRecordingMode: vi.fn(() => 'buffer'),
+ };
+
+ vi.spyOn(client, 'getIntegrationByName').mockReturnValue(mockReplayIntegration as any);
+
+ _INTERNAL_captureLog({ level: 'info', message: 'test log with buffer mode but no replay ID' }, scope);
+
+ expect(mockReplayIntegration.getReplayId).toHaveBeenCalledWith(true);
+ // getRecordingMode should not be called if there's no replay ID
+ expect(mockReplayIntegration.getRecordingMode).not.toHaveBeenCalled();
+
+ const logAttributes = _INTERNAL_getLogBuffer(client)?.[0]?.attributes;
+ expect(logAttributes).toEqual({});
+ expect(logAttributes).not.toHaveProperty('sentry.replay_id');
+ expect(logAttributes).not.toHaveProperty('sentry.internal.replay_is_buffering');
+ });
+
+ it('does not set replay_is_buffering attribute when replay integration is missing', () => {
+ const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, enableLogs: true });
+ const client = new TestClient(options);
+ const scope = new Scope();
+ scope.setClient(client);
+
+ // Mock no replay integration found
+ vi.spyOn(client, 'getIntegrationByName').mockReturnValue(undefined);
+
+ _INTERNAL_captureLog({ level: 'info', message: 'test log without replay integration' }, scope);
+
+ const logAttributes = _INTERNAL_getLogBuffer(client)?.[0]?.attributes;
+ expect(logAttributes).toEqual({});
+ expect(logAttributes).not.toHaveProperty('sentry.replay_id');
+ expect(logAttributes).not.toHaveProperty('sentry._internal.replay_is_buffering');
+ });
+
+ it('combines replay_is_buffering with other replay attributes', () => {
+ const options = getDefaultTestClientOptions({
+ dsn: PUBLIC_DSN,
+ enableLogs: true,
+ release: '1.0.0',
+ environment: 'test',
+ });
+ const client = new TestClient(options);
+ const scope = new Scope();
+ scope.setClient(client);
+
+ // Mock replay integration with buffer mode
+ const mockReplayIntegration = {
+ getReplayId: vi.fn(() => 'buffer-replay-id'),
+ getRecordingMode: vi.fn(() => 'buffer'),
+ };
+
+ vi.spyOn(client, 'getIntegrationByName').mockReturnValue(mockReplayIntegration as any);
+
+ _INTERNAL_captureLog(
+ {
+ level: 'info',
+ message: 'test log with buffer replay and other attributes',
+ attributes: { component: 'auth', action: 'login' },
+ },
+ scope,
+ );
+
+ const logAttributes = _INTERNAL_getLogBuffer(client)?.[0]?.attributes;
+ expect(logAttributes).toEqual({
+ component: {
+ value: 'auth',
+ type: 'string',
+ },
+ action: {
+ value: 'login',
+ type: 'string',
+ },
+ 'sentry.release': {
+ value: '1.0.0',
+ type: 'string',
+ },
+ 'sentry.environment': {
+ value: 'test',
+ type: 'string',
+ },
+ 'sentry.replay_id': {
+ value: 'buffer-replay-id',
+ type: 'string',
+ },
+ 'sentry._internal.replay_is_buffering': {
+ value: true,
+ type: 'boolean',
+ },
+ });
+ });
+ });
+
describe('user functionality', () => {
it('includes user data in log attributes', () => {
const options = getDefaultTestClientOptions({
diff --git a/packages/core/test/lib/utils/promisebuffer.test.ts b/packages/core/test/lib/utils/promisebuffer.test.ts
index 618de06322a0..b1316302e6f6 100644
--- a/packages/core/test/lib/utils/promisebuffer.test.ts
+++ b/packages/core/test/lib/utils/promisebuffer.test.ts
@@ -1,52 +1,163 @@
import { describe, expect, test, vi } from 'vitest';
import { makePromiseBuffer } from '../../../src/utils/promisebuffer';
-import { SyncPromise } from '../../../src/utils/syncpromise';
+import { rejectedSyncPromise, resolvedSyncPromise } from '../../../src/utils/syncpromise';
describe('PromiseBuffer', () => {
describe('add()', () => {
- test('no limit', () => {
- const buffer = makePromiseBuffer();
- const p = vi.fn(() => new SyncPromise(resolve => setTimeout(resolve)));
- void buffer.add(p);
- expect(buffer.$.length).toEqual(1);
+ test('enforces limit of promises', async () => {
+ const buffer = makePromiseBuffer(5);
+
+ const producer1 = vi.fn(() => new Promise(resolve => setTimeout(resolve, 1)));
+ const producer2 = vi.fn(() => new Promise(resolve => setTimeout(resolve, 1)));
+ const producer3 = vi.fn(() => new Promise(resolve => setTimeout(resolve, 1)));
+ const producer4 = vi.fn(() => new Promise(resolve => setTimeout(resolve, 1)));
+ const producer5 = vi.fn(() => new Promise(resolve => setTimeout(resolve, 1)));
+ const producer6 = vi.fn(() => new Promise(resolve => setTimeout(resolve, 1)));
+
+ void buffer.add(producer1);
+ void buffer.add(producer2);
+ void buffer.add(producer3);
+ void buffer.add(producer4);
+ void buffer.add(producer5);
+ await expect(buffer.add(producer6)).rejects.toThrowError();
+
+ expect(producer1).toHaveBeenCalledTimes(1);
+ expect(producer2).toHaveBeenCalledTimes(1);
+ expect(producer3).toHaveBeenCalledTimes(1);
+ expect(producer4).toHaveBeenCalledTimes(1);
+ expect(producer5).toHaveBeenCalledTimes(1);
+ expect(producer6).not.toHaveBeenCalled();
+
+ expect(buffer.$.length).toEqual(5);
+
+ await buffer.drain();
+
+ expect(buffer.$.length).toEqual(0);
+
+ expect(producer1).toHaveBeenCalledTimes(1);
+ expect(producer2).toHaveBeenCalledTimes(1);
+ expect(producer3).toHaveBeenCalledTimes(1);
+ expect(producer4).toHaveBeenCalledTimes(1);
+ expect(producer5).toHaveBeenCalledTimes(1);
+ expect(producer6).not.toHaveBeenCalled();
+ });
+
+ test('sync promises', async () => {
+ const buffer = makePromiseBuffer(1);
+ let task1;
+ const producer1 = vi.fn(() => {
+ task1 = resolvedSyncPromise();
+ return task1;
+ });
+ const producer2 = vi.fn(() => resolvedSyncPromise());
+ expect(buffer.add(producer1)).toEqual(task1);
+ const add2 = buffer.add(producer2);
+
+ // This is immediately executed and removed again from the buffer
+ expect(buffer.$.length).toEqual(0);
+
+ await expect(add2).resolves.toBeUndefined();
+
+ expect(producer1).toHaveBeenCalled();
+ expect(producer2).toHaveBeenCalled();
});
- test('with limit', () => {
+ test('async promises', async () => {
const buffer = makePromiseBuffer(1);
let task1;
const producer1 = vi.fn(() => {
- task1 = new SyncPromise(resolve => setTimeout(resolve));
+ task1 = new Promise(resolve => setTimeout(resolve, 1));
return task1;
});
- const producer2 = vi.fn(() => new SyncPromise(resolve => setTimeout(resolve)));
+ const producer2 = vi.fn(() => new Promise(resolve => setTimeout(resolve, 1)));
expect(buffer.add(producer1)).toEqual(task1);
- void expect(buffer.add(producer2)).rejects.toThrowError();
+ const add2 = buffer.add(producer2);
+
expect(buffer.$.length).toEqual(1);
+
+ await expect(add2).rejects.toThrowError();
+
expect(producer1).toHaveBeenCalled();
expect(producer2).not.toHaveBeenCalled();
});
+
+ test('handles multiple equivalent promises', async () => {
+ const buffer = makePromiseBuffer(10);
+
+ const promise = new Promise(resolve => setTimeout(resolve, 1));
+
+ const producer = vi.fn(() => promise);
+ const producer2 = vi.fn(() => promise);
+
+ expect(buffer.add(producer)).toEqual(promise);
+ expect(buffer.add(producer2)).toEqual(promise);
+
+ expect(buffer.$.length).toEqual(1);
+
+ expect(producer).toHaveBeenCalled();
+ expect(producer2).toHaveBeenCalled();
+
+ await buffer.drain();
+
+ expect(buffer.$.length).toEqual(0);
+ });
});
describe('drain()', () => {
- test('without timeout', async () => {
+ test('drains all promises without timeout', async () => {
const buffer = makePromiseBuffer();
- for (let i = 0; i < 5; i++) {
- void buffer.add(() => new SyncPromise(resolve => setTimeout(resolve)));
- }
+
+ const p1 = vi.fn(() => new Promise(resolve => setTimeout(resolve, 1)));
+ const p2 = vi.fn(() => new Promise(resolve => setTimeout(resolve, 1)));
+ const p3 = vi.fn(() => new Promise(resolve => setTimeout(resolve, 1)));
+ const p4 = vi.fn(() => new Promise(resolve => setTimeout(resolve, 1)));
+ const p5 = vi.fn(() => new Promise(resolve => setTimeout(resolve, 1)));
+
+ [p1, p2, p3, p4, p5].forEach(p => {
+ void buffer.add(p);
+ });
+
expect(buffer.$.length).toEqual(5);
const result = await buffer.drain();
expect(result).toEqual(true);
expect(buffer.$.length).toEqual(0);
+
+ expect(p1).toHaveBeenCalled();
+ expect(p2).toHaveBeenCalled();
+ expect(p3).toHaveBeenCalled();
+ expect(p4).toHaveBeenCalled();
+ expect(p5).toHaveBeenCalled();
});
- test('with timeout', async () => {
+ test('drains all promises with timeout', async () => {
const buffer = makePromiseBuffer();
- for (let i = 0; i < 5; i++) {
- void buffer.add(() => new SyncPromise(resolve => setTimeout(resolve, 100)));
- }
+
+ const p1 = vi.fn(() => new Promise(resolve => setTimeout(resolve, 2)));
+ const p2 = vi.fn(() => new Promise(resolve => setTimeout(resolve, 4)));
+ const p3 = vi.fn(() => new Promise(resolve => setTimeout(resolve, 6)));
+ const p4 = vi.fn(() => new Promise(resolve => setTimeout(resolve, 8)));
+ const p5 = vi.fn(() => new Promise(resolve => setTimeout(resolve, 10)));
+
+ [p1, p2, p3, p4, p5].forEach(p => {
+ void buffer.add(p);
+ });
+
+ expect(p1).toHaveBeenCalled();
+ expect(p2).toHaveBeenCalled();
+ expect(p3).toHaveBeenCalled();
+ expect(p4).toHaveBeenCalled();
+ expect(p5).toHaveBeenCalled();
+
expect(buffer.$.length).toEqual(5);
- const result = await buffer.drain(50);
+ const result = await buffer.drain(8);
expect(result).toEqual(false);
+ // p5 is still in the buffer
+ expect(buffer.$.length).toEqual(1);
+
+ // Now drain final item
+ const result2 = await buffer.drain();
+ expect(result2).toEqual(true);
+ expect(buffer.$.length).toEqual(0);
});
test('on empty buffer', async () => {
@@ -56,11 +167,26 @@ describe('PromiseBuffer', () => {
expect(result).toEqual(true);
expect(buffer.$.length).toEqual(0);
});
+
+ test('resolves even if one of the promises rejects', async () => {
+ const buffer = makePromiseBuffer();
+ const p1 = vi.fn(() => new Promise(resolve => setTimeout(resolve, 1)));
+ const p2 = vi.fn(() => new Promise((_, reject) => setTimeout(() => reject(new Error('whoops')), 1)));
+ void buffer.add(p1);
+ void buffer.add(p2);
+
+ const result = await buffer.drain();
+ expect(result).toEqual(true);
+ expect(buffer.$.length).toEqual(0);
+
+ expect(p1).toHaveBeenCalled();
+ expect(p2).toHaveBeenCalled();
+ });
});
test('resolved promises should not show up in buffer length', async () => {
const buffer = makePromiseBuffer();
- const producer = () => new SyncPromise(resolve => setTimeout(resolve));
+ const producer = () => new Promise(resolve => setTimeout(resolve, 1));
const task = buffer.add(producer);
expect(buffer.$.length).toEqual(1);
await task;
@@ -69,20 +195,18 @@ describe('PromiseBuffer', () => {
test('rejected promises should not show up in buffer length', async () => {
const buffer = makePromiseBuffer();
- const producer = () => new SyncPromise((_, reject) => setTimeout(reject));
+ const error = new Error('whoops');
+ const producer = () => new Promise((_, reject) => setTimeout(() => reject(error), 1));
const task = buffer.add(producer);
expect(buffer.$.length).toEqual(1);
- try {
- await task;
- } catch {
- // no-empty
- }
+
+ await expect(task).rejects.toThrow(error);
expect(buffer.$.length).toEqual(0);
});
test('resolved task should give an access to the return value', async () => {
const buffer = makePromiseBuffer();
- const producer = () => new SyncPromise(resolve => setTimeout(() => resolve('test')));
+ const producer = () => resolvedSyncPromise('test');
const task = buffer.add(producer);
const result = await task;
expect(result).toEqual('test');
@@ -91,7 +215,7 @@ describe('PromiseBuffer', () => {
test('rejected task should give an access to the return value', async () => {
expect.assertions(1);
const buffer = makePromiseBuffer();
- const producer = () => new SyncPromise((_, reject) => setTimeout(() => reject(new Error('whoops'))));
+ const producer = () => rejectedSyncPromise(new Error('whoops'));
const task = buffer.add(producer);
try {
await task;
diff --git a/packages/nextjs/src/config/turbopack/constructTurbopackConfig.ts b/packages/nextjs/src/config/turbopack/constructTurbopackConfig.ts
index 76d98fda25e8..5c6372d6dec1 100644
--- a/packages/nextjs/src/config/turbopack/constructTurbopackConfig.ts
+++ b/packages/nextjs/src/config/turbopack/constructTurbopackConfig.ts
@@ -1,5 +1,4 @@
import { debug } from '@sentry/core';
-import * as chalk from 'chalk';
import type { RouteManifest } from '../manifest/types';
import type { NextConfigObject, TurbopackMatcherWithRule, TurbopackOptions } from '../types';
import { generateValueInjectionRules } from './generateValueInjectionRules';
@@ -57,9 +56,7 @@ export function safelyAddTurbopackRule(
// If the rule already exists, we don't want to mess with it.
if (existingRules[matcher]) {
debug.log(
- `${chalk.cyan(
- 'info',
- )} - Turbopack rule already exists for ${matcher}. Please remove it from your Next.js config in order for Sentry to work properly.`,
+ `[@sentry/nextjs] - Turbopack rule already exists for ${matcher}. Please remove it from your Next.js config in order for Sentry to work properly.`,
);
return existingRules;
}
diff --git a/packages/react-router/package.json b/packages/react-router/package.json
index b9d339c865db..c56faf7f1662 100644
--- a/packages/react-router/package.json
+++ b/packages/react-router/package.json
@@ -61,7 +61,7 @@
"@react-router/dev": "^7.5.2",
"@react-router/node": "^7.5.2",
"react": "^18.3.1",
- "react-router": "^7.5.2",
+ "react-router": "^7.9.2",
"vite": "^6.1.0"
},
"peerDependencies": {
diff --git a/packages/react-router/src/server/createSentryHandleRequest.tsx b/packages/react-router/src/server/createSentryHandleRequest.tsx
index d7db59be616f..eaf13eb16779 100644
--- a/packages/react-router/src/server/createSentryHandleRequest.tsx
+++ b/packages/react-router/src/server/createSentryHandleRequest.tsx
@@ -1,7 +1,7 @@
import type { createReadableStreamFromReadable } from '@react-router/node';
import type { ReactNode } from 'react';
import React from 'react';
-import type { AppLoadContext, EntryContext, ServerRouter } from 'react-router';
+import type { AppLoadContext, EntryContext, RouterContextProvider, ServerRouter } from 'react-router';
import { PassThrough } from 'stream';
import { getMetaTagTransformer } from './getMetaTagTransformer';
import { wrapSentryHandleRequest } from './wrapSentryHandleRequest';
@@ -67,7 +67,7 @@ export function createSentryHandleRequest(
responseStatusCode: number,
responseHeaders: Headers,
routerContext: EntryContext,
- loadContext: AppLoadContext,
+ loadContext: AppLoadContext | RouterContextProvider,
) => Promise {
const {
streamTimeout = 10000,
@@ -82,7 +82,7 @@ export function createSentryHandleRequest(
responseStatusCode: number,
responseHeaders: Headers,
routerContext: EntryContext,
- _loadContext: AppLoadContext,
+ _loadContext: AppLoadContext | RouterContextProvider,
): Promise {
return new Promise((resolve, reject) => {
let shellRendered = false;
diff --git a/packages/react-router/src/server/wrapSentryHandleRequest.ts b/packages/react-router/src/server/wrapSentryHandleRequest.ts
index 161b40f9e241..5651ad208a9d 100644
--- a/packages/react-router/src/server/wrapSentryHandleRequest.ts
+++ b/packages/react-router/src/server/wrapSentryHandleRequest.ts
@@ -8,14 +8,14 @@ import {
SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN,
SEMANTIC_ATTRIBUTE_SENTRY_SOURCE,
} from '@sentry/core';
-import type { AppLoadContext, EntryContext } from 'react-router';
+import type { AppLoadContext, EntryContext, RouterContextProvider } from 'react-router';
type OriginalHandleRequest = (
request: Request,
responseStatusCode: number,
responseHeaders: Headers,
routerContext: EntryContext,
- loadContext: AppLoadContext,
+ loadContext: AppLoadContext | RouterContextProvider,
) => Promise;
/**
@@ -30,7 +30,7 @@ export function wrapSentryHandleRequest(originalHandle: OriginalHandleRequest):
responseStatusCode: number,
responseHeaders: Headers,
routerContext: EntryContext,
- loadContext: AppLoadContext,
+ loadContext: AppLoadContext | RouterContextProvider,
) {
const parameterizedPath =
routerContext?.staticHandlerContext?.matches?.[routerContext.staticHandlerContext.matches.length - 1]?.route.path;
diff --git a/packages/react/src/reactrouter-compat-utils/instrumentation.tsx b/packages/react/src/reactrouter-compat-utils/instrumentation.tsx
index a464875a8575..10db32231195 100644
--- a/packages/react/src/reactrouter-compat-utils/instrumentation.tsx
+++ b/packages/react/src/reactrouter-compat-utils/instrumentation.tsx
@@ -567,6 +567,13 @@ export function handleNavigation(opts: {
return;
}
+ // Avoid starting a navigation span on initial load when a pageload root span is active.
+ // This commonly happens when lazy routes resolve during the first render and React Router emits a POP.
+ const activeRootSpan = getActiveRootSpan();
+ if (activeRootSpan && spanToJSON(activeRootSpan).op === 'pageload' && navigationType === 'POP') {
+ return;
+ }
+
if ((navigationType === 'PUSH' || navigationType === 'POP') && branches) {
const [name, source] = resolveRouteNameAndSource(
location,
diff --git a/packages/replay-internal/src/integration.ts b/packages/replay-internal/src/integration.ts
index 795562c7f6ce..41c5966b88c5 100644
--- a/packages/replay-internal/src/integration.ts
+++ b/packages/replay-internal/src/integration.ts
@@ -280,13 +280,16 @@ export class Replay implements Integration {
/**
* Get the current session ID.
+ *
+ * @param onlyIfSampled - If true, will only return the session ID if the session is sampled.
+ *
*/
- public getReplayId(): string | undefined {
+ public getReplayId(onlyIfSampled?: boolean): string | undefined {
if (!this._replay?.isEnabled()) {
return;
}
- return this._replay.getSessionId();
+ return this._replay.getSessionId(onlyIfSampled);
}
/**
diff --git a/packages/replay-internal/src/replay.ts b/packages/replay-internal/src/replay.ts
index ae3aa9589cab..61676f790b4d 100644
--- a/packages/replay-internal/src/replay.ts
+++ b/packages/replay-internal/src/replay.ts
@@ -719,8 +719,15 @@ export class ReplayContainer implements ReplayContainerInterface {
this._debouncedFlush.cancel();
}
- /** Get the current session (=replay) ID */
- public getSessionId(): string | undefined {
+ /** Get the current session (=replay) ID
+ *
+ * @param onlyIfSampled - If true, will only return the session ID if the session is sampled.
+ */
+ public getSessionId(onlyIfSampled?: boolean): string | undefined {
+ if (onlyIfSampled && this.session?.sampled === false) {
+ return undefined;
+ }
+
return this.session?.id;
}
diff --git a/packages/replay-internal/test/integration/getReplayId.test.ts b/packages/replay-internal/test/integration/getReplayId.test.ts
index c2f4e765520a..28b8f56ccaab 100644
--- a/packages/replay-internal/test/integration/getReplayId.test.ts
+++ b/packages/replay-internal/test/integration/getReplayId.test.ts
@@ -30,4 +30,113 @@ describe('Integration | getReplayId', () => {
expect(integration.getReplayId()).toBeUndefined();
});
+
+ describe('onlyIfSampled parameter', () => {
+ it('returns replay ID for session mode when onlyIfSampled=true', async () => {
+ const { integration, replay } = await mockSdk({
+ replayOptions: {
+ stickySession: true,
+ },
+ });
+
+ // Should be in session mode by default with sessionSampleRate: 1.0
+ expect(replay.recordingMode).toBe('session');
+ expect(replay.session?.sampled).toBe('session');
+
+ expect(integration.getReplayId(true)).toBeDefined();
+ expect(integration.getReplayId(true)).toEqual(replay.session?.id);
+ });
+
+ it('returns replay ID for buffer mode when onlyIfSampled=true', async () => {
+ const { integration, replay } = await mockSdk({
+ replayOptions: {
+ stickySession: true,
+ },
+ sentryOptions: {
+ replaysSessionSampleRate: 0.0,
+ replaysOnErrorSampleRate: 1.0,
+ },
+ });
+
+ // Force buffer mode by manually setting session
+ if (replay.session) {
+ replay.session.sampled = 'buffer';
+ replay.recordingMode = 'buffer';
+ }
+
+ expect(integration.getReplayId(true)).toBeDefined();
+ expect(integration.getReplayId(true)).toEqual(replay.session?.id);
+ });
+
+ it('returns undefined for unsampled sessions when onlyIfSampled=true', async () => {
+ const { integration, replay } = await mockSdk({
+ replayOptions: {
+ stickySession: true,
+ },
+ sentryOptions: {
+ replaysSessionSampleRate: 1.0, // Start enabled to create session
+ replaysOnErrorSampleRate: 0.0,
+ },
+ });
+
+ // Manually create an unsampled session by overriding the existing one
+ replay.session = {
+ id: 'test-unsampled-session',
+ started: Date.now(),
+ lastActivity: Date.now(),
+ segmentId: 0,
+ sampled: false,
+ };
+
+ expect(integration.getReplayId(true)).toBeUndefined();
+ // But default behavior should still return the ID
+ expect(integration.getReplayId()).toBe('test-unsampled-session');
+ expect(integration.getReplayId(false)).toBe('test-unsampled-session');
+ });
+
+ it('maintains backward compatibility when onlyIfSampled is not provided', async () => {
+ const { integration, replay } = await mockSdk({
+ replayOptions: {
+ stickySession: true,
+ },
+ sentryOptions: {
+ replaysSessionSampleRate: 1.0, // Start with a session to ensure initialization
+ replaysOnErrorSampleRate: 0.0,
+ },
+ });
+
+ const testCases: Array<{ sampled: 'session' | 'buffer' | false; sessionId: string }> = [
+ { sampled: 'session', sessionId: 'session-test-id' },
+ { sampled: 'buffer', sessionId: 'buffer-test-id' },
+ { sampled: false, sessionId: 'unsampled-test-id' },
+ ];
+
+ for (const { sampled, sessionId } of testCases) {
+ replay.session = {
+ id: sessionId,
+ started: Date.now(),
+ lastActivity: Date.now(),
+ segmentId: 0,
+ sampled,
+ };
+
+ // Default behavior should always return the ID
+ expect(integration.getReplayId()).toBe(sessionId);
+ }
+ });
+
+ it('returns undefined when replay is disabled regardless of onlyIfSampled', async () => {
+ const { integration } = await mockSdk({
+ replayOptions: {
+ stickySession: true,
+ },
+ });
+
+ integration.stop();
+
+ expect(integration.getReplayId()).toBeUndefined();
+ expect(integration.getReplayId(true)).toBeUndefined();
+ expect(integration.getReplayId(false)).toBeUndefined();
+ });
+ });
});
diff --git a/packages/replay-internal/test/unit/getSessionId.test.ts b/packages/replay-internal/test/unit/getSessionId.test.ts
new file mode 100644
index 000000000000..c9ccde7d07d0
--- /dev/null
+++ b/packages/replay-internal/test/unit/getSessionId.test.ts
@@ -0,0 +1,123 @@
+/**
+ * @vitest-environment jsdom
+ */
+
+import { describe, expect, it } from 'vitest';
+import type { Session } from '../../src/types';
+import { setupReplayContainer } from '../utils/setupReplayContainer';
+
+describe('Unit | ReplayContainer | getSessionId', () => {
+ it('returns session ID when session exists', () => {
+ const replay = setupReplayContainer();
+ const mockSession: Session = {
+ id: 'test-session-id',
+ started: Date.now(),
+ lastActivity: Date.now(),
+ segmentId: 0,
+ sampled: 'session',
+ };
+ replay.session = mockSession;
+
+ expect(replay.getSessionId()).toBe('test-session-id');
+ });
+
+ it('returns undefined when no session exists', () => {
+ const replay = setupReplayContainer();
+ replay.session = undefined;
+
+ expect(replay.getSessionId()).toBeUndefined();
+ });
+
+ describe('onlyIfSampled parameter', () => {
+ it('returns session ID for sampled=session when onlyIfSampled=true', () => {
+ const replay = setupReplayContainer();
+ const mockSession: Session = {
+ id: 'test-session-id',
+ started: Date.now(),
+ lastActivity: Date.now(),
+ segmentId: 0,
+ sampled: 'session',
+ };
+ replay.session = mockSession;
+
+ expect(replay.getSessionId(true)).toBe('test-session-id');
+ });
+
+ it('returns session ID for sampled=buffer when onlyIfSampled=true', () => {
+ const replay = setupReplayContainer();
+ const mockSession: Session = {
+ id: 'test-session-id',
+ started: Date.now(),
+ lastActivity: Date.now(),
+ segmentId: 0,
+ sampled: 'buffer',
+ };
+ replay.session = mockSession;
+
+ expect(replay.getSessionId(true)).toBe('test-session-id');
+ });
+
+ it('returns undefined for sampled=false when onlyIfSampled=true', () => {
+ const replay = setupReplayContainer();
+ const mockSession: Session = {
+ id: 'test-session-id',
+ started: Date.now(),
+ lastActivity: Date.now(),
+ segmentId: 0,
+ sampled: false,
+ };
+ replay.session = mockSession;
+
+ expect(replay.getSessionId(true)).toBeUndefined();
+ });
+
+ it('returns session ID for sampled=false when onlyIfSampled=false (default)', () => {
+ const replay = setupReplayContainer();
+ const mockSession: Session = {
+ id: 'test-session-id',
+ started: Date.now(),
+ lastActivity: Date.now(),
+ segmentId: 0,
+ sampled: false,
+ };
+ replay.session = mockSession;
+
+ expect(replay.getSessionId()).toBe('test-session-id');
+ expect(replay.getSessionId(false)).toBe('test-session-id');
+ });
+
+ it('returns undefined when no session exists regardless of onlyIfSampled', () => {
+ const replay = setupReplayContainer();
+ replay.session = undefined;
+
+ expect(replay.getSessionId(true)).toBeUndefined();
+ expect(replay.getSessionId(false)).toBeUndefined();
+ });
+ });
+
+ describe('backward compatibility', () => {
+ it('maintains existing behavior when onlyIfSampled is not provided', () => {
+ const replay = setupReplayContainer();
+
+ // Test with different sampling states
+ const testCases: Array<{ sampled: Session['sampled']; expected: string | undefined }> = [
+ { sampled: 'session', expected: 'test-session-id' },
+ { sampled: 'buffer', expected: 'test-session-id' },
+ { sampled: false, expected: 'test-session-id' },
+ ];
+
+ testCases.forEach(({ sampled, expected }) => {
+ const mockSession: Session = {
+ id: 'test-session-id',
+ started: Date.now(),
+ lastActivity: Date.now(),
+ segmentId: 0,
+ sampled,
+ };
+ replay.session = mockSession;
+
+ expect(replay.getSessionId()).toBe(expected);
+ });
+ });
+ });
+});
diff --git a/yarn.lock b/yarn.lock
index 299b1415d89a..6e477bf0a40b 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -9448,7 +9448,7 @@
postcss "^8.4.47"
source-map-js "^1.2.0"
-"@vue/compiler-sfc@^3.4.15", "@vue/compiler-sfc@^3.5.13", "@vue/compiler-sfc@^3.5.4":
+"@vue/compiler-sfc@^3.4.15", "@vue/compiler-sfc@^3.5.4":
version "3.5.21"
resolved "https://registry.yarnpkg.com/@vue/compiler-sfc/-/compiler-sfc-3.5.21.tgz#e48189ef3ffe334c864c2625389ebe3bb4fa41eb"
integrity sha512-SXlyk6I5eUGBd2v8Ie7tF6ADHE9kCR6mBEuPyH1nUZ0h6Xx6nZI29i12sJKQmzbDyr2tUHMhhTt51Z6blbkTTQ==
@@ -14248,9 +14248,6 @@ detective-scss@^5.0.1:
version "5.0.1"
resolved "https://registry.yarnpkg.com/detective-scss/-/detective-scss-5.0.1.tgz#6a7f792dc9c0e8cfc0d252a50ba26a6df12596a7"
integrity sha512-MAyPYRgS6DCiS6n6AoSBJXLGVOydsr9huwXORUlJ37K3YLyiN0vYHpzs3AdJOgHobBfispokoqrEon9rbmKacg==
- dependencies:
- gonzales-pe "^4.3.0"
- node-source-walk "^7.0.1"
detective-stylus@^4.0.0:
version "4.0.0"
@@ -14285,14 +14282,6 @@ detective-vue2@^2.2.0:
version "2.2.0"
resolved "https://registry.yarnpkg.com/detective-vue2/-/detective-vue2-2.2.0.tgz#35fd1d39e261b064aca9fcaf20e136c76877482a"
integrity sha512-sVg/t6O2z1zna8a/UIV6xL5KUa2cMTQbdTIIvqNM0NIPswp52fe43Nwmbahzj3ww4D844u/vC2PYfiGLvD3zFA==
- dependencies:
- "@dependents/detective-less" "^5.0.1"
- "@vue/compiler-sfc" "^3.5.13"
- detective-es6 "^5.0.1"
- detective-sass "^6.0.1"
- detective-scss "^5.0.1"
- detective-stylus "^5.0.1"
- detective-typescript "^14.0.0"
deterministic-object-hash@^1.3.1:
version "1.3.1"
@@ -16844,9 +16833,6 @@ fetch-blob@^3.1.2, fetch-blob@^3.1.4:
version "3.2.0"
resolved "https://registry.yarnpkg.com/fetch-blob/-/fetch-blob-3.2.0.tgz#f09b8d4bbd45adc6f0c20b7e787e793e309dcce9"
integrity sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==
- dependencies:
- node-domexception "^1.0.0"
- web-streams-polyfill "^3.0.3"
fflate@0.8.2, fflate@^0.8.2:
version "0.8.2"
@@ -23099,11 +23085,6 @@ node-cron@^3.0.3:
dependencies:
uuid "8.3.2"
-node-domexception@^1.0.0:
- version "1.0.0"
- resolved "https://registry.yarnpkg.com/node-domexception/-/node-domexception-1.0.0.tgz#6888db46a1f71c0b76b3f7555016b63fe64766e5"
- integrity sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==
-
node-fetch-native@^1.4.0, node-fetch-native@^1.6.3, node-fetch-native@^1.6.4, node-fetch-native@^1.6.6:
version "1.6.6"
resolved "https://registry.yarnpkg.com/node-fetch-native/-/node-fetch-native-1.6.6.tgz#ae1d0e537af35c2c0b0de81cbff37eedd410aa37"
@@ -26164,10 +26145,10 @@ react-router@6.28.1:
dependencies:
"@remix-run/router" "1.21.0"
-react-router@^7.5.2:
- version "7.6.2"
- resolved "https://registry.yarnpkg.com/react-router/-/react-router-7.6.2.tgz#9f48b343bead7d0a94e28342fc4f9ae29131520e"
- integrity sha512-U7Nv3y+bMimgWjhlT5CRdzHPu2/KVmqPwKUCChW8en5P3znxUqwlYFlbmyj8Rgp1SF6zs5X4+77kBVknkg6a0w==
+react-router@^7.9.2:
+ version "7.9.2"
+ resolved "https://registry.yarnpkg.com/react-router/-/react-router-7.9.2.tgz#f424a14f87e4d7b5b268ce3647876e9504e4fca6"
+ integrity sha512-i2TPp4dgaqrOqiRGLZmqh2WXmbdFknUyiCRmSKs0hf6fWXkTKg5h56b+9F22NbGRAMxjYfqQnpi63egzD2SuZA==
dependencies:
cookie "^1.0.1"
set-cookie-parser "^2.6.0"
@@ -31227,7 +31208,7 @@ web-namespaces@^2.0.0:
resolved "https://registry.yarnpkg.com/web-namespaces/-/web-namespaces-2.0.1.tgz#1010ff7c650eccb2592cebeeaf9a1b253fd40692"
integrity sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ==
-web-streams-polyfill@^3.0.3, web-streams-polyfill@^3.1.1:
+web-streams-polyfill@^3.1.1:
version "3.3.3"
resolved "https://registry.yarnpkg.com/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz#2073b91a2fdb1fbfbd401e7de0ac9f8214cecb4b"
integrity sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==