Skip to content

Commit 345b6fd

Browse files
committed
feat(core): Ensure replay envelopes are sent in order when offline
1 parent 72e6926 commit 345b6fd

File tree

4 files changed

+177
-83
lines changed

4 files changed

+177
-83
lines changed

packages/browser/src/transports/offline.ts

Lines changed: 28 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -48,8 +48,8 @@ function keys(store: IDBObjectStore): Promise<number[]> {
4848
return promisifyRequest(store.getAllKeys() as IDBRequest<number[]>);
4949
}
5050

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

66+
/** Insert into the front of the store */
67+
export function unshift(store: Store, value: Uint8Array | string, maxQueueSize: number): Promise<void> {
68+
return store(store => {
69+
return keys(store).then(keys => {
70+
if (keys.length >= maxQueueSize) {
71+
return;
72+
}
73+
74+
// We insert with an incremented key so that the entries are popped in order
75+
store.put(value, Math.min(...keys, 0) - 1);
76+
return promisifyRequest(store.transaction);
77+
});
78+
});
79+
}
80+
6681
/** Pop the oldest value from the store */
6782
export function pop(store: Store): Promise<Uint8Array | string | undefined> {
6883
return store(store => {
@@ -79,7 +94,7 @@ export function pop(store: Store): Promise<Uint8Array | string | undefined> {
7994
});
8095
}
8196

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

112127
return {
113-
insert: async (env: Envelope) => {
128+
push: async (env: Envelope) => {
129+
try {
130+
const serialized = await serializeEnvelope(env);
131+
await push(getStore(), serialized, options.maxQueueSize || 30);
132+
} catch (_) {
133+
//
134+
}
135+
},
136+
unshift: async (env: Envelope) => {
114137
try {
115138
const serialized = await serializeEnvelope(env);
116-
await insert(getStore(), serialized, options.maxQueueSize || 30);
139+
await unshift(getStore(), serialized, options.maxQueueSize || 30);
117140
} catch (_) {
118141
//
119142
}

packages/browser/test/unit/transports/offline.test.ts

Lines changed: 12 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import type {
1111
import { createEnvelope } from '@sentry/utils';
1212

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

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

66-
it('indexedDb wrappers insert and pop', async () => {
66+
it('indexedDb wrappers push, unshift and pop', async () => {
6767
const store = createStore('test', 'test');
6868
const found = await pop(store);
6969
expect(found).toBeUndefined();
7070

71-
await insert(store, 'test1', 30);
72-
await insert(store, new Uint8Array([1, 2, 3, 4, 5]), 30);
71+
await push(store, 'test1', 30);
72+
await push(store, new Uint8Array([1, 2, 3, 4, 5]), 30);
73+
await unshift(store, 'test2', 30);
7374

7475
const found2 = await pop(store);
75-
expect(found2).toEqual('test1');
76+
expect(found2).toEqual('test2');
7677
const found3 = await pop(store);
77-
expect(found3).toEqual(new Uint8Array([1, 2, 3, 4, 5]));
78-
78+
expect(found3).toEqual('test1');
7979
const found4 = await pop(store);
80-
expect(found4).toBeUndefined();
80+
expect(found4).toEqual(new Uint8Array([1, 2, 3, 4, 5]));
81+
82+
const found5 = await pop(store);
83+
expect(found5).toBeUndefined();
8184
});
8285

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

107-
await delay(MIN_DELAY * 2);
110+
await delay(MIN_DELAY * 5);
108111

109112
expect(queuedCount).toEqual(1);
110113
expect(getSendCount()).toEqual(2);

packages/core/src/transports/offline.ts

Lines changed: 27 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,8 @@ function log(msg: string, error?: Error): void {
1212
}
1313

1414
export interface OfflineStore {
15-
insert(env: Envelope): Promise<void>;
15+
push(env: Envelope): Promise<void>;
16+
unshift(env: Envelope): Promise<void>;
1617
pop(): Promise<Envelope | undefined>;
1718
}
1819

@@ -55,17 +56,19 @@ export function makeOfflineTransport<TO>(
5556
): (options: TO & OfflineTransportOptions) => Transport {
5657
return options => {
5758
const transport = createTransport(options);
58-
const store = options.createStore ? options.createStore(options) : undefined;
59+
60+
if (!options.createStore) {
61+
throw new Error('No `createStore` function was provided');
62+
}
63+
64+
const store = options.createStore(options);
5965

6066
let retryDelay = START_DELAY;
6167
let flushTimer: Timer | undefined;
6268

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

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

7982
function flushIn(delay: number): void {
80-
if (!store) {
81-
return;
82-
}
83-
8483
if (flushTimer) {
8584
clearTimeout(flushTimer as ReturnType<typeof setTimeout>);
8685
}
@@ -91,7 +90,7 @@ export function makeOfflineTransport<TO>(
9190
const found = await store.pop();
9291
if (found) {
9392
log('Attempting to send previously queued event');
94-
void send(found).catch(e => {
93+
void send(found, true).catch(e => {
9594
log('Failed to retry sending', e);
9695
});
9796
}
@@ -113,7 +112,15 @@ export function makeOfflineTransport<TO>(
113112
retryDelay = Math.min(retryDelay * 2, MAX_DELAY);
114113
}
115114

116-
async function send(envelope: Envelope): Promise<TransportMakeRequestResponse> {
115+
async function send(envelope: Envelope, isRetry: boolean = false): Promise<TransportMakeRequestResponse> {
116+
// We queue all replay envelopes to avoid multiple replay envelopes being sent at the same time. If one fails, we
117+
// need to retry them in order.
118+
if (!isRetry && envelopeContainsItemType(envelope, ['replay_event', 'replay_recording'])) {
119+
await store.push(envelope);
120+
flushIn(MIN_DELAY);
121+
return {};
122+
}
123+
117124
try {
118125
const result = await transport.send(envelope);
119126

@@ -133,8 +140,13 @@ export function makeOfflineTransport<TO>(
133140
retryDelay = START_DELAY;
134141
return result;
135142
} catch (e) {
136-
if (store && (await shouldQueue(envelope, e as Error, retryDelay))) {
137-
await store.insert(envelope);
143+
if (await shouldQueue(envelope, e as Error, retryDelay)) {
144+
// If this envelope was a retry, we want to add it to the front of the queue so it's retried again first.
145+
if (isRetry) {
146+
await store.unshift(envelope);
147+
} else {
148+
await store.push(envelope);
149+
}
138150
flushWithBackOff();
139151
log('Error sending. Event queued', e as Error);
140152
return {};

0 commit comments

Comments
 (0)