diff --git a/packages/integrations/package.json b/packages/integrations/package.json index df339367f7f5..f857a392e7c3 100644 --- a/packages/integrations/package.json +++ b/packages/integrations/package.json @@ -18,6 +18,7 @@ "dependencies": { "@sentry/types": "5.21.4", "@sentry/utils": "5.21.4", + "localforage": "^1.8.1", "tslib": "^1.9.3" }, "devDependencies": { diff --git a/packages/integrations/src/index.ts b/packages/integrations/src/index.ts index c1e8d69794ad..f1ba52e92026 100644 --- a/packages/integrations/src/index.ts +++ b/packages/integrations/src/index.ts @@ -4,6 +4,7 @@ export { Debug } from './debug'; export { Dedupe } from './dedupe'; export { Ember } from './ember'; export { ExtraErrorData } from './extraerrordata'; +export { Offline } from './offline'; export { ReportingObserver } from './reportingobserver'; export { RewriteFrames } from './rewriteframes'; export { SessionTiming } from './sessiontiming'; diff --git a/packages/integrations/src/offline.ts b/packages/integrations/src/offline.ts new file mode 100644 index 000000000000..cd8c7ff9fa55 --- /dev/null +++ b/packages/integrations/src/offline.ts @@ -0,0 +1,169 @@ +import { Event, EventProcessor, Hub, Integration } from '@sentry/types'; +import { getGlobalObject, logger, uuid4 } from '@sentry/utils'; +// @ts-ignore: Module '"localforage"' has no default export. +import localforage from 'localforage'; + +/** + * cache offline errors and send when connected + */ +export class Offline implements Integration { + /** + * @inheritDoc + */ + public static id: string = 'Offline'; + + /** + * @inheritDoc + */ + public readonly name: string = Offline.id; + + /** + * the global instance + */ + public global: Window; + + /** + * the current hub instance + */ + public hub?: Hub; + + /** + * maximum number of events to store while offline + */ + public maxStoredEvents: number; + + /** + * event cache + */ + public offlineEventStore: LocalForage; // type imported from localforage + + /** + * @inheritDoc + */ + public constructor(options: { maxStoredEvents?: number } = {}) { + this.global = getGlobalObject(); + this.maxStoredEvents = options.maxStoredEvents || 30; // set a reasonable default + this.offlineEventStore = localforage.createInstance({ + name: 'sentry/offlineEventStore', + }); + + if ('addEventListener' in this.global) { + this.global.addEventListener('online', () => { + this._sendEvents().catch(() => { + logger.warn('could not send cached events'); + }); + }); + } + } + + /** + * @inheritDoc + */ + public setupOnce(addGlobalEventProcessor: (callback: EventProcessor) => void, getCurrentHub: () => Hub): void { + this.hub = getCurrentHub(); + + addGlobalEventProcessor((event: Event) => { + if (this.hub && this.hub.getIntegration(Offline)) { + // cache if we are positively offline + if ('navigator' in this.global && 'onLine' in this.global.navigator && !this.global.navigator.onLine) { + this._cacheEvent(event) + .then((_event: Event): Promise => this._enforceMaxEvents()) + .catch( + (_error): void => { + logger.warn('could not cache event while offline'); + }, + ); + + // return null on success or failure, because being offline will still result in an error + return null; + } + } + + return event; + }); + + // if online now, send any events stored in a previous offline session + if ('navigator' in this.global && 'onLine' in this.global.navigator && this.global.navigator.onLine) { + this._sendEvents().catch(() => { + logger.warn('could not send cached events'); + }); + } + } + + /** + * cache an event to send later + * @param event an event + */ + private async _cacheEvent(event: Event): Promise { + return this.offlineEventStore.setItem(uuid4(), event); + } + + /** + * purge excess events if necessary + */ + private async _enforceMaxEvents(): Promise { + const events: Array<{ event: Event; cacheKey: string }> = []; + + return this.offlineEventStore + .iterate( + (event: Event, cacheKey: string, _index: number): void => { + // aggregate events + events.push({ cacheKey, event }); + }, + ) + .then( + (): Promise => + // this promise resolves when the iteration is finished + this._purgeEvents( + // purge all events past maxStoredEvents in reverse chronological order + events + .sort((a, b) => (b.event.timestamp || 0) - (a.event.timestamp || 0)) + .slice(this.maxStoredEvents < events.length ? this.maxStoredEvents : events.length) + .map(event => event.cacheKey), + ), + ) + .catch( + (_error): void => { + logger.warn('could not enforce max events'); + }, + ); + } + + /** + * purge event from cache + */ + private async _purgeEvent(cacheKey: string): Promise { + return this.offlineEventStore.removeItem(cacheKey); + } + + /** + * purge events from cache + */ + private async _purgeEvents(cacheKeys: string[]): Promise { + // trail with .then to ensure the return type as void and not void|void[] + return Promise.all(cacheKeys.map(cacheKey => this._purgeEvent(cacheKey))).then(); + } + + /** + * send all events + */ + private async _sendEvents(): Promise { + return this.offlineEventStore.iterate( + (event: Event, cacheKey: string, _index: number): void => { + if (this.hub) { + const newEventId = this.hub.captureEvent(event); + + if (newEventId) { + this._purgeEvent(cacheKey).catch( + (_error): void => { + logger.warn('could not purge event from cache'); + }, + ); + } + } else { + logger.warn('no hub found - could not send cached event'); + } + }, + ); + } +} diff --git a/packages/integrations/test/offline.test.ts b/packages/integrations/test/offline.test.ts new file mode 100644 index 000000000000..b88f24f5b339 --- /dev/null +++ b/packages/integrations/test/offline.test.ts @@ -0,0 +1,217 @@ +import { Event, EventProcessor, Hub, Integration } from '@sentry/types'; +import * as utils from '@sentry/utils'; + +import { Offline } from '../src/offline'; + +// mock localforage methods +jest.mock('localforage', () => ({ + createInstance(_options: { name: string }): any { + let items: { key: string; value: Event }[] = []; + + return { + async getItem(key: string): Event { + return items.find(item => item.key === key); + }, + async iterate(callback: () => void): void { + items.forEach((item, index) => { + callback(item.value, item.key, index); + }); + }, + async length(): number { + return items.length; + }, + async removeItem(key: string): void { + items = items.filter(item => item.key !== key); + }, + async setItem(key: string, value: Event): void { + items.push({ + key, + value, + }); + }, + }; + }, +})); + +let integration: Integration; +let online: boolean; + +describe('Offline', () => { + describe('when app is online', () => { + beforeEach(() => { + online = true; + + initIntegration(); + }); + + it('does not store events in offline store', async () => { + setupOnce(); + processEvents(); + + expect(await integration.offlineEventStore.length()).toEqual(0); + }); + + describe('when there are already events in the cache from a previous offline session', () => { + beforeEach(done => { + const event = { message: 'previous event' }; + + integration.offlineEventStore + .setItem('previous', event) + .then(() => { + done(); + }) + .catch(error => error); + }); + + it('sends stored events', async () => { + expect(await integration.offlineEventStore.length()).toEqual(1); + + setupOnce(); + processEvents(); + + expect(await integration.offlineEventStore.length()).toEqual(0); + }); + }); + }); + + describe('when app is offline', () => { + beforeEach(() => { + online = false; + }); + + it('stores events in offline store', async () => { + initIntegration(); + setupOnce(); + prepopulateEvents(1); + processEvents(); + + expect(await integration.offlineEventStore.length()).toEqual(1); + }); + + it('enforces a default of 30 maxStoredEvents', done => { + initIntegration(); + setupOnce(); + prepopulateEvents(50); + processEvents(); + + setImmediate(async () => { + // allow background promises to finish resolving + expect(await integration.offlineEventStore.length()).toEqual(30); + done(); + }); + }); + + it('does not purge events when below the maxStoredEvents threshold', done => { + initIntegration(); + setupOnce(); + prepopulateEvents(5); + processEvents(); + + setImmediate(async () => { + // allow background promises to finish resolving + expect(await integration.offlineEventStore.length()).toEqual(5); + done(); + }); + }); + + describe('when maxStoredEvents is supplied', () => { + it('respects the configuration', done => { + initIntegration({ maxStoredEvents: 5 }); + setupOnce(); + prepopulateEvents(50); + processEvents(); + + setImmediate(async () => { + // allow background promises to finish resolving + expect(await integration.offlineEventStore.length()).toEqual(5); + done(); + }); + }); + }); + + describe('when connectivity is restored', () => { + it('sends stored events', async done => { + initIntegration(); + setupOnce(); + prepopulateEvents(1); + processEvents(); + processEventListeners(); + + expect(await integration.offlineEventStore.length()).toEqual(0); + + setImmediate(done); + }); + }); + }); +}); + +let eventListeners: any[]; +let eventProcessors: EventProcessor[]; +let events: Event[]; + +/** JSDoc */ +function addGlobalEventProcessor(callback: () => void): void { + eventProcessors.push(callback); +} + +/** JSDoc */ +function getCurrentHub(): Hub { + return { + captureEvent(_event: Event): string { + return 'an-event-id'; + }, + getIntegration(_integration: Integration): any { + // pretend integration is enabled + return true; + }, + }; +} + +/** JSDoc */ +function initIntegration(options: { maxStoredEvents?: number } = {}): void { + eventListeners = []; + eventProcessors = []; + events = []; + + utils.getGlobalObject = jest.fn(() => ({ + addEventListener: (_windowEvent, callback) => { + eventListeners.push(callback); + }, + navigator: { + onLine: online, + }, + })); + + integration = new Offline(options); +} + +/** JSDoc */ +function prepopulateEvents(count: number = 1): void { + for (let i = 0; i < count; i++) { + events.push({ + message: 'There was an error!', + timestamp: new Date().getTime(), + }); + } +} + +/** JSDoc */ +function processEventListeners(): void { + eventListeners.forEach(listener => { + listener(); + }); +} + +/** JSDoc */ +function processEvents(): void { + eventProcessors.forEach(processor => { + events.forEach(event => { + processor(event) as Event | null; + }); + }); +} + +/** JSDoc */ +function setupOnce(): void { + integration.setupOnce(addGlobalEventProcessor, getCurrentHub); +} diff --git a/packages/integrations/tsconfig.json b/packages/integrations/tsconfig.json index 6564f8267130..8cbf422e8cd9 100644 --- a/packages/integrations/tsconfig.json +++ b/packages/integrations/tsconfig.json @@ -3,6 +3,7 @@ "include": ["src/**/*.ts", "test/**/*.ts"], "exclude": ["dist"], "compilerOptions": { + "esModuleInterop": true, "declarationMap": false, "rootDir": ".", "types": ["node", "jest"] diff --git a/yarn.lock b/yarn.lock index b113fed5a70d..7260df85d840 100644 --- a/yarn.lock +++ b/yarn.lock @@ -10521,6 +10521,11 @@ ignore@^5.1.1, ignore@^5.1.4: resolved "https://registry.yarnpkg.com/ignore/-/ignore-5.1.8.tgz#f150a8b50a34289b33e22f5889abd4d8016f0e57" integrity sha512-BMpfD7PpiETpBl/A6S498BaIJ6Y/ABT93ETbby2fP00v4EbvPBXWEoaR1UBPKs3iR53pJY7EtZk5KACI57i1Uw== +immediate@~3.0.5: + version "3.0.6" + resolved "https://registry.yarnpkg.com/immediate/-/immediate-3.0.6.tgz#9db1dbd0faf8de6fbe0f5dd5e56bb606280de69b" + integrity sha1-nbHb0Pr43m++D13V5Wu2BigN5ps= + import-fresh@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/import-fresh/-/import-fresh-2.0.0.tgz#d81355c15612d386c61f9ddd3922d4304822a546" @@ -12283,6 +12288,13 @@ libnpmpublish@^1.1.1: semver "^5.5.1" ssri "^6.0.1" +lie@3.1.1: + version "3.1.1" + resolved "https://registry.yarnpkg.com/lie/-/lie-3.1.1.tgz#9a436b2cc7746ca59de7a41fa469b3efb76bd87e" + integrity sha1-mkNrLMd0bKWd56QfpGmz77dr2H4= + dependencies: + immediate "~3.0.5" + line-stream@0.0.0: version "0.0.0" resolved "https://registry.yarnpkg.com/line-stream/-/line-stream-0.0.0.tgz#888b7cc7951c6a05ce4d696dd1e6b8262371bb45" @@ -12366,6 +12378,13 @@ loader.js@^4.7.0: resolved "https://registry.yarnpkg.com/loader.js/-/loader.js-4.7.0.tgz#a1a52902001c83631efde9688b8ab3799325ef1f" integrity sha512-9M2KvGT6duzGMgkOcTkWb+PR/Q2Oe54df/tLgHGVmFpAmtqJ553xJh6N63iFYI2yjo2PeJXbS5skHi/QpJq4vA== +localforage@^1.8.1: + version "1.9.0" + resolved "https://registry.yarnpkg.com/localforage/-/localforage-1.9.0.tgz#f3e4d32a8300b362b4634cc4e066d9d00d2f09d1" + integrity sha512-rR1oyNrKulpe+VM9cYmcFn6tsHuokyVHFaCM3+osEmxaHTbEk8oQu6eGDfS6DQLWi/N67XRmB8ECG37OES368g== + dependencies: + lie "3.1.1" + locate-path@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/locate-path/-/locate-path-2.0.0.tgz#2b568b265eec944c6d9c0de9c3dbbbca0354cd8e"