Skip to content

test(replay): Add Integration tests + setup for custom events #7052

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 8 commits into from
Feb 8, 2023
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
18 changes: 18 additions & 0 deletions packages/integration-tests/suites/replay/customEvents/init.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import * as Sentry from '@sentry/browser';
import { Replay } from '@sentry/replay';

window.Sentry = Sentry;
window.Replay = new Replay({
flushMinDelay: 500,
flushMaxDelay: 500,
useCompression: false,
});

Sentry.init({
dsn: 'https://[email protected]/1337',
sampleRate: 0,
replaysSessionSampleRate: 1.0,
replaysOnErrorSampleRate: 0.0,

integrations: [window.Replay],
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
document.getElementById('go-background').addEventListener('click', () => {
Object.defineProperty(document, 'hidden', { value: true, writable: true });
const ev = document.createEvent('Event');
ev.initEvent('visibilitychange');
document.dispatchEvent(ev);
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
</head>
<body>
<button id="go-background">New Tab</button>
</body>
</html>
107 changes: 107 additions & 0 deletions packages/integration-tests/suites/replay/customEvents/test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import { expect } from '@playwright/test';

import { sentryTest } from '../../../utils/fixtures';
import {
expectedClickBreadcrumb,
expectedFCPPerformanceSpan,
expectedFPPerformanceSpan,
expectedLCPPerformanceSpan,
expectedMemoryPerformanceSpan,
expectedNavigationPerformanceSpan,
getExpectedReplayEvent,
} from '../../../utils/replayEventTemplates';
import { getCustomRecordingEvents, getReplayEvent, waitForReplayRequest } from '../../../utils/replayHelpers';

sentryTest(
'replay recording should contain default performance spans',
async ({ getLocalTestPath, page, browserName }) => {
// Replay bundles are es6 only and most performance entries are only available in chromium
if ((process.env.PW_BUNDLE && process.env.PW_BUNDLE.startsWith('bundle_es5')) || browserName !== 'chromium') {
sentryTest.skip();
}

const reqPromise0 = waitForReplayRequest(page, 0);
const reqPromise1 = waitForReplayRequest(page, 1);

await page.route('https://dsn.ingest.sentry.io/**/*', route => {
return route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ id: 'test-id' }),
});
});

const url = await getLocalTestPath({ testDir: __dirname });

await page.goto(url);
const replayEvent0 = getReplayEvent(await reqPromise0);
const { performanceSpans: performanceSpans0 } = getCustomRecordingEvents(await reqPromise0);

expect(replayEvent0).toEqual(getExpectedReplayEvent({ segment_id: 0 }));

await page.click('button');

const replayEvent1 = getReplayEvent(await reqPromise1);
const { performanceSpans: performanceSpans1 } = getCustomRecordingEvents(await reqPromise1);

expect(replayEvent1).toEqual(
getExpectedReplayEvent({ segment_id: 1, urls: [], replay_start_timestamp: undefined }),
);

// We can't guarantee the order of the performance spans, or in which of the two segments they are sent
// So to avoid flakes, we collect them all and check that they are all there
const collectedPerformanceSpans = [...performanceSpans0, ...performanceSpans1];

expect(collectedPerformanceSpans).toEqual(
expect.arrayContaining([
expectedNavigationPerformanceSpan,
expectedLCPPerformanceSpan,
expectedFPPerformanceSpan,
expectedFCPPerformanceSpan,
expectedMemoryPerformanceSpan, // two memory spans - once per flush
expectedMemoryPerformanceSpan,
]),
);
},
);

sentryTest(
'replay recording should contain a click breadcrumb when a button is clicked',
async ({ getLocalTestPath, page }) => {
// Replay bundles are es6 only
if (process.env.PW_BUNDLE && process.env.PW_BUNDLE.startsWith('bundle_es5')) {
sentryTest.skip();
}

const reqPromise0 = waitForReplayRequest(page, 0);
const reqPromise1 = waitForReplayRequest(page, 1);

await page.route('https://dsn.ingest.sentry.io/**/*', route => {
return route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ id: 'test-id' }),
});
});

