Skip to content

Commit 56f6c7c

Browse files
authored
feat(core): Add Offline Transport wrapper (#6884)
1 parent 15ec85b commit 56f6c7c

File tree

3 files changed

+483
-0
lines changed

3 files changed

+483
-0
lines changed

packages/core/src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
export type { ClientClass } from './sdk';
22
export type { Carrier, Layer } from './hub';
3+
export type { OfflineStore, OfflineTransportOptions } from './transports/offline';
34

45
export {
56
addBreadcrumb,
@@ -24,6 +25,7 @@ export { getEnvelopeEndpointWithUrlEncodedAuth, getReportDialogEndpoint } from '
2425
export { BaseClient } from './baseclient';
2526
export { initAndBind } from './sdk';
2627
export { createTransport } from './transports/base';
28+
export { makeOfflineTransport } from './transports/offline';
2729
export { SDK_VERSION } from './version';
2830
export { getIntegrationsToSetup } from './integration';
2931
export { FunctionToString, InboundFilters } from './integrations';
Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
1+
import type { Envelope, InternalBaseTransportOptions, Transport, TransportMakeRequestResponse } from '@sentry/types';
2+
import { forEachEnvelopeItem, logger, parseRetryAfterHeader } from '@sentry/utils';
3+
4+
export const MIN_DELAY = 100; // 100 ms
5+
export const START_DELAY = 5_000; // 5 seconds
6+
const MAX_DELAY = 3.6e6; // 1 hour
7+
8+
function isReplayEnvelope(envelope: Envelope): boolean {
9+
let isReplay = false;
10+
11+
forEachEnvelopeItem(envelope, (_, type) => {
12+
if (type === 'replay_event') {
13+
isReplay = true;
14+
}
15+
});
16+
17+
return isReplay;
18+
}
19+
20+
function log(msg: string, error?: Error): void {
21+
__DEBUG_BUILD__ && logger.info(`[Offline]: ${msg}`, error);
22+
}
23+
24+
export interface OfflineStore {
25+
insert(env: Envelope): Promise<void>;
26+
pop(): Promise<Envelope | undefined>;
27+
}
28+
29+
export type CreateOfflineStore = (options: OfflineTransportOptions) => OfflineStore;
30+
31+
export interface OfflineTransportOptions extends InternalBaseTransportOptions {
32+
/**
33+
* A function that creates the offline store instance.
34+
*/
35+
createStore?: CreateOfflineStore;
36+
37+
/**
38+
* Flush the offline store shortly after startup.
39+
*
40+
* Defaults: false
41+
*/
42+
flushAtStartup?: boolean;
43+
44+
/**
45+
* Called before an event is stored.
46+
*
47+
* Return false to drop the envelope rather than store it.
48+
*
49+
* @param envelope The envelope that failed to send.
50+
* @param error The error that occurred.
51+
* @param retryDelay The current retry delay in milliseconds.
52+
*/
53+
shouldStore?: (envelope: Envelope, error: Error, retryDelay: number) => boolean | Promise<boolean>;
54+
}
55+
56+
type Timer = number | { unref?: () => void };
57+
58+
/**
59+
* Wraps a transport and stores and retries events when they fail to send.
60+
*
61+
* @param createTransport The transport to wrap.
62+
*/
63+
export function makeOfflineTransport<TO>(
64+
createTransport: (options: TO) => Transport,
65+
): (options: TO & OfflineTransportOptions) => Transport {
66+
return options => {
67+
const transport = createTransport(options);
68+
const store = options.createStore ? options.createStore(options) : undefined;
69+
70+
let retryDelay = START_DELAY;
71+
let flushTimer: Timer | undefined;
72+
73+
function shouldQueue(env: Envelope, error: Error, retryDelay: number): boolean | Promise<boolean> {
74+
// We don't queue Session Replay envelopes because they are:
75+
// - Ordered and Replay relies on the response status to know when they're successfully sent.
76+
// - Likely to fill the queue quickly and block other events from being sent.
77+
if (isReplayEnvelope(env)) {
78+
return false;
79+
}
80+
81+
if (options.shouldStore) {
82+
return options.shouldStore(env, error, retryDelay);
83+
}
84+
85+
return true;
86+
}
87+
88+
function flushIn(delay: number): void {
89+
if (!store) {
90+
return;
91+
}
92+
93+
if (flushTimer) {
94+
clearTimeout(flushTimer as ReturnType<typeof setTimeout>);
95+
}
96+
97+
flushTimer = setTimeout(async () => {
98+
flushTimer = undefined;
99+
100+
const found = await store.pop();
101+
if (found) {
102+
log('Attempting to send previously queued event');
103+
void send(found).catch(e => {
104+
log('Failed to retry sending', e);
105+
});
106+
}
107+
}, delay) as Timer;
108+
109+
// We need to unref the timer in node.js, otherwise the node process never exit.
110+
if (typeof flushTimer !== 'number' && flushTimer.unref) {
111+
flushTimer.unref();
112+
}
113+
}
114+
115+
function flushWithBackOff(): void {
116+
if (flushTimer) {
117+
return;
118+
}
119+
120+
flushIn(retryDelay);
121+
122+
retryDelay = Math.min(retryDelay * 2, MAX_DELAY);
123+
}
124+
125+
async function send(envelope: Envelope): Promise<void | TransportMakeRequestResponse> {
126+
try {
127+
const result = await transport.send(envelope);
128+
129+
let delay = MIN_DELAY;
130+
131+
if (result) {
132+
// If there's a retry-after header, use that as the next delay.
133+
if (result.headers && result.headers['retry-after']) {
134+
delay = parseRetryAfterHeader(result.headers['retry-after']);
135+
} // If we have a server error, return now so we don't flush the queue.
136+
else if ((result.statusCode || 0) >= 400) {
137+
return result;
138+
}
139+
}
140+
141+
flushIn(delay);
142+
retryDelay = START_DELAY;
143+
return result;
144+
} catch (e) {
145+
if (store && (await shouldQueue(envelope, e, retryDelay))) {
146+
await store.insert(envelope);
147+
flushWithBackOff();
148+
log('Error sending. Event queued', e);
149+
return {};
150+
} else {
151+
throw e;
152+
}
153+
}
154+
}
155+
156+
if (options.flushAtStartup) {
157+
flushWithBackOff();
158+
}
159+
160+
return {
161+
send,
162+
flush: t => transport.flush(t),
163+
};
164+
};
165+
}

0 commit comments

Comments
 (0)