diff --git a/dev-packages/browser-integration-tests/playwright.config.ts b/dev-packages/browser-integration-tests/playwright.config.ts
index 78a71108837f..77ed6014d230 100644
--- a/dev-packages/browser-integration-tests/playwright.config.ts
+++ b/dev-packages/browser-integration-tests/playwright.config.ts
@@ -11,7 +11,7 @@ const config: PlaywrightTestConfig = {
testMatch: /test.ts/,
use: {
- trace: process.env.CI ? 'retry-with-trace' : 'off',
+ trace: process.env.CI ? 'retain-on-failure' : 'off',
},
projects: [
diff --git a/dev-packages/browser-integration-tests/suites/replay/captureReplayOffline/init.js b/dev-packages/browser-integration-tests/suites/replay/captureReplayOffline/init.js
new file mode 100644
index 000000000000..0e1297ae8710
--- /dev/null
+++ b/dev-packages/browser-integration-tests/suites/replay/captureReplayOffline/init.js
@@ -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://public@dsn.ingest.sentry.io/1337',
+ sampleRate: 0,
+ replaysSessionSampleRate: 1.0,
+ replaysOnErrorSampleRate: 0.0,
+ transport: Sentry.makeBrowserOfflineTransport(),
+ integrations: [window.Replay],
+});
diff --git a/dev-packages/browser-integration-tests/suites/replay/captureReplayOffline/template.html b/dev-packages/browser-integration-tests/suites/replay/captureReplayOffline/template.html
new file mode 100644
index 000000000000..2b3e2f0b27b4
--- /dev/null
+++ b/dev-packages/browser-integration-tests/suites/replay/captureReplayOffline/template.html
@@ -0,0 +1,9 @@
+
+
+
+
+
+
+
+
+
diff --git a/dev-packages/browser-integration-tests/suites/replay/captureReplayOffline/test.ts b/dev-packages/browser-integration-tests/suites/replay/captureReplayOffline/test.ts
new file mode 100644
index 000000000000..a74a2c891fad
--- /dev/null
+++ b/dev-packages/browser-integration-tests/suites/replay/captureReplayOffline/test.ts
@@ -0,0 +1,45 @@
+import { expect } from '@playwright/test';
+
+import { sentryTest } from '../../../utils/fixtures';
+import { getReplayEvent, shouldSkipReplayTest, waitForReplayRequest } from '../../../utils/replayHelpers';
+
+sentryTest('should capture replays offline', async ({ getLocalTestPath, page }) => {
+ // makeBrowserOfflineTransport is not included in any CDN bundles
+ if (shouldSkipReplayTest() || (process.env.PW_BUNDLE && process.env.PW_BUNDLE.startsWith('bundle'))) {
+ 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 });
+
+ // This would be the obvious way to test offline support but it doesn't appear to work!
+ // await context.setOffline(true);
+
+ // Abort the first envelope request so the event gets queued
+ await page.route(/ingest\.sentry\.io/, route => route.abort('internetdisconnected'), { times: 1 });
+
+ await page.goto(url);
+
+ await new Promise(resolve => setTimeout(resolve, 2_000));
+
+ // 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 reqPromise0);
+ const replayEvent1 = getReplayEvent(await reqPromise1);
+
+ // Check that we received the envelopes in the correct order
+ expect(replayEvent0.timestamp).toBeGreaterThan(0);
+ expect(replayEvent1.timestamp).toBeGreaterThan(0);
+ expect(replayEvent0.timestamp).toBeLessThan(replayEvent1.timestamp || 0);
+});
diff --git a/dev-packages/browser-integration-tests/suites/transport/offline/init.js b/dev-packages/browser-integration-tests/suites/transport/offline/init.js
index a69f8a32b32a..e102d6a8a5a5 100644
--- a/dev-packages/browser-integration-tests/suites/transport/offline/init.js
+++ b/dev-packages/browser-integration-tests/suites/transport/offline/init.js
@@ -4,5 +4,5 @@ window.Sentry = Sentry;
Sentry.init({
dsn: 'https://public@dsn.ingest.sentry.io/1337',
- transport: Sentry.makeBrowserOfflineTransport(Sentry.makeFetchTransport),
+ transport: Sentry.makeBrowserOfflineTransport(),
});
diff --git a/packages/browser/src/transports/offline.ts b/packages/browser/src/transports/offline.ts
index dd7d9c70d929..4a435b2972fa 100644
--- a/packages/browser/src/transports/offline.ts
+++ b/packages/browser/src/transports/offline.ts
@@ -48,8 +48,8 @@ function keys(store: IDBObjectStore): Promise {
return promisifyRequest(store.getAllKeys() as IDBRequest);
}
-/** Insert into the store */
-export function insert(store: Store, value: Uint8Array | string, maxQueueSize: number): Promise {
+/** Insert into the end of the store */
+export function push(store: Store, value: Uint8Array | string, maxQueueSize: number): Promise {
return store(store => {
return keys(store).then(keys => {
if (keys.length >= maxQueueSize) {
@@ -63,8 +63,23 @@ 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 {
+ 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 {
+export function shift(store: Store): Promise {
return store(store => {
return keys(store).then(keys => {
if (keys.length === 0) {
@@ -79,7 +94,7 @@ export function pop(store: Store): Promise {
});
}
-export interface BrowserOfflineTransportOptions extends OfflineTransportOptions {
+export interface BrowserOfflineTransportOptions extends Omit {
/**
* Name of indexedDb database to store envelopes in
* Default: 'sentry-offline'
@@ -110,17 +125,25 @@ 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 (_) {
//
}
},
- pop: async () => {
+ shift: async () => {
try {
- const deserialized = await pop(getStore());
+ const deserialized = await shift(getStore());
if (deserialized) {
return parseEnvelope(deserialized);
}
diff --git a/packages/browser/test/unit/transports/offline.test.ts b/packages/browser/test/unit/transports/offline.test.ts
index ccc206dbdf97..ed4cd770101d 100644
--- a/packages/browser/test/unit/transports/offline.test.ts
+++ b/packages/browser/test/unit/transports/offline.test.ts
@@ -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, push, shift, unshift } from '../../../src/transports/offline';
function deleteDatabase(name: string): Promise {
return new Promise((resolve, reject) => {
@@ -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);
+ const found = await shift(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');
- const found3 = await pop(store);
- expect(found3).toEqual(new Uint8Array([1, 2, 3, 4, 5]));
+ const found2 = await shift(store);
+ expect(found2).toEqual('test2');
+ const found3 = await shift(store);
+ expect(found3).toEqual('test1');
+ const found4 = await shift(store);
+ expect(found4).toEqual(new Uint8Array([1, 2, 3, 4, 5]));
- const found4 = await pop(store);
- expect(found4).toBeUndefined();
+ const found5 = await shift(store);
+ expect(found5).toBeUndefined();
});
it('Queues and retries envelope if wrapped transport throws error', async () => {
@@ -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);
diff --git a/packages/core/src/transports/offline.ts b/packages/core/src/transports/offline.ts
index 2e0db450ddfd..9d30b8cb34ec 100644
--- a/packages/core/src/transports/offline.ts
+++ b/packages/core/src/transports/offline.ts
@@ -7,13 +7,10 @@ export const MIN_DELAY = 100; // 100 ms
export const START_DELAY = 5_000; // 5 seconds
const MAX_DELAY = 3.6e6; // 1 hour
-function log(msg: string, error?: Error): void {
- DEBUG_BUILD && logger.info(`[Offline]: ${msg}`, error);
-}
-
export interface OfflineStore {
- insert(env: Envelope): Promise;
- pop(): Promise;
+ push(env: Envelope): Promise;
+ unshift(env: Envelope): Promise;
+ shift(): Promise;
}
export type CreateOfflineStore = (options: OfflineTransportOptions) => OfflineStore;
@@ -53,19 +50,25 @@ type Timer = number | { unref?: () => void };
export function makeOfflineTransport(
createTransport: (options: TO) => Transport,
): (options: TO & OfflineTransportOptions) => Transport {
+ function log(...args: unknown[]): void {
+ DEBUG_BUILD && logger.info('[Offline]:', ...args);
+ }
+
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 {
- // 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;
}
@@ -77,10 +80,6 @@ export function makeOfflineTransport(
}
function flushIn(delay: number): void {
- if (!store) {
- return;
- }
-
if (flushTimer) {
clearTimeout(flushTimer as ReturnType);
}
@@ -88,10 +87,14 @@ export function makeOfflineTransport(
flushTimer = setTimeout(async () => {
flushTimer = undefined;
- const found = await store.pop();
+ const found = await store.shift();
if (found) {
log('Attempting to send previously queued event');
- void send(found).catch(e => {
+
+ // We should to update the sent_at timestamp to the current time.
+ found[0].sent_at = new Date().toISOString();
+
+ void send(found, true).catch(e => {
log('Failed to retry sending', e);
});
}
@@ -113,7 +116,15 @@ export function makeOfflineTransport(
retryDelay = Math.min(retryDelay * 2, MAX_DELAY);
}
- async function send(envelope: Envelope): Promise {
+ async function send(envelope: Envelope, isRetry: boolean = false): Promise {
+ // 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);
@@ -123,6 +134,8 @@ export function makeOfflineTransport(
// If there's a retry-after header, use that as the next delay.
if (result.headers && result.headers['retry-after']) {
delay = parseRetryAfterHeader(result.headers['retry-after']);
+ } else if (result.headers && result.headers['x-sentry-rate-limits']) {
+ delay = 60_000; // 60 seconds
} // If we have a server error, return now so we don't flush the queue.
else if ((result.statusCode || 0) >= 400) {
return result;
@@ -133,10 +146,15 @@ export function makeOfflineTransport(
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);
+ log('Error sending. Event queued.', e as Error);
return {};
} else {
throw e;
diff --git a/packages/core/test/lib/transports/offline.test.ts b/packages/core/test/lib/transports/offline.test.ts
index 7e87115b9cdb..2368f61d5892 100644
--- a/packages/core/test/lib/transports/offline.test.ts
+++ b/packages/core/test/lib/transports/offline.test.ts
@@ -6,7 +6,6 @@ import type {
InternalBaseTransportOptions,
ReplayEnvelope,
ReplayEvent,
- Transport,
TransportMakeRequestResponse,
} from '@sentry/types';
import {
@@ -15,6 +14,7 @@ import {
createEventEnvelopeHeaders,
dsnFromString,
getSdkMetadataForEnvelopeHeader,
+ parseEnvelope,
} from '@sentry/utils';
import { createTransport } from '../../../src';
@@ -25,34 +25,40 @@ const ERROR_ENVELOPE = createEnvelope({ event_id: 'aa3ff046696b4b
[{ type: 'event' }, { event_id: 'aa3ff046696b4bc6b609ce6d28fde9e2' }] as EventItem,
]);
-const REPLAY_EVENT: ReplayEvent = {
- type: 'replay_event',
- timestamp: 1670837008.634,
- error_ids: ['errorId'],
- trace_ids: ['traceId'],
- urls: ['https://example.com'],
- replay_id: 'MY_REPLAY_ID',
- segment_id: 3,
- replay_type: 'buffer',
-};
+function REPLAY_EVENT(message: string): ReplayEvent {
+ return {
+ type: 'replay_event',
+ timestamp: 1670837008.634,
+ error_ids: ['errorId'],
+ trace_ids: ['traceId'],
+ urls: ['https://example.com'],
+ replay_id: 'MY_REPLAY_ID',
+ segment_id: 3,
+ replay_type: 'buffer',
+ message,
+ };
+}
const DSN = dsnFromString('https://public@dsn.ingest.sentry.io/1337')!;
const DATA = 'nothing';
-const RELAY_ENVELOPE = createEnvelope(
- createEventEnvelopeHeaders(REPLAY_EVENT, getSdkMetadataForEnvelopeHeader(REPLAY_EVENT), undefined, DSN),
- [
- [{ type: 'replay_event' }, REPLAY_EVENT],
+function REPLAY_ENVELOPE(message: string) {
+ const event = REPLAY_EVENT(message);
+ return createEnvelope(
+ createEventEnvelopeHeaders(event, getSdkMetadataForEnvelopeHeader(event), undefined, DSN),
[
- {
- type: 'replay_recording',
- length: DATA.length,
- },
- DATA,
+ [{ type: 'replay_event' }, event],
+ [
+ {
+ type: 'replay_recording',
+ length: DATA.length,
+ },
+ DATA,
+ ],
],
- ],
-);
+ );
+}
const DEFAULT_DISCARDED_EVENTS: ClientReport['discarded_events'] = [
{
@@ -79,22 +85,21 @@ const transportOptions = {
type MockResult = T | Error;
-const createTestTransport = (
- ...sendResults: MockResult[]
-): { getSendCount: () => number; baseTransport: (options: InternalBaseTransportOptions) => Transport } => {
- let sendCount = 0;
+const createTestTransport = (...sendResults: MockResult[]) => {
+ const sentEnvelopes: (string | Uint8Array)[] = [];
return {
- getSendCount: () => sendCount,
+ getSentEnvelopes: () => sentEnvelopes,
+ getSendCount: () => sentEnvelopes.length,
baseTransport: (options: InternalBaseTransportOptions) =>
- createTransport(options, () => {
+ createTransport(options, ({ body }) => {
return new Promise((resolve, reject) => {
const next = sendResults.shift();
if (next instanceof Error) {
reject(next);
} else {
- sendCount += 1;
+ sentEnvelopes.push(body);
resolve(next as TransportMakeRequestResponse);
}
});
@@ -102,7 +107,7 @@ const createTestTransport = (
};
};
-type StoreEvents = ('add' | 'pop')[];
+type StoreEvents = ('push' | 'unshift' | 'shift')[];
function createTestStore(...popResults: MockResult[]): {
getCalls: () => StoreEvents;
@@ -113,14 +118,20 @@ function createTestStore(...popResults: MockResult[]): {
return {
getCalls: () => calls,
store: (_: OfflineTransportOptions) => ({
- insert: async env => {
+ push: async env => {
if (popResults.length < 30) {
popResults.push(env);
- calls.push('add');
+ calls.push('push');
}
},
- pop: async () => {
- calls.push('pop');
+ unshift: async env => {
+ if (popResults.length < 30) {
+ popResults.unshift(env);
+ calls.push('unshift');
+ }
+ },
+ shift: async () => {
+ calls.push('shift');
const next = popResults.shift();
if (next instanceof Error) {
@@ -129,6 +140,7 @@ function createTestStore(...popResults: MockResult[]): {
return next;
},
+ count: async () => popResults.length,
}),
};
}
@@ -170,10 +182,10 @@ describe('makeOfflineTransport', () => {
await waitUntil(() => getCalls().length == 1, 1_000);
// After a successful send, the store should be checked
- expect(getCalls()).toEqual(['pop']);
+ expect(getCalls()).toEqual(['shift']);
});
- it('After successfully sending, sends further envelopes found in the store', async () => {
+ it('Envelopes are added after existing envelopes in the queue', async () => {
const { getCalls, store } = createTestStore(ERROR_ENVELOPE);
const { getSendCount, baseTransport } = createTestTransport({ statusCode: 200 }, { statusCode: 200 });
const transport = makeOfflineTransport(baseTransport)({ ...transportOptions, createStore: store });
@@ -185,7 +197,7 @@ describe('makeOfflineTransport', () => {
expect(getSendCount()).toEqual(2);
// After a successful send from the store, the store should be checked again to ensure it's empty
- expect(getCalls()).toEqual(['pop', 'pop']);
+ expect(getCalls()).toEqual(['shift', 'shift']);
});
it('Queues envelope if wrapped transport throws error', async () => {
@@ -208,7 +220,7 @@ describe('makeOfflineTransport', () => {
expect(getSendCount()).toEqual(0);
expect(queuedCount).toEqual(1);
- expect(getCalls()).toEqual(['add']);
+ expect(getCalls()).toEqual(['push']);
});
it('Does not queue envelopes if status code >= 400', async () => {
@@ -242,18 +254,18 @@ describe('makeOfflineTransport', () => {
const transport = makeOfflineTransport(baseTransport)({ ...transportOptions, createStore: store });
const result = await transport.send(ERROR_ENVELOPE);
expect(result).toEqual({});
- expect(getCalls()).toEqual(['add']);
+ expect(getCalls()).toEqual(['push']);
await waitUntil(() => getCalls().length === 3 && getSendCount() === 1, START_DELAY * 2);
expect(getSendCount()).toEqual(1);
- expect(getCalls()).toEqual(['add', 'pop', 'pop']);
+ expect(getCalls()).toEqual(['push', 'shift', 'shift']);
},
START_DELAY + 2_000,
);
it(
- 'When enabled, sends envelopes found in store shortly after startup',
+ 'When flushAtStartup is enabled, sends envelopes found in store shortly after startup',
async () => {
const { getCalls, store } = createTestStore(ERROR_ENVELOPE, ERROR_ENVELOPE);
const { getSendCount, baseTransport } = createTestTransport({ statusCode: 200 }, { statusCode: 200 });
@@ -267,7 +279,58 @@ describe('makeOfflineTransport', () => {
await waitUntil(() => getCalls().length === 3 && getSendCount() === 2, START_DELAY * 2);
expect(getSendCount()).toEqual(2);
- expect(getCalls()).toEqual(['pop', 'pop', 'pop']);
+ expect(getCalls()).toEqual(['shift', 'shift', 'shift']);
+ },
+ START_DELAY + 2_000,
+ );
+
+ it(
+ 'Unshifts envelopes on retry failure',
+ async () => {
+ const { getCalls, store } = createTestStore(ERROR_ENVELOPE);
+ const { getSendCount, baseTransport } = createTestTransport(new Error(), { statusCode: 200 });
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
+ const _transport = makeOfflineTransport(baseTransport)({
+ ...transportOptions,
+ createStore: store,
+ flushAtStartup: true,
+ });
+
+ await waitUntil(() => getCalls().length === 2, START_DELAY * 2);
+
+ expect(getSendCount()).toEqual(0);
+ expect(getCalls()).toEqual(['shift', 'unshift']);
+ },
+ START_DELAY + 2_000,
+ );
+
+ it(
+ 'Updates sent_at envelope header on retry',
+ async () => {
+ const testStartTime = new Date();
+
+ // Create an envelope with a sent_at header very far in the past
+ const env: EventEnvelope = [...ERROR_ENVELOPE];
+ env[0].sent_at = new Date(2020, 1, 1).toISOString();
+
+ const { getCalls, store } = createTestStore(ERROR_ENVELOPE);
+ const { getSentEnvelopes, baseTransport } = createTestTransport({ statusCode: 200 });
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
+ const _transport = makeOfflineTransport(baseTransport)({
+ ...transportOptions,
+ createStore: store,
+ flushAtStartup: true,
+ });
+
+ await waitUntil(() => getCalls().length >= 1, START_DELAY * 2);
+ expect(getCalls()).toEqual(['shift']);
+
+ // When it gets shifted out of the store, the sent_at header should be updated
+ const envelopes = getSentEnvelopes().map(parseEnvelope) as EventEnvelope[];
+ expect(envelopes[0][0]).toBeDefined();
+ const sent_at = new Date(envelopes[0][0].sent_at);
+
+ expect(sent_at.getTime()).toBeGreaterThan(testStartTime.getTime());
},
START_DELAY + 2_000,
);
@@ -289,23 +352,6 @@ describe('makeOfflineTransport', () => {
expect(getCalls()).toEqual([]);
});
- it('should not store Relay envelopes on send failure', async () => {
- const { getCalls, store } = createTestStore();
- const { getSendCount, baseTransport } = createTestTransport(new Error());
- const queuedCount = 0;
- const transport = makeOfflineTransport(baseTransport)({
- ...transportOptions,
- createStore: store,
- shouldStore: () => true,
- });
- const result = transport.send(RELAY_ENVELOPE);
-
- await expect(result).rejects.toBeInstanceOf(Error);
- expect(queuedCount).toEqual(0);
- expect(getSendCount()).toEqual(0);
- expect(getCalls()).toEqual([]);
- });
-
it('should not store client report envelopes on send failure', async () => {
const { getCalls, store } = createTestStore();
const { getSendCount, baseTransport } = createTestTransport(new Error());
@@ -323,6 +369,47 @@ describe('makeOfflineTransport', () => {
expect(getCalls()).toEqual([]);
});
+ it(
+ 'Sends replay envelopes in order',
+ async () => {
+ const { getCalls, store } = createTestStore(REPLAY_ENVELOPE('1'), REPLAY_ENVELOPE('2'));
+ const { getSendCount, getSentEnvelopes, baseTransport } = createTestTransport(
+ new Error(),
+ { statusCode: 200 },
+ { statusCode: 200 },
+ { statusCode: 200 },
+ );
+ const transport = makeOfflineTransport(baseTransport)({ ...transportOptions, createStore: store });
+ const result = await transport.send(REPLAY_ENVELOPE('3'));
+
+ expect(result).toEqual({});
+ expect(getCalls()).toEqual(['push']);
+
+ await waitUntil(() => getCalls().length === 6 && getSendCount() === 3, START_DELAY * 5);
+
+ expect(getSendCount()).toEqual(3);
+ expect(getCalls()).toEqual([
+ // We're sending a replay envelope and they always get queued
+ 'push',
+ // The first envelope popped out fails to send so it gets added to the front of the queue
+ 'shift',
+ 'unshift',
+ // The rest of the attempts succeed
+ 'shift',
+ 'shift',
+ 'shift',
+ ]);
+
+ const envelopes = getSentEnvelopes().map(parseEnvelope);
+
+ // Ensure they're still in the correct order
+ expect((envelopes[0][1][0][1] as ErrorEvent).message).toEqual('1');
+ expect((envelopes[1][1][0][1] as ErrorEvent).message).toEqual('2');
+ expect((envelopes[2][1][0][1] as ErrorEvent).message).toEqual('3');
+ },
+ START_DELAY + 2_000,
+ );
+
// eslint-disable-next-line jest/no-disabled-tests
it.skip(
'Follows the Retry-After header',
@@ -360,7 +447,7 @@ describe('makeOfflineTransport', () => {
expect(getSendCount()).toEqual(2);
expect(queuedCount).toEqual(0);
- expect(getCalls()).toEqual(['pop', 'pop']);
+ expect(getCalls()).toEqual(['shift', 'shift']);
},
START_DELAY * 3,
);