Skip to content
Merged
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
6 changes: 4 additions & 2 deletions packages/core/src/logs/internal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -151,8 +151,10 @@ export function _INTERNAL_captureLog(
setLogAttribute(processedLogAttributes, 'sentry.sdk.name', name);
setLogAttribute(processedLogAttributes, 'sentry.sdk.version', version);

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

const beforeLogMessage = beforeLog.message;
if (isParameterizedString(beforeLogMessage)) {
Expand Down
181 changes: 181 additions & 0 deletions packages/core/test/lib/logs/internal.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
7 changes: 5 additions & 2 deletions packages/replay-internal/src/integration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

/**
Expand Down
11 changes: 9 additions & 2 deletions packages/replay-internal/src/replay.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down
109 changes: 109 additions & 0 deletions packages/replay-internal/test/integration/getReplayId.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
});
});
Loading