diff --git a/packages/core/src/logs/internal.ts b/packages/core/src/logs/internal.ts index adcbf0dfb737..b439d04ec075 100644 --- a/packages/core/src/logs/internal.ts +++ b/packages/core/src/logs/internal.ts @@ -151,8 +151,10 @@ 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 string }>( + 'Replay', + ); + setLogAttribute(processedLogAttributes, 'sentry.replay_id', replay?.getReplayId(true)); const beforeLogMessage = beforeLog.message; if (isParameterizedString(beforeLogMessage)) { diff --git a/packages/core/test/lib/logs/internal.test.ts b/packages/core/test/lib/logs/internal.test.ts index 49339e72b6b1..b2d5569ecfa7 100644 --- a/packages/core/test/lib/logs/internal.test.ts +++ b/packages/core/test/lib/logs/internal.test.ts @@ -411,6 +411,187 @@ 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'; + }), + }; + + 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'; + }), + }; + + 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', + }, + }); + }); + + 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'), + }; + + 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'); + }); + }); + }); + describe('user functionality', () => { it('includes user data in log attributes', () => { const options = getDefaultTestClientOptions({ 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); + }); + }); + }); +});