Skip to content

feat: added errorHandler option to client #713

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 2 commits into from
Nov 22, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 56 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@ The hassle-free way to add Segment analytics to your React-Native app.
- [Controlling Upload With Flush Policies](#controlling-upload-with-flush-policies)
- [Adding or removing policies](#adding-or-removing-policies)
- [Creating your own flush policies](#creating-your-own-flush-policies)
- [Handling errors](#handling-errors)
- [Reporting errors from plugins](#reporting-errors-from-plugins)
- [Contributing](#contributing)
- [Code of Conduct](#code-of-conduct)
- [License](#license)
Expand Down Expand Up @@ -621,6 +623,60 @@ export class FlushOnScreenEventsPolicy extends FlushPolicyBase {
}
```

## Handling errors

You can handle analytics client errors through the `errorHandler` option.

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.

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:

```ts
const flushPolicies = [new CountFlushPolicy(5), new TimerFlushPolicy(500)];

const errorHandler = (error: SegmentError) => {
if (error.type === ErrorType.NetworkServerLimited) {
// Remove all flush policies
segmentClient.removeFlushPolicy(...segmentClient.getFlushPolicies());
// Add less persistent flush policies
segmentClient.addFlushPolicy(
new CountFlushPolicy(100),
new TimerFlushPolicy(5000)
);
}
};

const segmentClient = createClient({
writeKey: 'WRITE_KEY',
trackAppLifecycleEvents: true,
collectDeviceId: true,
debug: true,
trackDeepLinks: true,
flushPolicies: flushPolicies,
errorHandler: errorHandler,
});

```

The reported errors can be of any of the [`ErrorType`](packages/core/src/errors.ts#L4) enum values.

### Reporting errors from plugins

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:

```ts
try {
distinctId = await mixpanel.getDistinctId();
} catch (e) {
analytics.reportInternalError(
new SegmentError(ErrorType.PluginError, 'Error: Mixpanel error calling getDistinctId', e)
);
analytics.logger.warn(e);
}
```




## Contributing

Expand Down
19 changes: 19 additions & 0 deletions packages/core/src/__tests__/analytics.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { AppStateStatus } from 'react-native';
import { AppState } from 'react-native';
import { SegmentClient } from '../analytics';
import { ErrorType, SegmentError } from '../errors';
import { getMockLogger } from './__helpers__/mockLogger';
import { MockSegmentStore } from './__helpers__/mockSegmentStore';

Expand Down Expand Up @@ -144,4 +145,22 @@ describe('SegmentClient', () => {
});
});
});

describe('Error Handler', () => {
it('calls the error handler when reportErrorInternal is called', () => {
const errorHandler = jest.fn();
client = new SegmentClient({
...clientArgs,
config: { ...clientArgs.config, errorHandler: errorHandler },
});

const error = new SegmentError(
ErrorType.NetworkUnknown,
'Some weird error'
);
client.reportInternalError(error);

expect(errorHandler).toHaveBeenCalledWith(error);
});
});
});
21 changes: 21 additions & 0 deletions packages/core/src/__tests__/internal/fetchSettings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ describe('internal #getSettings', () => {
it('fetches the settings succesfully ', async () => {
const mockJSONResponse = { integrations: { foo: 'bar' } };
const mockResponse = Promise.resolve({
ok: true,
json: () => mockJSONResponse,
});
// @ts-ignore
Expand Down Expand Up @@ -92,4 +93,24 @@ describe('internal #getSettings', () => {
);
expect(setSettingsSpy).not.toHaveBeenCalled();
});

it('fails to the settings succesfully and has no default settings for soft API errors', async () => {
const mockResponse = Promise.resolve({
ok: false,
status: 500,
});
// @ts-ignore
global.fetch = jest.fn(() => Promise.resolve(mockResponse));
const anotherClient = new SegmentClient({
...clientArgs,
config: { ...clientArgs.config, defaultSettings: undefined },
});

await anotherClient.fetchSettings();

expect(fetch).toHaveBeenCalledWith(
`${settingsCDN}/${clientArgs.config.writeKey}/settings`
);
expect(setSettingsSpy).not.toHaveBeenCalled();
});
});
21 changes: 20 additions & 1 deletion packages/core/src/analytics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,11 @@ import {
import { getPluginsWithFlush, getPluginsWithReset } from './util';
import { getUUID } from './uuid';
import type { FlushPolicy } from './flushPolicies';
import {
checkResponseForErrors,
SegmentError,
translateHTTPError,
} from './errors';

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

try {
const res = await fetch(settingsEndpoint);
checkResponseForErrors(res);

const resJson: SegmentAPISettings = await res.json();
const integrations = resJson.integrations;
const filters = this.generateFiltersMap(
Expand All @@ -277,14 +284,17 @@ export class SegmentClient {
this.store.settings.set(integrations),
this.store.filters.set(filters),
]);
} catch {
} catch (e) {
this.reportInternalError(translateHTTPError(e));

this.logger.warn(
`Could not receive settings from Segment. ${
this.config.defaultSettings
? 'Will use the default settings.'
: 'Device mode destinations will be ignored unless you specify default settings in the client config.'
}`
);

if (this.config.defaultSettings) {
await this.store.settings.set(this.config.defaultSettings.integrations);
}
Expand Down Expand Up @@ -727,4 +737,13 @@ export class SegmentClient {
getFlushPolicies() {
return this.flushPolicyExecuter.policies;
}

reportInternalError(error: SegmentError, fatal: boolean = false) {
if (fatal) {
this.logger.error('A critical error ocurred: ', error);
} else {
this.logger.warn('An internal error occurred: ', error);
}
this.config.errorHandler?.(error);
}
}
2 changes: 1 addition & 1 deletion packages/core/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ export const uploadEvents = async ({
events: SegmentEvent[];
}) => {
const requestUrl = config.proxy || batchApi;
await fetch(requestUrl, {
return await fetch(requestUrl, {
method: 'POST',
body: JSON.stringify({
batch: events,
Expand Down
119 changes: 119 additions & 0 deletions packages/core/src/errors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
/**
* Error types reported through the errorHandler in the client
*/
export enum ErrorType {
NetworkUnexpectedHTTPCode,
NetworkServerLimited,
NetworkServerRejected,
NetworkUnknown,

JsonUnableToSerialize,
JsonUnableToDeserialize,
JsonUnknown,

PluginError,
}

/**
* Segment Error object for ErrorHandler option
*/
export class SegmentError extends Error {
type: ErrorType;
message: string;
innerError?: unknown;

constructor(type: ErrorType, message: string, innerError?: unknown) {
super(message);
Object.setPrototypeOf(this, SegmentError.prototype);
this.type = type;
this.message = message;
this.innerError = innerError;
}
}

/**
* Custom Error type for Segment HTTP Error responses
*/
export class NetworkError extends SegmentError {
statusCode: number;
type:
| ErrorType.NetworkServerLimited
| ErrorType.NetworkServerRejected
| ErrorType.NetworkUnexpectedHTTPCode
| ErrorType.NetworkUnknown;

constructor(statusCode: number, message: string, innerError?: unknown) {
let type: ErrorType;
if (statusCode === 429) {
type = ErrorType.NetworkServerLimited;
} else if (statusCode > 300 && statusCode < 400) {
type = ErrorType.NetworkUnexpectedHTTPCode;
} else if (statusCode >= 400) {
type = ErrorType.NetworkServerRejected;
} else {
type = ErrorType.NetworkUnknown;
}

super(type, message, innerError);
Object.setPrototypeOf(this, NetworkError.prototype);

this.statusCode = statusCode;
this.type = type;
}
}

/**
* Error type for JSON Serialization errors
*/
export class JSONError extends SegmentError {
constructor(
type: ErrorType.JsonUnableToDeserialize | ErrorType.JsonUnableToSerialize,
message: string,
innerError?: unknown
) {
super(type, message, innerError);
Object.setPrototypeOf(this, JSONError.prototype);
}
}

/**
* Utility method for handling HTTP fetch errors
* @param response Fetch Response
* @returns response if status OK, throws NetworkError for everything else
*/
export const checkResponseForErrors = (response: Response) => {
if (!response.ok) {
throw new NetworkError(response.status, response.statusText);
}

return response;
};

/**
* Converts a .fetch() error to a SegmentError object for reporting to the error handler
* @param error any JS error instance
* @returns a SegmentError object
*/
export const translateHTTPError = (error: unknown): SegmentError => {
// SegmentError already
if (error instanceof SegmentError) {
return error;
// JSON Deserialization Errors
} else if (error instanceof SyntaxError) {
return new JSONError(
ErrorType.JsonUnableToDeserialize,
error.message,
error
);

// HTTP Errors
} else {
const message =
error instanceof Error
? error.message
: typeof error === 'string'
? error
: 'Unknown error';
return new NetworkError(-1, message, error);
}
};
1 change: 1 addition & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,4 @@ export { getNativeModule } from './util';
export { SegmentClient } from './analytics';
export { SegmentDestination } from './plugins/SegmentDestination';
export * from './flushPolicies';
export * from './errors';
2 changes: 1 addition & 1 deletion packages/core/src/plugins/QueueFlushingPlugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ export class QueueFlushingPlugin extends UtilityPlugin {
* Calls the onFlush callback with the events in the queue
*/
async flush() {
const events = this.queueStore?.getState().events ?? [];
const events = (await this.queueStore?.getState(true))?.events ?? [];
if (!this.isPendingUpload) {
try {
this.isPendingUpload = true;
Expand Down
5 changes: 4 additions & 1 deletion packages/core/src/plugins/SegmentDestination.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { uploadEvents } from '../api';
import type { SegmentClient } from '../analytics';
import { DestinationMetadataEnrichment } from './DestinationMetadataEnrichment';
import { QueueFlushingPlugin } from './QueueFlushingPlugin';
import { checkResponseForErrors, translateHTTPError } from '../errors';

const MAX_EVENTS_PER_BATCH = 100;
const MAX_PAYLOAD_SIZE_IN_KB = 500;
Expand Down Expand Up @@ -32,12 +33,14 @@ export class SegmentDestination extends DestinationPlugin {
await Promise.all(
chunkedEvents.map(async (batch: SegmentEvent[]) => {
try {
await uploadEvents({
const res = await uploadEvents({
config: this.analytics?.getConfig()!,
events: batch,
});
checkResponseForErrors(res);
sentEvents = sentEvents.concat(batch);
} catch (e) {
this.analytics?.reportInternalError(translateHTTPError(e));
this.analytics?.logger.warn(e);
numFailedEvents += batch.length;
} finally {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -237,7 +237,9 @@ describe('SegmentDestination', () => {
.spyOn(plugin.queuePlugin.queueStore!, 'getState')
.mockImplementation(createMockStoreGetter(() => ({ events })));

const sendEventsSpy = jest.spyOn(api, 'uploadEvents').mockResolvedValue();
const sendEventsSpy = jest
.spyOn(api, 'uploadEvents')
.mockResolvedValue({ ok: true } as Response);

await plugin.flush();

Expand Down
17 changes: 8 additions & 9 deletions packages/core/src/timeline.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,8 @@
import { PluginType, SegmentEvent, UpdateType } from './types';
import type { DestinationPlugin, Plugin } from './plugin';
import { getAllPlugins } from './util';
import { ErrorType, SegmentError } from './errors';

/*
type TimelinePlugins = {
before?: Plugin[] | undefined;
enrichment?: Plugin[] | undefined;
destination?: Plugin[] | undefined;
after?: Plugin[] | undefined;
utility?: Plugin[] | undefined;
}
*/
type TimelinePlugins = {
[key in PluginType]?: Plugin[];
};
Expand Down Expand Up @@ -116,6 +108,13 @@ export class Timeline {
result = await pluginResult;
}
} catch (error) {
plugin.analytics?.reportInternalError(
new SegmentError(
ErrorType.PluginError,
JSON.stringify(error),
error
)
);
plugin.analytics?.logger.warn(
`Destination ${
(plugin as DestinationPlugin).key
Expand Down
Loading