Skip to content

feat(core): Ensure replay envelopes are sent in order when offline #11413

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 23 commits into from
Apr 9, 2024
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import * as Sentry from '@sentry/browser';

window.Sentry = Sentry;
window.Replay = Sentry.replayIntegration({
flushMinDelay: 200,
flushMaxDelay: 200,
minReplayDuration: 0,
});

Sentry.init({
dsn: 'https://[email protected]/1337',
sampleRate: 0,
replaysSessionSampleRate: 1.0,
replaysOnErrorSampleRate: 0.0,
transport: Sentry.makeBrowserOfflineTransport(),
integrations: [window.Replay],
});
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 onclick="console.log('Test log')">Click me</button>
</body>
</html>
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { expect } from '@playwright/test';

import { sentryTest } from '../../../utils/fixtures';
import { getReplayEvent, shouldSkipReplayTest, waitForReplayRequest } from '../../../utils/replayHelpers';

function delay(ms: number) {
return new Promise(resolve => setTimeout(resolve, ms));
}

sentryTest('should capture replays offline', async ({ getLocalTestPath, page }) => {
if (shouldSkipReplayTest()) {
sentryTest.skip();
}

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 });

// This would be the obvious way to test offline support but it doesn't appear to work!
// await context.setOffline(true);

let abortedCount = 0;

// Abort the first envelope request so the event gets queued
await page.route(/ingest\.sentry\.io/, route => {
abortedCount += 1;
return route.abort();
});
await page.goto(url);
await delay(1_000);
await page.unroute(/ingest\.sentry\.io/);

expect(abortedCount).toBe(1);

await delay(100);
// Now send a second event which should be queued after the the first one and force flushing the queue
await page.locator('button').click();

const replayEvent0 = getReplayEvent(await waitForReplayRequest(page, 0));
const replayEvent1 = getReplayEvent(await waitForReplayRequest(page, 1));

// Check that we received the envelopes in the correct order
expect(replayEvent0.timestamp).toBeLessThan(replayEvent1.timestamp || 0);
});
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,5 @@ window.Sentry = Sentry;

Sentry.init({
dsn: 'https://[email protected]/1337',
transport: Sentry.makeBrowserOfflineTransport(Sentry.makeFetchTransport),
transport: Sentry.makeBrowserOfflineTransport(),
});
33 changes: 28 additions & 5 deletions packages/browser/src/transports/offline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,8 +48,8 @@ function keys(store: IDBObjectStore): Promise<number[]> {
return promisifyRequest(store.getAllKeys() as IDBRequest<number[]>);
}

