Skip to content

Commit b95788b

Browse files
authored
feat: added errorHandler option to client (#713)
1 parent 3b44278 commit b95788b

File tree

15 files changed

+280
-18
lines changed

15 files changed

+280
-18
lines changed

README.md

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,8 @@ The hassle-free way to add Segment analytics to your React-Native app.
4040
- [Controlling Upload With Flush Policies](#controlling-upload-with-flush-policies)
4141
- [Adding or removing policies](#adding-or-removing-policies)
4242
- [Creating your own flush policies](#creating-your-own-flush-policies)
43+
- [Handling errors](#handling-errors)
44+
- [Reporting errors from plugins](#reporting-errors-from-plugins)
4345
- [Contributing](#contributing)
4446
- [Code of Conduct](#code-of-conduct)
4547
- [License](#license)
@@ -621,6 +623,60 @@ export class FlushOnScreenEventsPolicy extends FlushPolicyBase {
621623
}
622624
```
623625
626+
## Handling errors
627+
628+
You can handle analytics client errors through the `errorHandler` option.
629+
630+
The error handler configuration receives a function which will get called whenever an error happens on the analytics client. It will receive an argument of [`SegmentError`](packages/core/src/errors.ts#L20) type.
631+
632+
You can use this error handling to trigger different behaviours in the client when a problem occurs. For example if the client gets rate limited you could use the error handler to swap flush policies to be less aggressive:
633+
634+
```ts
635+
const flushPolicies = [new CountFlushPolicy(5), new TimerFlushPolicy(500)];
636+
637+
const errorHandler = (error: SegmentError) => {
638+
if (error.type === ErrorType.NetworkServerLimited) {
639+
// Remove all flush policies
640+
segmentClient.removeFlushPolicy(...segmentClient.getFlushPolicies());
641+
// Add less persistent flush policies
642+
segmentClient.addFlushPolicy(
643+
new CountFlushPolicy(100),
644+
new TimerFlushPolicy(5000)
645+
);
646+
}
647+
};
648+
649+
const segmentClient = createClient({
650+
writeKey: 'WRITE_KEY',
651+
trackAppLifecycleEvents: true,
652+
collectDeviceId: true,
653+
debug: true,
654+
trackDeepLinks: true,
655+
flushPolicies: flushPolicies,
656+
errorHandler: errorHandler,
657+
});
658+
659+
```
660+
661+
The reported errors can be of any of the [`ErrorType`](packages/core/src/errors.ts#L4) enum values.
662+
663+
### Reporting errors from plugins
664+
665+
Plugins can also report errors to the handler by using the [`.reportInternalError`](packages/core/src/analytics.ts#L741) function of the analytics client, we recommend using the `ErrorType.PluginError` for consistency, and attaching the `innerError` with the actual exception that was hit:
666+
667+
```ts
668+
try {
669+
distinctId = await mixpanel.getDistinctId();
670+
} catch (e) {
671+
analytics.reportInternalError(
672+
new SegmentError(ErrorType.PluginError, 'Error: Mixpanel error calling getDistinctId', e)
673+
);
674+
analytics.logger.warn(e);
675+
}
676+
```
677+
678+
679+
624680
625681
## Contributing
626682

packages/core/src/__tests__/analytics.test.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import type { AppStateStatus } from 'react-native';
22
import { AppState } from 'react-native';
33
import { SegmentClient } from '../analytics';
4+
import { ErrorType, SegmentError } from '../errors';
45
import { getMockLogger } from './__helpers__/mockLogger';
56
import { MockSegmentStore } from './__helpers__/mockSegmentStore';
67

@@ -144,4 +145,22 @@ describe('SegmentClient', () => {
144145
});
145146
});
146147
});
148+
149+
describe('Error Handler', () => {
150+
it('calls the error handler when reportErrorInternal is called', () => {
151+
const errorHandler = jest.fn();
152+
client = new SegmentClient({
153+
...clientArgs,
154+
config: { ...clientArgs.config, errorHandler: errorHandler },
155+
});
156+
157+
const error = new SegmentError(
158+
ErrorType.NetworkUnknown,
159+
'Some weird error'
160+
);
161+
client.reportInternalError(error);
162+
163+
expect(errorHandler).toHaveBeenCalledWith(error);
164+
});
165+
});
147166
});

packages/core/src/__tests__/internal/fetchSettings.test.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ describe('internal #getSettings', () => {
4040
it('fetches the settings succesfully ', async () => {
4141
const mockJSONResponse = { integrations: { foo: 'bar' } };
4242
const mockResponse = Promise.resolve({
43+
ok: true,
4344
json: () => mockJSONResponse,
4445
});
4546
// @ts-ignore
@@ -92,4 +93,24 @@ describe('internal #getSettings', () => {
9293
);
9394
expect(setSettingsSpy).not.toHaveBeenCalled();
9495
});
96+
97+
it('fails to the settings succesfully and has no default settings for soft API errors', async () => {
98+
const mockResponse = Promise.resolve({
99+
ok: false,
100+
status: 500,
101+
});
102+
// @ts-ignore
103+
global.fetch = jest.fn(() => Promise.resolve(mockResponse));
104+
const anotherClient = new SegmentClient({
105+
...clientArgs,
106+
config: { ...clientArgs.config, defaultSettings: undefined },
107+
});
108+
109+
await anotherClient.fetchSettings();
110+
111+
expect(fetch).toHaveBeenCalledWith(
112+
`${settingsCDN}/${clientArgs.config.writeKey}/settings`
113+
);
114+
expect(setSettingsSpy).not.toHaveBeenCalled();
115+
});
95116
});

packages/core/src/analytics.ts

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,11 @@ import {
4646
import { getPluginsWithFlush, getPluginsWithReset } from './util';
4747
import { getUUID } from './uuid';
4848
import type { FlushPolicy } from './flushPolicies';
49+
import {
50+
checkResponseForErrors,
51+
SegmentError,
52+
translateHTTPError,
53+
} from './errors';
4954

5055
type OnContextLoadCallback = (type: UpdateType) => void | Promise<void>;
5156
type OnPluginAddedCallback = (plugin: Plugin) => void;
@@ -267,6 +272,8 @@ export class SegmentClient {
267272

268273
try {
269274
const res = await fetch(settingsEndpoint);
275+
checkResponseForErrors(res);
276+
270277
const resJson: SegmentAPISettings = await res.json();
271278
const integrations = resJson.integrations;
272279
const filters = this.generateFiltersMap(
@@ -277,14 +284,17 @@ export class SegmentClient {
277284
this.store.settings.set(integrations),
278285
this.store.filters.set(filters),
279286
]);
280-
} catch {
287+
} catch (e) {
288+
this.reportInternalError(translateHTTPError(e));
289+
281290
this.logger.warn(
282291
`Could not receive settings from Segment. ${
283292
this.config.defaultSettings
284293
? 'Will use the default settings.'
285294
: 'Device mode destinations will be ignored unless you specify default settings in the client config.'
286295
}`
287296
);
297+
288298
if (this.config.defaultSettings) {
289299
await this.store.settings.set(this.config.defaultSettings.integrations);
290300
}
@@ -727,4 +737,13 @@ export class SegmentClient {
727737
getFlushPolicies() {
728738
return this.flushPolicyExecuter.policies;
729739
}
740+
741+
reportInternalError(error: SegmentError, fatal: boolean = false) {
742+
if (fatal) {
743+
this.logger.error('A critical error ocurred: ', error);
744+
} else {
745+
this.logger.warn('An internal error occurred: ', error);
746+
}
747+
this.config.errorHandler?.(error);
748+
}
730749
}

packages/core/src/api.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ export const uploadEvents = async ({
1010
events: SegmentEvent[];
1111
}) => {
1212
const requestUrl = config.proxy || batchApi;
13-
await fetch(requestUrl, {
13+
return await fetch(requestUrl, {
1414
method: 'POST',
1515
body: JSON.stringify({
1616
batch: events,

packages/core/src/errors.ts

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
/**
2+
* Error types reported through the errorHandler in the client
3+
*/
4+
export enum ErrorType {
5+
NetworkUnexpectedHTTPCode,
6+
NetworkServerLimited,
7+
NetworkServerRejected,
8+
NetworkUnknown,
9+
10+
JsonUnableToSerialize,
11+
JsonUnableToDeserialize,
12+
JsonUnknown,
13+
14+
PluginError,
15+
}
16+
17+
/**
18+
* Segment Error object for ErrorHandler option
19+
*/
20+
export class SegmentError extends Error {
21+
type: ErrorType;
22+
message: string;
23+
innerError?: unknown;
24+
25+
constructor(type: ErrorType, message: string, innerError?: unknown) {
26+
super(message);
27+
Object.setPrototypeOf(this, SegmentError.prototype);
28+
this.type = type;
29+
this.message = message;
30+
this.innerError = innerError;
31+
}
32+
}
33+
34+
/**
35+
* Custom Error type for Segment HTTP Error responses
36+
*/
37+
export class NetworkError extends SegmentError {
38+
statusCode: number;
39+
type:
40+
| ErrorType.NetworkServerLimited
41+
| ErrorType.NetworkServerRejected
42+
| ErrorType.NetworkUnexpectedHTTPCode
43+
| ErrorType.NetworkUnknown;
44+
45+
constructor(statusCode: number, message: string, innerError?: unknown) {
46+
let type: ErrorType;
47+
if (statusCode === 429) {
48+
type = ErrorType.NetworkServerLimited;
49+
} else if (statusCode > 300 && statusCode < 400) {
50+
type = ErrorType.NetworkUnexpectedHTTPCode;
51+
} else if (statusCode >= 400) {
52+
type = ErrorType.NetworkServerRejected;
53+
} else {
54+
type = ErrorType.NetworkUnknown;
55+
}
56+
57+
super(type, message, innerError);
58+
Object.setPrototypeOf(this, NetworkError.prototype);
59+
60+
this.statusCode = statusCode;
61+
this.type = type;
62+
}
63+
}
64+
65+
/**
66+
* Error type for JSON Serialization errors
67+
*/
68+
export class JSONError extends SegmentError {
69+
constructor(
70+
type: ErrorType.JsonUnableToDeserialize | ErrorType.JsonUnableToSerialize,
71+
message: string,
72+
innerError?: unknown
73+
) {
74+
super(type, message, innerError);
75+
Object.setPrototypeOf(this, JSONError.prototype);
76+
}
77+
}
78+
79+
/**
80+
* Utility method for handling HTTP fetch errors
81+
* @param response Fetch Response
82+
* @returns response if status OK, throws NetworkError for everything else
83+
*/
84+
export const checkResponseForErrors = (response: Response) => {
85+
if (!response.ok) {
86+
throw new NetworkError(response.status, response.statusText);
87+
}
88+
89+
return response;
90+
};
91+
92+
/**
93+
* Converts a .fetch() error to a SegmentError object for reporting to the error handler
94+
* @param error any JS error instance
95+
* @returns a SegmentError object
96+
*/
97+
export const translateHTTPError = (error: unknown): SegmentError => {
98+
// SegmentError already
99+
if (error instanceof SegmentError) {
100+
return error;
101+
// JSON Deserialization Errors
102+
} else if (error instanceof SyntaxError) {
103+
return new JSONError(
104+
ErrorType.JsonUnableToDeserialize,
105+
error.message,
106+
error
107+
);
108+
109+
// HTTP Errors
110+
} else {
111+
const message =
112+
error instanceof Error
113+
? error.message
114+
: typeof error === 'string'
115+
? error
116+
: 'Unknown error';
117+
return new NetworkError(-1, message, error);
118+
}
119+
};

packages/core/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,3 +6,4 @@ export { getNativeModule } from './util';
66
export { SegmentClient } from './analytics';
77
export { SegmentDestination } from './plugins/SegmentDestination';
88
export * from './flushPolicies';
9+
export * from './errors';

packages/core/src/plugins/QueueFlushingPlugin.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,7 @@ export class QueueFlushingPlugin extends UtilityPlugin {
6363
* Calls the onFlush callback with the events in the queue
6464
*/
6565
async flush() {
66-
const events = this.queueStore?.getState().events ?? [];
66+
const events = (await this.queueStore?.getState(true))?.events ?? [];
6767
if (!this.isPendingUpload) {
6868
try {
6969
this.isPendingUpload = true;

packages/core/src/plugins/SegmentDestination.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { uploadEvents } from '../api';
55
import type { SegmentClient } from '../analytics';
66
import { DestinationMetadataEnrichment } from './DestinationMetadataEnrichment';
77
import { QueueFlushingPlugin } from './QueueFlushingPlugin';
8+
import { checkResponseForErrors, translateHTTPError } from '../errors';
89

910
const MAX_EVENTS_PER_BATCH = 100;
1011
const MAX_PAYLOAD_SIZE_IN_KB = 500;
@@ -32,12 +33,14 @@ export class SegmentDestination extends DestinationPlugin {
3233
await Promise.all(
3334
chunkedEvents.map(async (batch: SegmentEvent[]) => {
3435
try {
35-
await uploadEvents({
36+
const res = await uploadEvents({
3637
config: this.analytics?.getConfig()!,
3738
events: batch,
3839
});
40+
checkResponseForErrors(res);
3941
sentEvents = sentEvents.concat(batch);
4042
} catch (e) {
43+
this.analytics?.reportInternalError(translateHTTPError(e));
4144
this.analytics?.logger.warn(e);
4245
numFailedEvents += batch.length;
4346
} finally {

packages/core/src/plugins/__tests__/SegmentDestination.test.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -237,7 +237,9 @@ describe('SegmentDestination', () => {
237237
.spyOn(plugin.queuePlugin.queueStore!, 'getState')
238238
.mockImplementation(createMockStoreGetter(() => ({ events })));
239239

240-
const sendEventsSpy = jest.spyOn(api, 'uploadEvents').mockResolvedValue();
240+
const sendEventsSpy = jest
241+
.spyOn(api, 'uploadEvents')
242+
.mockResolvedValue({ ok: true } as Response);
241243

242244
await plugin.flush();
243245

packages/core/src/timeline.ts

Lines changed: 8 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,8 @@
11
import { PluginType, SegmentEvent, UpdateType } from './types';
22
import type { DestinationPlugin, Plugin } from './plugin';
33
import { getAllPlugins } from './util';
4+
import { ErrorType, SegmentError } from './errors';
45

5-
/*
6-
type TimelinePlugins = {
7-
before?: Plugin[] | undefined;
8-
enrichment?: Plugin[] | undefined;
9-
destination?: Plugin[] | undefined;
10-
after?: Plugin[] | undefined;
11-
utility?: Plugin[] | undefined;
12-
}
13-
*/
146
type TimelinePlugins = {
157
[key in PluginType]?: Plugin[];
168
};
@@ -116,6 +108,13 @@ export class Timeline {
116108
result = await pluginResult;
117109
}
118110
} catch (error) {
111+
plugin.analytics?.reportInternalError(
112+
new SegmentError(
113+
ErrorType.PluginError,
114+
JSON.stringify(error),
115+
error
116+
)
117+
);
119118
plugin.analytics?.logger.warn(
120119
`Destination ${
121120
(plugin as DestinationPlugin).key

0 commit comments

Comments
 (0)