Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
1 change: 1 addition & 0 deletions packages/browser/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
"btoa": "^1.2.1",
"chai": "^4.1.2",
"chokidar": "^3.0.2",
"fake-indexeddb": "^4.0.1",
"karma": "^6.3.16",
"karma-chai": "^0.1.0",
"karma-chrome-launcher": "^2.2.0",
Expand Down
14 changes: 9 additions & 5 deletions packages/browser/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,14 @@ const INTEGRATIONS = {
export { INTEGRATIONS as Integrations };

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

// __ROLLUP_EXCLUDE_FROM_BUNDLES_BEGIN__
// __ROLLUP_EXCLUDE_REPLAY_FROM_BUNDLES_BEGIN__
export { Replay } from '@sentry/replay';
// __ROLLUP_EXCLUDE_FROM_BUNDLES_END__
// __ROLLUP_EXCLUDE_REPLAY_FROM_BUNDLES_END__

// __ROLLUP_EXCLUDE_OFFLINE_FROM_BUNDLES_BEGIN__
export { makeBrowserOfflineTransport } from './transports/offline';
// __ROLLUP_EXCLUDE_OFFLINE_FROM_BUNDLES_END__
158 changes: 158 additions & 0 deletions packages/browser/src/transports/offline.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
import type { OfflineStore, OfflineTransportOptions } from '@sentry/core';
import { makeOfflineTransport } from '@sentry/core';
import type { Envelope, InternalBaseTransportOptions, Transport } from '@sentry/types';
import type { TextDecoderInternal } from '@sentry/utils';
import { parseEnvelope, serializeEnvelope } from '@sentry/utils';

// 'Store', 'promisifyRequest' and 'createStore' were originally copied from the 'idb-keyval' package before being
// modified and simplified: https://github.com/jakearchibald/idb-keyval
//
// At commit: 0420a704fd6cbb4225429c536b1f61112d012fca
// Original licence:

// Copyright 2016, Jake Archibald
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

type Store = <T>(callback: (store: IDBObjectStore) => T | PromiseLike<T>) => Promise<T>;

function promisifyRequest<T = undefined>(request: IDBRequest<T> | IDBTransaction): Promise<T> {
return new Promise<T>((resolve, reject) => {
// @ts-ignore - file size hacks
request.oncomplete = request.onsuccess = () => resolve(request.result);
// @ts-ignore - file size hacks
request.onabort = request.onerror = () => reject(request.error);
});
}

/** Create or open an IndexedDb store */
export function createStore(dbName: string, storeName: string): Store {
const request = indexedDB.open(dbName);
request.onupgradeneeded = () => request.result.createObjectStore(storeName);
const dbp = promisifyRequest(request);

return callback => dbp.then(db => callback(db.transaction(storeName, 'readwrite').objectStore(storeName)));
}

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> {
return store(store => {
return keys(store).then(keys => {
if (keys.length >= maxQueueSize) {
return;
}

// We insert with an incremented key so that the entries are popped in order
store.put(value, Math.max(...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 => {
return keys(store).then(keys => {
if (keys.length === 0) {
return undefined;
}

return promisifyRequest(store.get(keys[0])).then(value => {
store.delete(keys[0]);
return promisifyRequest(store.transaction).then(() => value);
});
});
});
}

interface BrowserOfflineTransportOptions extends OfflineTransportOptions {
/**
* Name of indexedDb database to store envelopes in
* Default: 'sentry-offline'
*/
dbName?: string;
/**
* Name of indexedDb object store to store envelopes in
* Default: 'queue'
*/
storeName?: string;
/**
* Maximum number of envelopes to store
* Default: 30
*/
maxQueueSize?: number;
/**
* Only required for testing on node.js
* @ignore
*/
textDecoder?: TextDecoderInternal;
}

function createIndexedDbStore(options: BrowserOfflineTransportOptions): OfflineStore {
let store: Store | undefined;

// Lazily create the store only when it's needed
function getStore(): Store {
if (store == undefined) {
store = createStore(options.dbName || 'sentry-offline', options.storeName || 'queue');
}

return store;
}

return {
insert: async (env: Envelope) => {
try {
const serialized = await serializeEnvelope(env, options.textEncoder);
await insert(getStore(), serialized, options.maxQueueSize || 30);
} catch (_) {
//
}
},
pop: async () => {
try {
const deserialized = await pop(getStore());
if (deserialized) {
return parseEnvelope(
deserialized,
options.textEncoder || new TextEncoder(),
options.textDecoder || new TextDecoder(),
);
}
} catch (_) {
//
}

return undefined;
},
};
}

function makeIndexedDbOfflineTransport<T>(
createTransport: (options: T) => Transport,
): (options: T & BrowserOfflineTransportOptions) => Transport {
return options => createTransport({ ...options, createStore: createIndexedDbStore });
}

/**
* Creates a transport that uses IndexedDb to store events when offline.
*/
export function makeBrowserOfflineTransport<T extends InternalBaseTransportOptions>(
createTransport: (options: T) => Transport,
): (options: T & BrowserOfflineTransportOptions) => Transport {
return makeIndexedDbOfflineTransport<T>(makeOfflineTransport(createTransport));
}
111 changes: 111 additions & 0 deletions packages/browser/test/unit/transports/offline.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
import 'fake-indexeddb/auto';

import { createTransport } from '@sentry/core';
import type {
EventEnvelope,
EventItem,
InternalBaseTransportOptions,
TransportMakeRequestResponse,
} from '@sentry/types';
import { createEnvelope } from '@sentry/utils';
import { TextDecoder, TextEncoder } from 'util';

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

function deleteDatabase(name: string): Promise<void> {
return new Promise<void>((resolve, reject) => {
const request = indexedDB.deleteDatabase(name);
request.onsuccess = () => resolve();
request.onerror = () => reject(request.error);
});
}

const ERROR_ENVELOPE = createEnvelope<EventEnvelope>({ event_id: 'aa3ff046696b4bc6b609ce6d28fde9e2', sent_at: '123' }, [
[{ type: 'event' }, { event_id: 'aa3ff046696b4bc6b609ce6d28fde9e2' }] as EventItem,
]);

const transportOptions = {
recordDroppedEvent: () => undefined, // noop
textEncoder: new TextEncoder(),
textDecoder: new TextDecoder(),
};

type MockResult<T> = T | Error;

export const createTestTransport = (...sendResults: MockResult<TransportMakeRequestResponse>[]) => {
let sendCount = 0;

return {
getSendCount: () => sendCount,
baseTransport: (options: InternalBaseTransportOptions) =>
createTransport(options, () => {
return new Promise((resolve, reject) => {
const next = sendResults.shift();

if (next instanceof Error) {
reject(next);
} else {
sendCount += 1;
resolve(next as TransportMakeRequestResponse | undefined);
}
});
}),
};
};

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

describe('makeOfflineTransport', () => {
beforeAll(async () => {
await deleteDatabase('sentry');
});

it('indexedDb wrappers insert 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);

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 found4 = await pop(store);
expect(found4).toBeUndefined();
});

it('Queues and retries envelope if wrapped transport throws error', async () => {
const { getSendCount, baseTransport } = createTestTransport(new Error(), { statusCode: 200 }, { statusCode: 200 });
let queuedCount = 0;
const transport = makeBrowserOfflineTransport(baseTransport)({
...transportOptions,
shouldStore: () => {
queuedCount += 1;
return true;
},
});
const result = await transport.send(ERROR_ENVELOPE);

expect(result).toEqual({});

await delay(MIN_DELAY * 2);

expect(getSendCount()).toEqual(0);
expect(queuedCount).toEqual(1);

// Sending again will retry the queued envelope too
const result2 = await transport.send(ERROR_ENVELOPE);
expect(result2).toEqual({ statusCode: 200 });

await delay(MIN_DELAY * 2);

expect(queuedCount).toEqual(1);
expect(getSendCount()).toEqual(2);
});
});
8 changes: 8 additions & 0 deletions packages/integration-tests/suites/transport/offline/init.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import * as Sentry from '@sentry/browser';

window.Sentry = Sentry;

Sentry.init({
dsn: 'https://[email protected]/1337',
transport: Sentry.makeBrowserOfflineTransport(Sentry.makeFetchTransport),
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
setTimeout(() => {
Sentry.captureMessage(`foo ${Math.random()}`);
}, 500);
51 changes: 51 additions & 0 deletions packages/integration-tests/suites/transport/offline/queued/test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { expect } from '@playwright/test';
import type { Event } from '@sentry/types';

import { sentryTest } from '../../../../utils/fixtures';
import { getMultipleSentryEnvelopeRequests } from '../../../../utils/helpers';

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

sentryTest('should queue and retry events when they fail to send', async ({ getLocalTestPath, page }) => {
// makeBrowserOfflineTransport is not included in any CDN bundles
if (process.env.PW_BUNDLE && process.env.PW_BUNDLE.startsWith('bundle_')) {
sentryTest.skip();
}

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 all envelope requests 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);

// The previous event should now be queued

// This will force the page to be reloaded and a new event to be sent
const eventData = await getMultipleSentryEnvelopeRequests<Event>(page, 3, { url, timeout: 10_000 });

// Filter out any client reports
const events = eventData.filter(e => !('discarded_events' in e)) as Event[];

expect(events).toHaveLength(2);

// The next two events will be message events starting with 'foo'
expect(events[0].message?.startsWith('foo'));
expect(events[1].message?.startsWith('foo'));

// But because these are two different events, they should have different random numbers in the message
expect(events[0].message !== events[1].message);
});
2 changes: 1 addition & 1 deletion packages/utils/src/envelope.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,7 @@ function concatBuffers(buffers: Uint8Array[]): Uint8Array {
return merged;
}

interface TextDecoderInternal {
export interface TextDecoderInternal {
decode(input?: Uint8Array): string;
}

Expand Down
Loading