Skip to content

Commit b539b36

Browse files
authored
feat(browser): Add IndexedDb offline transport store (#6983)
1 parent 6462b00 commit b539b36

File tree

11 files changed

+423
-12
lines changed

11 files changed

+423
-12
lines changed

packages/browser/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
"btoa": "^1.2.1",
2828
"chai": "^4.1.2",
2929
"chokidar": "^3.0.2",
30+
"fake-indexeddb": "^4.0.1",
3031
"karma": "^6.3.16",
3132
"karma-chai": "^0.1.0",
3233
"karma-chrome-launcher": "^2.2.0",

packages/browser/src/index.ts

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -21,10 +21,14 @@ const INTEGRATIONS = {
2121
export { INTEGRATIONS as Integrations };
2222

2323
// DO NOT DELETE THESE COMMENTS!
24-
// We want to exclude Replay from CDN bundles, so we remove the block below with our
25-
// excludeReplay Rollup plugin when generating bundles. Everything between
26-
// ROLLUP_EXCLUDE_FROM_BUNDLES_BEGIN and _END__ is removed for bundles.
24+
// We want to exclude Replay/Offline from CDN bundles, so we remove the block below with our
25+
// makeExcludeBlockPlugin Rollup plugin when generating bundles. Everything between
26+
// ROLLUP_EXCLUDE_*_FROM_BUNDLES_BEGIN and _END__ is removed for bundles.
2727

28-
// __ROLLUP_EXCLUDE_FROM_BUNDLES_BEGIN__
28+
// __ROLLUP_EXCLUDE_REPLAY_FROM_BUNDLES_BEGIN__
2929
export { Replay } from '@sentry/replay';
30-
// __ROLLUP_EXCLUDE_FROM_BUNDLES_END__
30+
// __ROLLUP_EXCLUDE_REPLAY_FROM_BUNDLES_END__
31+
32+
// __ROLLUP_EXCLUDE_OFFLINE_FROM_BUNDLES_BEGIN__
33+
export { makeBrowserOfflineTransport } from './transports/offline';
34+
// __ROLLUP_EXCLUDE_OFFLINE_FROM_BUNDLES_END__
Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
import type { OfflineStore, OfflineTransportOptions } from '@sentry/core';
2+
import { makeOfflineTransport } from '@sentry/core';
3+
import type { Envelope, InternalBaseTransportOptions, Transport } from '@sentry/types';
4+
import type { TextDecoderInternal } from '@sentry/utils';
5+
import { parseEnvelope, serializeEnvelope } from '@sentry/utils';
6+
7+
// 'Store', 'promisifyRequest' and 'createStore' were originally copied from the 'idb-keyval' package before being
8+
// modified and simplified: https://github.com/jakearchibald/idb-keyval
9+
//
10+
// At commit: 0420a704fd6cbb4225429c536b1f61112d012fca
11+
// Original licence:
12+
13+
// Copyright 2016, Jake Archibald
14+
//
15+
// Licensed under the Apache License, Version 2.0 (the "License");
16+
// you may not use this file except in compliance with the License.
17+
// You may obtain a copy of the License at
18+
//
19+
// http://www.apache.org/licenses/LICENSE-2.0
20+
//
21+
// Unless required by applicable law or agreed to in writing, software
22+
// distributed under the License is distributed on an "AS IS" BASIS,
23+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
24+
// See the License for the specific language governing permissions and
25+
// limitations under the License.
26+
27+
type Store = <T>(callback: (store: IDBObjectStore) => T | PromiseLike<T>) => Promise<T>;
28+
29+
function promisifyRequest<T = undefined>(request: IDBRequest<T> | IDBTransaction): Promise<T> {
30+
return new Promise<T>((resolve, reject) => {
31+
// @ts-ignore - file size hacks
32+
request.oncomplete = request.onsuccess = () => resolve(request.result);
33+
// @ts-ignore - file size hacks
34+
request.onabort = request.onerror = () => reject(request.error);
35+
});
36+
}
37+
38+
/** Create or open an IndexedDb store */
39+
export function createStore(dbName: string, storeName: string): Store {
40+
const request = indexedDB.open(dbName);
41+
request.onupgradeneeded = () => request.result.createObjectStore(storeName);
42+
const dbp = promisifyRequest(request);
43+
44+
return callback => dbp.then(db => callback(db.transaction(storeName, 'readwrite').objectStore(storeName)));
45+
}
46+
47+
function keys(store: IDBObjectStore): Promise<number[]> {
48+
return promisifyRequest(store.getAllKeys() as IDBRequest<number[]>);
49+
}
50+
51+
/** Insert into the store */
52+
export function insert(store: Store, value: Uint8Array | string, maxQueueSize: number): Promise<void> {
53+
return store(store => {
54+
return keys(store).then(keys => {
55+
if (keys.length >= maxQueueSize) {
56+
return;
57+
}
58+
59+
// We insert with an incremented key so that the entries are popped in order
60+
store.put(value, Math.max(...keys, 0) + 1);
61+
return promisifyRequest(store.transaction);
62+
});
63+
});
64+
}
65+
66+
/** Pop the oldest value from the store */
67+
export function pop(store: Store): Promise<Uint8Array | string | undefined> {
68+
return store(store => {
69+
return keys(store).then(keys => {
70+
if (keys.length === 0) {
71+
return undefined;
72+
}
73+
74+
return promisifyRequest(store.get(keys[0])).then(value => {
75+
store.delete(keys[0]);
76+
return promisifyRequest(store.transaction).then(() => value);
77+
});
78+
});
79+
});
80+
}
81+
82+
interface BrowserOfflineTransportOptions extends OfflineTransportOptions {
83+
/**
84+
* Name of indexedDb database to store envelopes in
85+
* Default: 'sentry-offline'
86+
*/
87+
dbName?: string;
88+
/**
89+
* Name of indexedDb object store to store envelopes in
90+
* Default: 'queue'
91+
*/
92+
storeName?: string;
93+
/**
94+
* Maximum number of envelopes to store
95+
* Default: 30
96+
*/
97+
maxQueueSize?: number;
98+
/**
99+
* Only required for testing on node.js
100+
* @ignore
101+
*/
102+
textDecoder?: TextDecoderInternal;
103+
}
104+
105+
function createIndexedDbStore(options: BrowserOfflineTransportOptions): OfflineStore {
106+
let store: Store | undefined;
107+
108+
// Lazily create the store only when it's needed
109+
function getStore(): Store {
110+
if (store == undefined) {
111+
store = createStore(options.dbName || 'sentry-offline', options.storeName || 'queue');
112+
}
113+
114+
return store;
115+
}
116+
117+
return {
118+
insert: async (env: Envelope) => {
119+
try {
120+
const serialized = await serializeEnvelope(env, options.textEncoder);
121+
await insert(getStore(), serialized, options.maxQueueSize || 30);
122+
} catch (_) {
123+
//
124+
}
125+
},
126+
pop: async () => {
127+
try {
128+
const deserialized = await pop(getStore());
129+
if (deserialized) {
130+
return parseEnvelope(
131+
deserialized,
132+
options.textEncoder || new TextEncoder(),
133+
options.textDecoder || new TextDecoder(),
134+
);
135+
}
136+
} catch (_) {
137+
//
138+
}
139+
140+
return undefined;
141+
},
142+
};
143+
}
144+
145+
function makeIndexedDbOfflineTransport<T>(
146+
createTransport: (options: T) => Transport,
147+
): (options: T & BrowserOfflineTransportOptions) => Transport {
148+
return options => createTransport({ ...options, createStore: createIndexedDbStore });
149+
}
150+
151+
/**
152+
* Creates a transport that uses IndexedDb to store events when offline.
153+
*/
154+
export function makeBrowserOfflineTransport<T extends InternalBaseTransportOptions>(
155+
createTransport: (options: T) => Transport,
156+
): (options: T & BrowserOfflineTransportOptions) => Transport {
157+
return makeIndexedDbOfflineTransport<T>(makeOfflineTransport(createTransport));
158+
}
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
import 'fake-indexeddb/auto';
2+
3+
import { createTransport } from '@sentry/core';
4+
import type {
5+
EventEnvelope,
6+
EventItem,
7+
InternalBaseTransportOptions,
8+
TransportMakeRequestResponse,
9+
} from '@sentry/types';
10+
import { createEnvelope } from '@sentry/utils';
11+
import { TextDecoder, TextEncoder } from 'util';
12+
13+
import { MIN_DELAY } from '../../../../core/src/transports/offline';
14+
import { createStore, insert, makeBrowserOfflineTransport, pop } from '../../../src/transports/offline';
15+
16+
function deleteDatabase(name: string): Promise<void> {
17+
return new Promise<void>((resolve, reject) => {
18+
const request = indexedDB.deleteDatabase(name);
19+
request.onsuccess = () => resolve();
20+
request.onerror = () => reject(request.error);
21+
});
22+
}
23+
24+
const ERROR_ENVELOPE = createEnvelope<EventEnvelope>({ event_id: 'aa3ff046696b4bc6b609ce6d28fde9e2', sent_at: '123' }, [
25+
[{ type: 'event' }, { event_id: 'aa3ff046696b4bc6b609ce6d28fde9e2' }] as EventItem,
26+
]);
27+
28+
const transportOptions = {
29+
recordDroppedEvent: () => undefined, // noop
30+
textEncoder: new TextEncoder(),
31+
textDecoder: new TextDecoder(),
32+
};
33+
34+
type MockResult<T> = T | Error;
35+
36+
export const createTestTransport = (...sendResults: MockResult<TransportMakeRequestResponse>[]) => {
37+
let sendCount = 0;
38+
39+
return {
40+
getSendCount: () => sendCount,
41+
baseTransport: (options: InternalBaseTransportOptions) =>
42+
createTransport(options, () => {
43+
return new Promise((resolve, reject) => {
44+
const next = sendResults.shift();
45+
46+
if (next instanceof Error) {
47+
reject(next);
48+
} else {
49+
sendCount += 1;
50+
resolve(next as TransportMakeRequestResponse | undefined);
51+
}
52+
});
53+
}),
54+
};
55+
};
56+
57+
function delay(ms: number): Promise<void> {
58+
return new Promise(resolve => setTimeout(resolve, ms));
59+
}
60+
61+
describe('makeOfflineTransport', () => {
62+
beforeAll(async () => {
63+
await deleteDatabase('sentry');
64+
});
65+
66+
it('indexedDb wrappers insert and pop', async () => {
67+
const store = createStore('test', 'test');
68+
const found = await pop(store);
69+
expect(found).toBeUndefined();
70+
71+
await insert(store, 'test1', 30);
72+
await insert(store, new Uint8Array([1, 2, 3, 4, 5]), 30);
73+
74+
const found2 = await pop(store);
75+
expect(found2).toEqual('test1');
76+
const found3 = await pop(store);
77+
expect(found3).toEqual(new Uint8Array([1, 2, 3, 4, 5]));
78+
79+
const found4 = await pop(store);
80+
expect(found4).toBeUndefined();
81+
});
82+
83+
it('Queues and retries envelope if wrapped transport throws error', async () => {
84+
const { getSendCount, baseTransport } = createTestTransport(new Error(), { statusCode: 200 }, { statusCode: 200 });
85+
let queuedCount = 0;
86+
const transport = makeBrowserOfflineTransport(baseTransport)({
87+
...transportOptions,
88+
shouldStore: () => {
89+
queuedCount += 1;
90+
return true;
91+
},
92+
});
93+
const result = await transport.send(ERROR_ENVELOPE);
94+
95+
expect(result).toEqual({});
96+
97+
await delay(MIN_DELAY * 2);
98+
99+
expect(getSendCount()).toEqual(0);
100+
expect(queuedCount).toEqual(1);
101+
102+
// Sending again will retry the queued envelope too
103+
const result2 = await transport.send(ERROR_ENVELOPE);
104+
expect(result2).toEqual({ statusCode: 200 });
105+
106+
await delay(MIN_DELAY * 2);
107+
108+
expect(queuedCount).toEqual(1);
109+
expect(getSendCount()).toEqual(2);
110+
});
111+
});
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
import * as Sentry from '@sentry/browser';
2+
3+
window.Sentry = Sentry;
4+
5+
Sentry.init({
6+
dsn: 'https://[email protected]/1337',
7+
transport: Sentry.makeBrowserOfflineTransport(Sentry.makeFetchTransport),
8+
});
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
setTimeout(() => {
2+
Sentry.captureMessage(`foo ${Math.random()}`);
3+
}, 500);
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import { expect } from '@playwright/test';
2+
import type { Event } from '@sentry/types';
3+
4+
import { sentryTest } from '../../../../utils/fixtures';
5+
import { getMultipleSentryEnvelopeRequests } from '../../../../utils/helpers';
6+
7+
function delay(ms: number) {
8+
return new Promise(resolve => setTimeout(resolve, ms));
9+
}
10+
11+
sentryTest('should queue and retry events when they fail to send', async ({ getLocalTestPath, page }) => {
12+
// makeBrowserOfflineTransport is not included in any CDN bundles
13+
if (process.env.PW_BUNDLE && process.env.PW_BUNDLE.startsWith('bundle_')) {
14+
sentryTest.skip();
15+
}
16+
17+
const url = await getLocalTestPath({ testDir: __dirname });
18+
19+
// This would be the obvious way to test offline support but it doesn't appear to work!
20+
// await context.setOffline(true);
21+
22+
let abortedCount = 0;
23+
24+
// Abort all envelope requests so the event gets queued
25+
await page.route(/ingest\.sentry\.io/, route => {
26+
abortedCount += 1;
27+
return route.abort();
28+
});
29+
await page.goto(url);
30+
await delay(1_000);
31+
await page.unroute(/ingest\.sentry\.io/);
32+
33+
expect(abortedCount).toBe(1);
34+
35+
// The previous event should now be queued
36+
37+
// This will force the page to be reloaded and a new event to be sent
38+
const eventData = await getMultipleSentryEnvelopeRequests<Event>(page, 3, { url, timeout: 10_000 });
39+
40+
// Filter out any client reports
41+
const events = eventData.filter(e => !('discarded_events' in e)) as Event[];
42+
43+
expect(events).toHaveLength(2);
44+
45+
// The next two events will be message events starting with 'foo'
46+
expect(events[0].message?.startsWith('foo'));
47+
expect(events[1].message?.startsWith('foo'));
48+
49+
// But because these are two different events, they should have different random numbers in the message
50+
expect(events[0].message !== events[1].message);
51+
});

packages/utils/src/envelope.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -115,7 +115,7 @@ function concatBuffers(buffers: Uint8Array[]): Uint8Array {
115115
return merged;
116116
}
117117

118-
interface TextDecoderInternal {
118+
export interface TextDecoderInternal {
119119
decode(input?: Uint8Array): string;
120120
}
121121

0 commit comments

Comments
 (0)