Skip to content

Commit 80e26e0

Browse files
authored
feat(replay/logs): Only attach sampled replay Ids to logs (#17750)
Adds an option to `getSessionId` and `getSessionId` to only return a value if the replay is sampled. ref #17676
1 parent b29c880 commit 80e26e0

File tree

6 files changed

+431
-6
lines changed

6 files changed

+431
-6
lines changed

packages/core/src/logs/internal.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -151,8 +151,10 @@ export function _INTERNAL_captureLog(
151151
setLogAttribute(processedLogAttributes, 'sentry.sdk.name', name);
152152
setLogAttribute(processedLogAttributes, 'sentry.sdk.version', version);
153153

154-
const replay = client.getIntegrationByName<Integration & { getReplayId: () => string }>('Replay');
155-
setLogAttribute(processedLogAttributes, 'sentry.replay_id', replay?.getReplayId());
154+
const replay = client.getIntegrationByName<Integration & { getReplayId: (onlyIfSampled?: boolean) => string }>(
155+
'Replay',
156+
);
157+
setLogAttribute(processedLogAttributes, 'sentry.replay_id', replay?.getReplayId(true));
156158

157159
const beforeLogMessage = beforeLog.message;
158160
if (isParameterizedString(beforeLogMessage)) {

packages/core/test/lib/logs/internal.test.ts

Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -411,6 +411,187 @@ describe('_INTERNAL_captureLog', () => {
411411
beforeCaptureLogSpy.mockRestore();
412412
});
413413

414+
describe('replay integration with onlyIfSampled', () => {
415+
it('includes replay ID for sampled sessions', () => {
416+
const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, enableLogs: true });
417+
const client = new TestClient(options);
418+
const scope = new Scope();
419+
scope.setClient(client);
420+
421+
// Mock replay integration with sampled session
422+
const mockReplayIntegration = {
423+
getReplayId: vi.fn((onlyIfSampled?: boolean) => {
424+
// Simulate behavior: return ID for sampled sessions
425+
return onlyIfSampled ? 'sampled-replay-id' : 'any-replay-id';
426+
}),
427+
};
428+
429+
vi.spyOn(client, 'getIntegrationByName').mockReturnValue(mockReplayIntegration as any);
430+
431+
_INTERNAL_captureLog({ level: 'info', message: 'test log with sampled replay' }, scope);
432+
433+
// Verify getReplayId was called with onlyIfSampled=true
434+
expect(mockReplayIntegration.getReplayId).toHaveBeenCalledWith(true);
435+
436+
const logAttributes = _INTERNAL_getLogBuffer(client)?.[0]?.attributes;
437+
expect(logAttributes).toEqual({
438+
'sentry.replay_id': {
439+
value: 'sampled-replay-id',
440+
type: 'string',
441+
},
442+
});
443+
});
444+
445+
it('excludes replay ID for unsampled sessions when onlyIfSampled=true', () => {
446+
const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, enableLogs: true });
447+
const client = new TestClient(options);
448+
const scope = new Scope();
449+
scope.setClient(client);
450+
451+
// Mock replay integration with unsampled session
452+
const mockReplayIntegration = {
453+
getReplayId: vi.fn((onlyIfSampled?: boolean) => {
454+
// Simulate behavior: return undefined for unsampled when onlyIfSampled=true
455+
return onlyIfSampled ? undefined : 'unsampled-replay-id';
456+
}),
457+
};
458+
459+
vi.spyOn(client, 'getIntegrationByName').mockReturnValue(mockReplayIntegration as any);
460+
461+
_INTERNAL_captureLog({ level: 'info', message: 'test log with unsampled replay' }, scope);
462+
463+
// Verify getReplayId was called with onlyIfSampled=true
464+
expect(mockReplayIntegration.getReplayId).toHaveBeenCalledWith(true);
465+
466+
const logAttributes = _INTERNAL_getLogBuffer(client)?.[0]?.attributes;
467+
// Should not include sentry.replay_id attribute
468+
expect(logAttributes).toEqual({});
469+
});
470+
471+
it('includes replay ID for buffer mode sessions', () => {
472+
const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, enableLogs: true });
473+
const client = new TestClient(options);
474+
const scope = new Scope();
475+
scope.setClient(client);
476+
477+
// Mock replay integration with buffer mode session
478+
const mockReplayIntegration = {
479+
getReplayId: vi.fn((_onlyIfSampled?: boolean) => {
480+
// Buffer mode should still return ID even with onlyIfSampled=true
481+
return 'buffer-replay-id';
482+
}),
483+
};
484+
485+
vi.spyOn(client, 'getIntegrationByName').mockReturnValue(mockReplayIntegration as any);
486+
487+
_INTERNAL_captureLog({ level: 'info', message: 'test log with buffer replay' }, scope);
488+
489+
expect(mockReplayIntegration.getReplayId).toHaveBeenCalledWith(true);
490+
491+
const logAttributes = _INTERNAL_getLogBuffer(client)?.[0]?.attributes;
492+
expect(logAttributes).toEqual({
493+
'sentry.replay_id': {
494+
value: 'buffer-replay-id',
495+
type: 'string',
496+
},
497+
});
498+
});
499+
500+
it('handles missing replay integration gracefully', () => {
501+
const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, enableLogs: true });
502+
const client = new TestClient(options);
503+
const scope = new Scope();
504+
scope.setClient(client);
505+
506+
// Mock no replay integration found
507+
vi.spyOn(client, 'getIntegrationByName').mockReturnValue(undefined);
508+
509+
_INTERNAL_captureLog({ level: 'info', message: 'test log without replay' }, scope);
510+
511+
const logAttributes = _INTERNAL_getLogBuffer(client)?.[0]?.attributes;
512+
// Should not include sentry.replay_id attribute
513+
expect(logAttributes).toEqual({});
514+
});
515+
516+
it('combines replay ID with other log attributes', () => {
517+
const options = getDefaultTestClientOptions({
518+
dsn: PUBLIC_DSN,
519+
enableLogs: true,
520+
release: '1.0.0',
521+
environment: 'test',
522+
});
523+
const client = new TestClient(options);
524+
const scope = new Scope();
525+
scope.setClient(client);
526+
527+
// Mock replay integration
528+
const mockReplayIntegration = {
529+
getReplayId: vi.fn(() => 'test-replay-id'),
530+
};
531+
532+
vi.spyOn(client, 'getIntegrationByName').mockReturnValue(mockReplayIntegration as any);
533+
534+
_INTERNAL_captureLog(
535+
{
536+
level: 'info',
537+
message: 'test log with replay and other attributes',
538+
attributes: { component: 'auth', action: 'login' },
539+
},
540+
scope,
541+
);
542+
543+
const logAttributes = _INTERNAL_getLogBuffer(client)?.[0]?.attributes;
544+
expect(logAttributes).toEqual({
545+
component: {
546+
value: 'auth',
547+
type: 'string',
548+
},
549+
action: {
550+
value: 'login',
551+
type: 'string',
552+
},
553+
'sentry.release': {
554+
value: '1.0.0',
555+
type: 'string',
556+
},
557+
'sentry.environment': {
558+
value: 'test',
559+
type: 'string',
560+
},
561+
'sentry.replay_id': {
562+
value: 'test-replay-id',
563+
type: 'string',
564+
},
565+
});
566+
});
567+
568+
it('does not set replay ID attribute when getReplayId returns null or undefined', () => {
569+
const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, enableLogs: true });
570+
const client = new TestClient(options);
571+
const scope = new Scope();
572+
scope.setClient(client);
573+
574+
const testCases = [null, undefined];
575+
576+
testCases.forEach(returnValue => {
577+
// Clear buffer for each test
578+
_INTERNAL_getLogBuffer(client)?.splice(0);
579+
580+
const mockReplayIntegration = {
581+
getReplayId: vi.fn(() => returnValue),
582+
};
583+
584+
vi.spyOn(client, 'getIntegrationByName').mockReturnValue(mockReplayIntegration as any);
585+
586+
_INTERNAL_captureLog({ level: 'info', message: `test log with replay returning ${returnValue}` }, scope);
587+
588+
const logAttributes = _INTERNAL_getLogBuffer(client)?.[0]?.attributes;
589+
expect(logAttributes).toEqual({});
590+
expect(logAttributes).not.toHaveProperty('sentry.replay_id');
591+
});
592+
});
593+
});
594+
414595
describe('user functionality', () => {
415596
it('includes user data in log attributes', () => {
416597
const options = getDefaultTestClientOptions({

packages/replay-internal/src/integration.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -280,13 +280,16 @@ export class Replay implements Integration {
280280

281281
/**
282282
* Get the current session ID.
283+
*
284+
* @param onlyIfSampled - If true, will only return the session ID if the session is sampled.
285+
*
283286
*/
284-
public getReplayId(): string | undefined {
287+
public getReplayId(onlyIfSampled?: boolean): string | undefined {
285288
if (!this._replay?.isEnabled()) {
286289
return;
287290
}
288291

289-
return this._replay.getSessionId();
292+
return this._replay.getSessionId(onlyIfSampled);
290293
}
291294

292295
/**

packages/replay-internal/src/replay.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -719,8 +719,15 @@ export class ReplayContainer implements ReplayContainerInterface {
719719
this._debouncedFlush.cancel();
720720
}
721721

722-
/** Get the current session (=replay) ID */
723-
public getSessionId(): string | undefined {
722+
/** Get the current session (=replay) ID
723+
*
724+
* @param onlyIfSampled - If true, will only return the session ID if the session is sampled.
725+
*/
726+
public getSessionId(onlyIfSampled?: boolean): string | undefined {
727+
if (onlyIfSampled && this.session?.sampled === false) {
728+
return undefined;
729+
}
730+
724731
return this.session?.id;
725732
}
726733

packages/replay-internal/test/integration/getReplayId.test.ts

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,4 +30,113 @@ describe('Integration | getReplayId', () => {
3030

3131
expect(integration.getReplayId()).toBeUndefined();
3232
});
33+
34+
describe('onlyIfSampled parameter', () => {
35+
it('returns replay ID for session mode when onlyIfSampled=true', async () => {
36+
const { integration, replay } = await mockSdk({
37+
replayOptions: {
38+
stickySession: true,
39+
},
40+
});
41+
42+
// Should be in session mode by default with sessionSampleRate: 1.0
43+
expect(replay.recordingMode).toBe('session');
44+
expect(replay.session?.sampled).toBe('session');
45+
46+
expect(integration.getReplayId(true)).toBeDefined();
47+
expect(integration.getReplayId(true)).toEqual(replay.session?.id);
48+
});
49+
50+
it('returns replay ID for buffer mode when onlyIfSampled=true', async () => {
51+
const { integration, replay } = await mockSdk({
52+
replayOptions: {
53+
stickySession: true,
54+
},
55+
sentryOptions: {
56+
replaysSessionSampleRate: 0.0,
57+
replaysOnErrorSampleRate: 1.0,
58+
},
59+
});
60+
61+
// Force buffer mode by manually setting session
62+
if (replay.session) {
63+
replay.session.sampled = 'buffer';
64+
replay.recordingMode = 'buffer';
65+
}
66+
67+
expect(integration.getReplayId(true)).toBeDefined();
68+
expect(integration.getReplayId(true)).toEqual(replay.session?.id);
69+
});
70+
71+
it('returns undefined for unsampled sessions when onlyIfSampled=true', async () => {
72+
const { integration, replay } = await mockSdk({
73+
replayOptions: {
74+
stickySession: true,
75+
},
76+
sentryOptions: {
77+
replaysSessionSampleRate: 1.0, // Start enabled to create session
78+
replaysOnErrorSampleRate: 0.0,
79+
},
80+
});
81+
82+
// Manually create an unsampled session by overriding the existing one
83+
replay.session = {
84+
id: 'test-unsampled-session',
85+
started: Date.now(),
86+
lastActivity: Date.now(),
87+
segmentId: 0,
88+
sampled: false,
89+
};
90+
91+
expect(integration.getReplayId(true)).toBeUndefined();
92+
// But default behavior should still return the ID
93+
expect(integration.getReplayId()).toBe('test-unsampled-session');
94+
expect(integration.getReplayId(false)).toBe('test-unsampled-session');
95+
});
96+
97+
it('maintains backward compatibility when onlyIfSampled is not provided', async () => {
98+
const { integration, replay } = await mockSdk({
99+
replayOptions: {
100+
stickySession: true,
101+
},
102+
sentryOptions: {
103+
replaysSessionSampleRate: 1.0, // Start with a session to ensure initialization
104+
replaysOnErrorSampleRate: 0.0,
105+
},
106+
});
107+
108+
const testCases: Array<{ sampled: 'session' | 'buffer' | false; sessionId: string }> = [
109+
{ sampled: 'session', sessionId: 'session-test-id' },
110+
{ sampled: 'buffer', sessionId: 'buffer-test-id' },
111+
{ sampled: false, sessionId: 'unsampled-test-id' },
112+
];
113+
114+
for (const { sampled, sessionId } of testCases) {
115+
replay.session = {
116+
id: sessionId,
117+
started: Date.now(),
118+
lastActivity: Date.now(),
119+
segmentId: 0,
120+
sampled,
121+
};
122+
123+
// Default behavior should always return the ID
124+
expect(integration.getReplayId()).toBe(sessionId);
125+
}
126+
});
127+
128+
it('returns undefined when replay is disabled regardless of onlyIfSampled', async () => {
129+
const { integration } = await mockSdk({
130+
replayOptions: {
131+
stickySession: true,
132+
},
133+
});
134+
135+
integration.stop();
136+
137+
expect(integration.getReplayId()).toBeUndefined();
138+
expect(integration.getReplayId(true)).toBeUndefined();
139+
expect(integration.getReplayId(false)).toBeUndefined();
140+
});
141+
});
33142
});

0 commit comments

Comments
 (0)