const url = await getLocalTestPath({ testDir: __dirname });

await page.goto(url);
const replayEvent0 = getReplayEvent(await reqPromise0);
const { breadcrumbs: breadcrumbs0 } = getCustomRecordingEvents(await reqPromise0);

expect(replayEvent0).toEqual(getExpectedReplayEvent({ segment_id: 0 }));
expect(breadcrumbs0.length).toEqual(0);

await page.click('button');

const replayEvent1 = getReplayEvent(await reqPromise1);
const { breadcrumbs: breadcrumbs1 } = getCustomRecordingEvents(await reqPromise1);

expect(replayEvent1).toEqual(
getExpectedReplayEvent({ segment_id: 1, urls: [], replay_start_timestamp: undefined }),
);

expect(breadcrumbs1).toEqual([expectedClickBreadcrumb]);
},
);
4 changes: 2 additions & 2 deletions packages/integration-tests/utils/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,8 @@ export const envelopeParser = (request: Request | null): unknown[] => {
});
};

export const envelopeRequestParser = (request: Request | null): Event => {
return envelopeParser(request)[2] as Event;
export const envelopeRequestParser = (request: Request | null, envelopeIndex = 2): Event => {
return envelopeParser(request)[envelopeIndex] as Event;
};

export const envelopeHeaderRequestParser = (request: Request | null): EventEnvelopeHeaders => {
Expand Down
124 changes: 124 additions & 0 deletions packages/integration-tests/utils/replayEventTemplates.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
/* eslint-disable @typescript-eslint/explicit-function-return-type */
import { expect } from '@playwright/test';
import { SDK_VERSION } from '@sentry/browser';
import type { ReplayEvent } from '@sentry/types';

const DEFAULT_REPLAY_EVENT = {
type: 'replay_event',
timestamp: expect.any(Number),
error_ids: [],
trace_ids: [],
urls: [expect.stringContaining('/dist/index.html')],
replay_id: expect.stringMatching(/\w{32}/),
replay_start_timestamp: expect.any(Number),
segment_id: 0,
replay_type: 'session',
event_id: expect.stringMatching(/\w{32}/),
environment: 'production',
sdk: {
integrations: [
'InboundFilters',
'FunctionToString',
'TryCatch',
'Breadcrumbs',
'GlobalHandlers',
'LinkedErrors',
'Dedupe',
'HttpContext',
'Replay',
],
version: SDK_VERSION,
name: 'sentry.javascript.browser',
},
sdkProcessingMetadata: {},
request: {
url: expect.stringContaining('/dist/index.html'),
headers: {
'User-Agent': expect.stringContaining(''),
},
},
platform: 'javascript',
contexts: { replay: { session_sample_rate: 1, error_sample_rate: 0 } },
};

/**
* Creates a ReplayEvent object with the default values merged with the customExpectedReplayEvent.
* This is useful for testing multi-segment replays to not repeat most of the properties that don't change
* throughout the replay segments.
*
* Note: The benfit of this approach over expect.objectContaining is that,
* we'll catch if properties we expect to stay the same actually change.
*
* @param customExpectedReplayEvent overwrite the default values with custom values (e.g. segment_id)
*/
export function getExpectedReplayEvent(customExpectedReplayEvent: Partial<ReplayEvent> & Record<string, unknown> = {}) {
return {
...DEFAULT_REPLAY_EVENT,
...customExpectedReplayEvent,
};
}

/* This is how we expect different kinds of navigation performance span to look: */

export const expectedNavigationPerformanceSpan = {
op: 'navigation.navigate',
description: '',
startTimestamp: expect.any(Number),
endTimestamp: expect.any(Number),
data: {
duration: expect.any(Number),
size: expect.any(Number),
},
};

export const expectedMemoryPerformanceSpan = {
op: 'memory',
description: 'memory',
startTimestamp: expect.any(Number),
endTimestamp: expect.any(Number),
data: {
memory: {
jsHeapSizeLimit: expect.any(Number),
totalJSHeapSize: expect.any(Number),
usedJSHeapSize: expect.any(Number),
},
},
};

export const expectedLCPPerformanceSpan = {
op: 'largest-contentful-paint',
description: 'largest-contentful-paint',
startTimestamp: expect.any(Number),
endTimestamp: expect.any(Number),
data: {
duration: expect.any(Number),
nodeId: expect.any(Number),
size: expect.any(Number),
},
};

export const expectedFCPPerformanceSpan = {
op: 'paint',
description: 'first-contentful-paint',
startTimestamp: expect.any(Number),
endTimestamp: expect.any(Number),
};

export const expectedFPPerformanceSpan = {
op: 'paint',
description: 'first-paint',
startTimestamp: expect.any(Number),
endTimestamp: expect.any(Number),
};

/* Breadcrumbs */

export const expectedClickBreadcrumb = {
timestamp: expect.any(Number),
type: 'default',
category: 'ui.click',
message: expect.any(String),
data: {
nodeId: expect.any(Number),
},
};
56 changes: 54 additions & 2 deletions packages/integration-tests/utils/replayHelpers.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,18 @@
import type { ReplayContainer } from '@sentry/replay/build/npm/types/types';
import type { Event, ReplayEvent } from '@sentry/types';
import type { RecordingEvent, ReplayContainer } from '@sentry/replay/build/npm/types/types';
import type { Breadcrumb, Event, ReplayEvent } from '@sentry/types';
import type { Page, Request } from 'playwright';

import { envelopeRequestParser } from './helpers';

type CustomRecordingEvent = { tag: string; payload: Record<string, unknown> };
type PerformanceSpan = {
op: string;
description: string;
startTimestamp: number;
endTimestamp: number;
data: Record<string, number>;
};

/**
* Waits for a replay request to be sent by the page and returns it.
*
Expand Down Expand Up @@ -56,3 +65,46 @@ export async function getReplaySnapshot(page: Page): Promise<ReplayContainer> {
}

export const REPLAY_DEFAULT_FLUSH_MAX_DELAY = 5_000;

export function getReplayEvent(replayRequest: Request): ReplayEvent {
const event = envelopeRequestParser(replayRequest);
if (!isReplayEvent(event)) {
throw new Error('Request is not a replay event');
}
return event;
}

/**
* Takes an uncompressed replay request and returns the custom recording events,
* i.e. the events we emit as type 5 rrweb events
*
* @param replayRequest
* @returns an object containing the replay breadcrumbs and performance spans
*/
export function getCustomRecordingEvents(replayRequest: Request): {
breadcrumbs: Breadcrumb[];
performanceSpans: PerformanceSpan[];
} {
const recordingEvents = envelopeRequestParser(replayRequest, 5) as RecordingEvent[];

const breadcrumbs = getReplayBreadcrumbs(recordingEvents);
const performanceSpans = getReplayPerformanceSpans(recordingEvents);
return { breadcrumbs, performanceSpans };
}

function getAllCustomRrwebRecordingEvents(recordingEvents: RecordingEvent[]): CustomRecordingEvent[] {
return recordingEvents.filter(event => event.type === 5).map(event => event.data as CustomRecordingEvent);
}

function getReplayBreadcrumbs(recordingEvents: RecordingEvent[], category?: string): Breadcrumb[] {
return getAllCustomRrwebRecordingEvents(recordingEvents)
.filter(data => data.tag === 'breadcrumb')
.map(data => data.payload)
.filter(payload => !category || payload.category === category);
}

function getReplayPerformanceSpans(recordingEvents: RecordingEvent[]): PerformanceSpan[] {
return getAllCustomRrwebRecordingEvents(recordingEvents)
.filter(data => data.tag === 'performanceSpan')
.map(data => data.payload) as PerformanceSpan[];
}