/** Insert into the store */
export function insert(store: Store, value: Uint8Array | string, maxQueueSize: number): Promise<void> {
/** Insert into the end of the store */
export function push(store: Store, value: Uint8Array | string, maxQueueSize: number): Promise<void> {
return store(store => {
return keys(store).then(keys => {
if (keys.length >= maxQueueSize) {
Expand All @@ -63,6 +63,21 @@ export function insert(store: Store, value: Uint8Array | string, maxQueueSize: n
});
}

/** Insert into the front of the store */
export function unshift(store: Store, value: Uint8Array | string, maxQueueSize: number): Promise<void> {
return store(store => {
return keys(store).then(keys => {
if (keys.length >= maxQueueSize) {
return;
}

// We insert with an decremented key so that the entries are popped in order
store.put(value, Math.min(...keys, 0) - 1);
return promisifyRequest(store.transaction);
});
});
}

/** Pop the oldest value from the store */
export function pop(store: Store): Promise<Uint8Array | string | undefined> {
return store(store => {
Expand All @@ -79,7 +94,7 @@ export function pop(store: Store): Promise<Uint8Array | string | undefined> {
});
}

export interface BrowserOfflineTransportOptions extends OfflineTransportOptions {
export interface BrowserOfflineTransportOptions extends Omit<OfflineTransportOptions, 'createStore'> {
/**
* Name of indexedDb database to store envelopes in
* Default: 'sentry-offline'
Expand Down Expand Up @@ -110,10 +125,18 @@ function createIndexedDbStore(options: BrowserOfflineTransportOptions): OfflineS
}

return {
insert: async (env: Envelope) => {
push: async (env: Envelope) => {
try {
const serialized = await serializeEnvelope(env);
await push(getStore(), serialized, options.maxQueueSize || 30);
} catch (_) {
//
}
},
unshift: async (env: Envelope) => {
try {
const serialized = await serializeEnvelope(env);
await insert(getStore(), serialized, options.maxQueueSize || 30);
await unshift(getStore(), serialized, options.maxQueueSize || 30);
} catch (_) {
//
}
Expand Down
21 changes: 12 additions & 9 deletions packages/browser/test/unit/transports/offline.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import type {
import { createEnvelope } from '@sentry/utils';

import { MIN_DELAY } from '../../../../core/src/transports/offline';
import { createStore, insert, makeBrowserOfflineTransport, pop } from '../../../src/transports/offline';
import { createStore, makeBrowserOfflineTransport, pop, push, unshift } from '../../../src/transports/offline';

function deleteDatabase(name: string): Promise<void> {
return new Promise<void>((resolve, reject) => {
Expand Down Expand Up @@ -63,21 +63,24 @@ describe('makeOfflineTransport', () => {
(global as any).TextDecoder = TextDecoder;
});

it('indexedDb wrappers insert and pop', async () => {
it('indexedDb wrappers push, unshift and pop', async () => {
const store = createStore('test', 'test');
const found = await pop(store);
expect(found).toBeUndefined();

await insert(store, 'test1', 30);
await insert(store, new Uint8Array([1, 2, 3, 4, 5]), 30);
await push(store, 'test1', 30);
await push(store, new Uint8Array([1, 2, 3, 4, 5]), 30);
await unshift(store, 'test2', 30);

const found2 = await pop(store);
expect(found2).toEqual('test1');
expect(found2).toEqual('test2');
const found3 = await pop(store);
expect(found3).toEqual(new Uint8Array([1, 2, 3, 4, 5]));

expect(found3).toEqual('test1');
const found4 = await pop(store);
expect(found4).toBeUndefined();
expect(found4).toEqual(new Uint8Array([1, 2, 3, 4, 5]));

const found5 = await pop(store);
expect(found5).toBeUndefined();
});

it('Queues and retries envelope if wrapped transport throws error', async () => {
Expand All @@ -104,7 +107,7 @@ describe('makeOfflineTransport', () => {
const result2 = await transport.send(ERROR_ENVELOPE);
expect(result2).toEqual({ statusCode: 200 });

await delay(MIN_DELAY * 2);
await delay(MIN_DELAY * 5);

expect(queuedCount).toEqual(1);
expect(getSendCount()).toEqual(2);
Expand Down
42 changes: 27 additions & 15 deletions packages/core/src/transports/offline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,8 @@ function log(msg: string, error?: Error): void {
}

export interface OfflineStore {
insert(env: Envelope): Promise<void>;
push(env: Envelope): Promise<void>;
unshift(env: Envelope): Promise<void>;
pop(): Promise<Envelope | undefined>;
}

Expand Down Expand Up @@ -55,17 +56,19 @@ export function makeOfflineTransport<TO>(
): (options: TO & OfflineTransportOptions) => Transport {
return options => {
const transport = createTransport(options);
const store = options.createStore ? options.createStore(options) : undefined;

if (!options.createStore) {
throw new Error('No `createStore` function was provided');
}

const store = options.createStore(options);

let retryDelay = START_DELAY;
let flushTimer: Timer | undefined;

function shouldQueue(env: Envelope, error: Error, retryDelay: number): boolean | Promise<boolean> {
// We don't queue Session Replay envelopes because they are:
// - Ordered and Replay relies on the response status to know when they're successfully sent.
// - Likely to fill the queue quickly and block other events from being sent.
// We also want to drop client reports because they can be generated when we retry sending events while offline.
if (envelopeContainsItemType(env, ['replay_event', 'replay_recording', 'client_report'])) {
// We want to drop client reports because they can be generated when we retry sending events while offline.
if (envelopeContainsItemType(env, ['client_report'])) {
return false;
}

Expand All @@ -77,10 +80,6 @@ export function makeOfflineTransport<TO>(
}

function flushIn(delay: number): void {
if (!store) {
return;
}

if (flushTimer) {
clearTimeout(flushTimer as ReturnType<typeof setTimeout>);
}
Expand All @@ -91,7 +90,7 @@ export function makeOfflineTransport<TO>(
const found = await store.pop();
if (found) {
log('Attempting to send previously queued event');
void send(found).catch(e => {
void send(found, true).catch(e => {
log('Failed to retry sending', e);
});
}
Expand All @@ -113,7 +112,15 @@ export function makeOfflineTransport<TO>(
retryDelay = Math.min(retryDelay * 2, MAX_DELAY);
}

async function send(envelope: Envelope): Promise<TransportMakeRequestResponse> {
async function send(envelope: Envelope, isRetry: boolean = false): Promise<TransportMakeRequestResponse> {
// We queue all replay envelopes to avoid multiple replay envelopes being sent at the same time. If one fails, we
// need to retry them in order.
if (!isRetry && envelopeContainsItemType(envelope, ['replay_event', 'replay_recording'])) {
await store.push(envelope);
flushIn(MIN_DELAY);
return {};
}

try {
const result = await transport.send(envelope);

Expand All @@ -133,8 +140,13 @@ export function makeOfflineTransport<TO>(
retryDelay = START_DELAY;
return result;
} catch (e) {
if (store && (await shouldQueue(envelope, e as Error, retryDelay))) {
await store.insert(envelope);
if (await shouldQueue(envelope, e as Error, retryDelay)) {
// If this envelope was a retry, we want to add it to the front of the queue so it's retried again first.
if (isRetry) {
await store.unshift(envelope);
} else {
await store.push(envelope);
}
flushWithBackOff();
log('Error sending. Event queued', e as Error);
return {};
Expand Down
Loading