Skip to content

Commit 8fbffe2

Browse files
feat: Add integration for offline support (#2778)
* Add integration for offline support Offline events are cached in the browser with localforage. * Add logging statements * Move 'offline' to the integrations package * Revert to import assignment I thought this build was passing, but it looks like there is not an easy way to use a standard import for localforage in this library. * Use Hub.captureEvent instead of adding @sentry/minimal * Use localforage types * Remove async/await syntax to keep bundle size down * Check for addEventListener in global * Add tests for Offline integration * Fixing lockfile after migrating to @sentry/integrations * Add TS compiler flag to allow import of localforage * Handle scenario where app has stored events from previous offline session * Add config for maxStoredEvents with a default limit of 30 * Address new linter rules
1 parent ec75ea7 commit 8fbffe2

File tree

6 files changed

+408
-0
lines changed

6 files changed

+408
-0
lines changed

packages/integrations/package.json

+1
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
"dependencies": {
1919
"@sentry/types": "5.21.4",
2020
"@sentry/utils": "5.21.4",
21+
"localforage": "^1.8.1",
2122
"tslib": "^1.9.3"
2223
},
2324
"devDependencies": {

packages/integrations/src/index.ts

+1
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ export { Debug } from './debug';
44
export { Dedupe } from './dedupe';
55
export { Ember } from './ember';
66
export { ExtraErrorData } from './extraerrordata';
7+
export { Offline } from './offline';
78
export { ReportingObserver } from './reportingobserver';
89
export { RewriteFrames } from './rewriteframes';
910
export { SessionTiming } from './sessiontiming';

packages/integrations/src/offline.ts

+169
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
1+
import { Event, EventProcessor, Hub, Integration } from '@sentry/types';
2+
import { getGlobalObject, logger, uuid4 } from '@sentry/utils';
3+
// @ts-ignore: Module '"localforage"' has no default export.
4+
import localforage from 'localforage';
5+
6+
/**
7+
* cache offline errors and send when connected
8+
*/
9+
export class Offline implements Integration {
10+
/**
11+
* @inheritDoc
12+
*/
13+
public static id: string = 'Offline';
14+
15+
/**
16+
* @inheritDoc
17+
*/
18+
public readonly name: string = Offline.id;
19+
20+
/**
21+
* the global instance
22+
*/
23+
public global: Window;
24+
25+
/**
26+
* the current hub instance
27+
*/
28+
public hub?: Hub;
29+
30+
/**
31+
* maximum number of events to store while offline
32+
*/
33+
public maxStoredEvents: number;
34+
35+
/**
36+
* event cache
37+
*/
38+
public offlineEventStore: LocalForage; // type imported from localforage
39+
40+
/**
41+
* @inheritDoc
42+
*/
43+
public constructor(options: { maxStoredEvents?: number } = {}) {
44+
this.global = getGlobalObject<Window>();
45+
this.maxStoredEvents = options.maxStoredEvents || 30; // set a reasonable default
46+
this.offlineEventStore = localforage.createInstance({
47+
name: 'sentry/offlineEventStore',
48+
});
49+
50+
if ('addEventListener' in this.global) {
51+
this.global.addEventListener('online', () => {
52+
this._sendEvents().catch(() => {
53+
logger.warn('could not send cached events');
54+
});
55+
});
56+
}
57+
}
58+
59+
/**
60+
* @inheritDoc
61+
*/
62+
public setupOnce(addGlobalEventProcessor: (callback: EventProcessor) => void, getCurrentHub: () => Hub): void {
63+
this.hub = getCurrentHub();
64+
65+
addGlobalEventProcessor((event: Event) => {
66+
if (this.hub && this.hub.getIntegration(Offline)) {
67+
// cache if we are positively offline
68+
if ('navigator' in this.global && 'onLine' in this.global.navigator && !this.global.navigator.onLine) {
69+
this._cacheEvent(event)
70+
.then((_event: Event): Promise<void> => this._enforceMaxEvents())
71+
.catch(
72+
(_error): void => {
73+
logger.warn('could not cache event while offline');
74+
},
75+
);
76+
77+
// return null on success or failure, because being offline will still result in an error
78+
return null;
79+
}
80+
}
81+
82+
return event;
83+
});
84+
85+
// if online now, send any events stored in a previous offline session
86+
if ('navigator' in this.global && 'onLine' in this.global.navigator && this.global.navigator.onLine) {
87+
this._sendEvents().catch(() => {
88+
logger.warn('could not send cached events');
89+
});
90+
}
91+
}
92+
93+
/**
94+
* cache an event to send later
95+
* @param event an event
96+
*/
97+
private async _cacheEvent(event: Event): Promise<Event> {
98+
return this.offlineEventStore.setItem<Event>(uuid4(), event);
99+
}
100+
101+
/**
102+
* purge excess events if necessary
103+
*/
104+
private async _enforceMaxEvents(): Promise<void> {
105+
const events: Array<{ event: Event; cacheKey: string }> = [];
106+
107+
return this.offlineEventStore
108+
.iterate<Event, void>(
109+
(event: Event, cacheKey: string, _index: number): void => {
110+
// aggregate events
111+
events.push({ cacheKey, event });
112+
},
113+
)
114+
.then(
115+
(): Promise<void> =>
116+
// this promise resolves when the iteration is finished
117+
this._purgeEvents(
118+
// purge all events past maxStoredEvents in reverse chronological order
119+
events
120+
.sort((a, b) => (b.event.timestamp || 0) - (a.event.timestamp || 0))
121+
.slice(this.maxStoredEvents < events.length ? this.maxStoredEvents : events.length)
122+
.map(event => event.cacheKey),
123+
),
124+
)
125+
.catch(
126+
(_error): void => {
127+
logger.warn('could not enforce max events');
128+
},
129+
);
130+
}
131+
132+
/**
133+
* purge event from cache
134+
*/
135+
private async _purgeEvent(cacheKey: string): Promise<void> {
136+
return this.offlineEventStore.removeItem(cacheKey);
137+
}
138+
139+
/**
140+
* purge events from cache
141+
*/
142+
private async _purgeEvents(cacheKeys: string[]): Promise<void> {
143+
// trail with .then to ensure the return type as void and not void|void[]
144+
return Promise.all(cacheKeys.map(cacheKey => this._purgeEvent(cacheKey))).then();
145+
}
146+
147+
/**
148+
* send all events
149+
*/
150+
private async _sendEvents(): Promise<void> {
151+
return this.offlineEventStore.iterate<Event, void>(
152+
(event: Event, cacheKey: string, _index: number): void => {
153+
if (this.hub) {
154+
const newEventId = this.hub.captureEvent(event);
155+
156+
if (newEventId) {
157+
this._purgeEvent(cacheKey).catch(
158+
(_error): void => {
159+
logger.warn('could not purge event from cache');
160+
},
161+
);
162+
}
163+
} else {
164+
logger.warn('no hub found - could not send cached event');
165+
}
166+
},
167+
);
168+
}
169+
}

0 commit comments

Comments
 (0)