Skip to content

Commit 26e5b0c

Browse files
committed
Simplify a bit and add some tests
1 parent ef57767 commit 26e5b0c

File tree

2 files changed

+240
-42
lines changed

2 files changed

+240
-42
lines changed

packages/core/src/transports/offline.ts

Lines changed: 65 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,35 @@
11
import type { Envelope, InternalBaseTransportOptions, Transport, TransportMakeRequestResponse } from '@sentry/types';
2+
import { logger } from '@sentry/utils';
3+
4+
export const START_DELAY = 5_000;
5+
const MAX_DELAY = 2_000_000_000;
6+
const DEFAULT_QUEUE_SIZE = 30;
27

38
function wasRateLimited(result: TransportMakeRequestResponse): boolean {
4-
return !!(result.headers && 'x-sentry-rate-limits' in result.headers);
9+
return !!(result.headers && 'x-sentry-rate-limits' in result.headers && result.headers['x-sentry-rate-limits']);
510
}
611

712
type BeforeSendResponse = 'send' | 'queue' | 'drop';
813

914
interface OfflineTransportOptions extends InternalBaseTransportOptions {
1015
/**
11-
* The maximum number of days to keep an event in the queue.
16+
* The maximum number of events to keep in the offline queue.
17+
*
18+
* Defaults: 30
1219
*/
13-
maxQueueAgeDays?: number;
20+
maxQueueSize?: number;
1421

1522
/**
16-
* The maximum number of events to keep in the queue.
23+
* Flush the offline queue shortly after startup.
24+
*
25+
* Defaults: false
1726
*/
18-
maxQueueCount?: number;
27+
flushAtStartup?: boolean;
1928

2029
/**
21-
* Called every time the number of requests in the queue changes.
30+
* Called when an event is queued .
2231
*/
23-
queuedLengthChanged?: (length: number) => void;
32+
eventQueued?: () => void;
2433

2534
/**
2635
* Called before attempting to send an event to Sentry.
@@ -32,38 +41,42 @@ interface OfflineTransportOptions extends InternalBaseTransportOptions {
3241
beforeSend?: (request: Envelope) => BeforeSendResponse | Promise<BeforeSendResponse>;
3342
}
3443

35-
interface OfflineTransportStore {
36-
add(env: Envelope): Promise<number>;
37-
pop(): Promise<[Envelope | undefined, number]>;
44+
interface OfflineStore {
45+
insert(env: Envelope): Promise<void>;
46+
pop(): Promise<Envelope | undefined>;
3847
}
3948

40-
const START_DELAY = 5_000;
41-
const MAX_DELAY = 2_000_000_000;
49+
export type CreateOfflineStore = (maxQueueCount: number) => OfflineStore;
4250

43-
/** */
51+
/**
52+
* Wraps a transport and queues events when envelopes fail to send.
53+
*
54+
* @param createTransport The transport to wrap.
55+
* @param createStore The store implementation to use.
56+
*/
4457
export function makeOfflineTransport<TO>(
45-
transport: (options: TO) => Transport,
46-
store: OfflineTransportStore,
58+
createTransport: (options: TO) => Transport,
59+
createStore: CreateOfflineStore,
4760
): (options: TO & OfflineTransportOptions) => Transport {
4861
return (options: TO & OfflineTransportOptions) => {
49-
const baseTransport = transport(options);
62+
const baseTransport = createTransport(options);
63+
const maxQueueSize = options.maxQueueSize === undefined ? DEFAULT_QUEUE_SIZE : options.maxQueueSize;
64+
const store = createStore(maxQueueSize);
5065

5166
let retryDelay = START_DELAY;
52-
let lastQueueLength = -1;
5367

54-
function queueLengthChanged(length: number): void {
55-
if (options.queuedLengthChanged && length !== lastQueueLength) {
56-
lastQueueLength = length;
57-
options.queuedLengthChanged(length);
68+
function queued(): void {
69+
if (options.eventQueued) {
70+
options.eventQueued();
5871
}
5972
}
6073

6174
function queueRequest(envelope: Envelope): Promise<void> {
62-
return store.add(envelope).then(count => {
63-
queueLengthChanged(count);
75+
return store.insert(envelope).then(() => {
76+
queued();
6477

6578
setTimeout(() => {
66-
flushQueue();
79+
void flushQueue();
6780
}, retryDelay);
6881

6982
retryDelay *= 3;
@@ -76,36 +89,40 @@ export function makeOfflineTransport<TO>(
7689
});
7790
}
7891

79-
function flushQueue(): void {
80-
void store.pop().then(([found, count]) => {
81-
if (found) {
82-
// We have pending plus just found
83-
queueLengthChanged(count + 1);
84-
void send(found);
85-
} else {
86-
queueLengthChanged(0);
87-
}
88-
});
92+
async function flushQueue(): Promise<void> {
93+
const found = await store.pop();
94+
95+
if (found) {
96+
__DEBUG_BUILD__ && logger.info('[Offline]: Attempting to send previously queued event');
97+
void send(found);
98+
}
8999
}
90100

91-
// eslint-disable-next-line @sentry-internal/sdk/no-async-await
92101
async function send(request: Envelope): Promise<void | TransportMakeRequestResponse> {
93-
let action = (await options.beforeSend?.(request)) || 'send';
102+
let action = 'send';
103+
104+
if (options.beforeSend) {
105+
action = await options.beforeSend(request);
106+
}
94107

95108
if (action === 'send') {
96109
try {
97110
const result = await baseTransport.send(request);
98-
if (!wasRateLimited(result || {})) {
111+
if (wasRateLimited(result || {})) {
112+
__DEBUG_BUILD__ && logger.info('[Offline]: Event queued due to rate limiting');
113+
action = 'queue';
114+
} else {
115+
// Envelope was successfully sent
99116
// Reset the retry delay
100117
retryDelay = START_DELAY;
101-
// We were successful so check the queue
102-
flushQueue();
118+
// Check if there are any more in the queue
119+
void flushQueue();
103120
return result;
104121
}
105-
} catch (_) {
106-
//
122+
} catch (e) {
123+
__DEBUG_BUILD__ && logger.info('[Offline]: Event queued due to error', e);
124+
action = 'queue';
107125
}
108-
action = 'queue';
109126
}
110127

111128
if (action == 'queue') {
@@ -115,6 +132,12 @@ export function makeOfflineTransport<TO>(
115132
return {};
116133
}
117134

135+
if (options.flushAtStartup) {
136+
setTimeout(() => {
137+
void flushQueue();
138+
}, retryDelay);
139+
}
140+
118141
return {
119142
send,
120143
flush: (timeout?: number) => baseTransport.flush(timeout),
Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
1+
import type {
2+
EventEnvelope,
3+
EventItem,
4+
TransportMakeRequestResponse,
5+
Envelope,
6+
Transport,
7+
InternalBaseTransportOptions,
8+
} from '@sentry/types';
9+
import { createEnvelope } from '@sentry/utils';
10+
import { TextEncoder } from 'util';
11+
12+
import { makeOfflineTransport, createTransport } from '../../../src';
13+
import { CreateOfflineStore, START_DELAY } from '../../../src/transports/offline';
14+
15+
const ERROR_ENVELOPE = createEnvelope<EventEnvelope>({ event_id: 'aa3ff046696b4bc6b609ce6d28fde9e2', sent_at: '123' }, [
16+
[{ type: 'event' }, { event_id: 'aa3ff046696b4bc6b609ce6d28fde9e2' }] as EventItem,
17+
]);
18+
19+
const transportOptions = {
20+
recordDroppedEvent: () => undefined, // noop
21+
textEncoder: new TextEncoder(),
22+
};
23+
24+
type MockResult<T> = T | Error;
25+
26+
const createTestTransport = (
27+
...sendResults: MockResult<TransportMakeRequestResponse>[]
28+
): { getSendCount: () => number; baseTransport: (options: InternalBaseTransportOptions) => Transport } => {
29+
let sendCount = 0;
30+
31+
return {
32+
getSendCount: () => sendCount,
33+
baseTransport: (options: InternalBaseTransportOptions) =>
34+
createTransport(options, () => {
35+
return new Promise((resolve, reject) => {
36+
const next = sendResults.shift();
37+
38+
if (next instanceof Error) {
39+
reject(next);
40+
} else {
41+
sendCount += 1;
42+
resolve(next as TransportMakeRequestResponse | undefined);
43+
}
44+
});
45+
}),
46+
};
47+
};
48+
49+
type StoreEvents = ('add' | 'pop')[];
50+
51+
function createTestStore(...popResults: MockResult<Envelope | undefined>[]): {
52+
getCalls: () => StoreEvents;
53+
store: CreateOfflineStore;
54+
} {
55+
const calls: StoreEvents = [];
56+
57+
return {
58+
getCalls: () => calls,
59+
store: (maxQueueCount: number) => ({
60+
insert: async env => {
61+
if (popResults.length < maxQueueCount) {
62+
popResults.push(env);
63+
calls.push('add');
64+
}
65+
},
66+
pop: async () => {
67+
calls.push('pop');
68+
const next = popResults.shift();
69+
70+
if (next instanceof Error) {
71+
throw next;
72+
}
73+
74+
return next;
75+
},
76+
}),
77+
};
78+
}
79+
80+
function delay(ms: number): Promise<void> {
81+
return new Promise(resolve => setTimeout(resolve, ms));
82+
}
83+
84+
describe('makeOfflineTransport', () => {
85+
it('Sends envelope and checks the store for further envelopes', async () => {
86+
expect.assertions(3);
87+
88+
const { getCalls, store } = createTestStore();
89+
const { getSendCount, baseTransport } = createTestTransport({ statusCode: 200 });
90+
const transport = makeOfflineTransport(baseTransport, store)(transportOptions);
91+
const result = await transport.send(ERROR_ENVELOPE);
92+
93+
expect(result).toEqual({ statusCode: 200 });
94+
expect(getSendCount()).toEqual(1);
95+
// After a successful send, the store should be checked
96+
expect(getCalls()).toEqual(['pop']);
97+
});
98+
99+
it('After successfully sending, sends further envelopes found in the store', async () => {
100+
const { getCalls, store } = createTestStore(ERROR_ENVELOPE);
101+
const { getSendCount, baseTransport } = createTestTransport({ statusCode: 200 }, { statusCode: 200 });
102+
const transport = makeOfflineTransport(baseTransport, store)(transportOptions);
103+
const result = await transport.send(ERROR_ENVELOPE);
104+
105+
expect(result).toEqual({ statusCode: 200 });
106+
107+
await delay(100);
108+
109+
expect(getSendCount()).toEqual(2);
110+
// After a successful send, the store should be checked again to ensure it's empty
111+
expect(getCalls()).toEqual(['pop', 'pop']);
112+
});
113+
114+
it('Queues envelope if wrapped transport throws error', async () => {
115+
const { getCalls, store } = createTestStore();
116+
const { getSendCount, baseTransport } = createTestTransport(new Error());
117+
const transport = makeOfflineTransport(baseTransport, store)(transportOptions);
118+
const result = await transport.send(ERROR_ENVELOPE);
119+
120+
expect(result).toEqual({});
121+
122+
await delay(100);
123+
124+
expect(getSendCount()).toEqual(0);
125+
expect(getCalls()).toEqual(['add']);
126+
});
127+
128+
it('Queues envelope if rate limited', async () => {
129+
const { getCalls, store } = createTestStore();
130+
const { getSendCount, baseTransport } = createTestTransport({
131+
headers: { 'x-sentry-rate-limits': 'something', 'retry-after': null },
132+
});
133+
const transport = makeOfflineTransport(baseTransport, store)(transportOptions);
134+
const result = await transport.send(ERROR_ENVELOPE);
135+
expect(result).toEqual({});
136+
137+
await delay(100);
138+
139+
expect(getSendCount()).toEqual(1);
140+
expect(getCalls()).toEqual(['add']);
141+
});
142+
143+
it(
144+
'Retries sending envelope after failure',
145+
async () => {
146+
const { getCalls, store } = createTestStore();
147+
const { getSendCount, baseTransport } = createTestTransport(new Error(), { statusCode: 200 });
148+
const transport = makeOfflineTransport(baseTransport, store)(transportOptions);
149+
const result = await transport.send(ERROR_ENVELOPE);
150+
expect(result).toEqual({});
151+
expect(getCalls()).toEqual(['add']);
152+
153+
await delay(START_DELAY + 1_000);
154+
155+
expect(getSendCount()).toEqual(1);
156+
expect(getCalls()).toEqual(['add', 'pop', 'pop']);
157+
},
158+
START_DELAY + 2_000,
159+
);
160+
161+
it(
162+
'When enabled, sends envelopes found in store shortly after startup',
163+
async () => {
164+
const { getCalls, store } = createTestStore(ERROR_ENVELOPE, ERROR_ENVELOPE);
165+
const { getSendCount, baseTransport } = createTestTransport({ statusCode: 200 }, { statusCode: 200 });
166+
const _transport = makeOfflineTransport(baseTransport, store)({ ...transportOptions, flushAtStartup: true });
167+
168+
await delay(START_DELAY + 1_000);
169+
170+
expect(getSendCount()).toEqual(2);
171+
expect(getCalls()).toEqual(['pop', 'pop', 'pop']);
172+
},
173+
START_DELAY + 2_000,
174+
);
175+
});

0 commit comments

Comments
 (0)