Skip to content

Commit 11f4eb0

Browse files
committed
feat: configure sentry with SDK
1 parent 24ae731 commit 11f4eb0

13 files changed

+475
-22
lines changed

.eslintignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,3 +2,4 @@ node_modules/
22
lib/
33
ios/
44
android/
5+
coverage/

jest-setup.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,3 +11,19 @@ jest.mock('react-native-encrypted-storage', () => ({
1111
removeItem: jest.fn(() => Promise.resolve()),
1212
clear: jest.fn(() => Promise.resolve()),
1313
}));
14+
15+
jest.mock('@sentry/react-native', () => ({
16+
ReactNativeClient: jest.fn().mockReturnValue({
17+
init: jest.fn(),
18+
}),
19+
Scope: jest.fn().mockReturnValue({
20+
setClient: jest.fn(),
21+
setExtra: jest.fn(),
22+
addBreadcrumb: jest.fn(),
23+
captureException: jest.fn(),
24+
}),
25+
}));
26+
27+
jest.mock('@sentry/react-native/dist/js/integrations/default', () => ({
28+
getDefaultIntegrations: jest.fn().mockReturnValue([]),
29+
}));

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,7 @@
141141
"version": "0.44.1"
142142
},
143143
"dependencies": {
144+
"@sentry/react-native": "^6.3.0",
144145
"lodash": "^4.17.21"
145146
}
146147
}

src/Contentpass.test.ts

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import type { ContentpassState } from './types/ContentpassState';
77
import OidcAuthStateStorage from './OidcAuthStateStorage';
88
import * as FetchContentpassTokenModule from './utils/fetchContentpassToken';
99
import { SCOPES } from './consts/oidcConsts';
10+
import * as SentryIntegrationModule from './sentryIntegration';
1011

1112
const config: ContentpassConfig = {
1213
propertyId: 'propertyId-1',
@@ -39,6 +40,7 @@ describe('Contentpass', () => {
3940
let contentpass: Contentpass;
4041
let authorizeSpy: jest.SpyInstance;
4142
let refreshSpy: jest.SpyInstance;
43+
let reportErrorSpy: jest.SpyInstance;
4244
let fetchContentpassTokenSpy: jest.SpyInstance;
4345
let oidcAuthStorageMock: OidcAuthStateStorage;
4446

@@ -50,6 +52,9 @@ describe('Contentpass', () => {
5052
refreshSpy = jest
5153
.spyOn(AppAuthModule, 'refresh')
5254
.mockResolvedValue(EXAMPLE_REFRESH_RESULT);
55+
reportErrorSpy = jest
56+
.spyOn(SentryIntegrationModule, 'reportError')
57+
.mockReturnValue(undefined);
5358

5459
oidcAuthStorageMock = {
5560
storeOidcAuthState: jest.fn(),
@@ -166,13 +171,18 @@ describe('Contentpass', () => {
166171

167172
it('should change contentpass state to error when authorize throws an error', async () => {
168173
const contentpassStates: ContentpassState[] = [];
169-
authorizeSpy.mockRejectedValue(new Error('Authorize error'));
174+
const error = new Error('Authorize error');
175+
authorizeSpy.mockRejectedValue(error);
170176
contentpass.registerObserver((state) => {
171177
contentpassStates.push(state);
172178
});
173179

174180
await contentpass.authenticate();
175181

182+
expect(reportErrorSpy).toHaveBeenCalledWith(error, {
183+
msg: 'Failed to authorize',
184+
});
185+
176186
expect(contentpassStates).toHaveLength(2);
177187
expect(contentpassStates[1]).toEqual({
178188
state: 'ERROR',
@@ -291,7 +301,8 @@ describe('Contentpass', () => {
291301
).getTime();
292302
const expectedDelay = expirationDate - NOW;
293303

294-
refreshSpy.mockRejectedValue(new Error('Refresh error'));
304+
const refreshError = new Error('Refresh error');
305+
refreshSpy.mockRejectedValue(refreshError);
295306

296307
await jest.advanceTimersByTimeAsync(expectedDelay);
297308
expect(contentpassStates).toHaveLength(2);
@@ -308,7 +319,11 @@ describe('Contentpass', () => {
308319
});
309320

310321
// after 6 retries the state should change to error
311-
await jest.advanceTimersByTimeAsync(180001);
322+
await jest.advanceTimersByTimeAsync(120001);
323+
expect(reportErrorSpy).toHaveBeenCalledTimes(7);
324+
expect(reportErrorSpy).toHaveBeenCalledWith(refreshError, {
325+
msg: 'Failed to refresh token after 6 retries',
326+
});
312327
expect(contentpassStates).toHaveLength(3);
313328
expect(contentpassStates[2]).toEqual({
314329
state: 'UNAUTHENTICATED',

src/Contentpass.ts

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import { RefreshTokenStrategy } from './types/RefreshTokenStrategy';
1616
import fetchContentpassToken from './utils/fetchContentpassToken';
1717
import validateSubscription from './utils/validateSubscription';
1818
import type { ContentpassConfig } from './types/ContentpassConfig';
19+
import { reportError, setSentryExtraAttribute } from './sentryIntegration';
1920

2021
export type ContentpassObserver = (state: ContentpassState) => void;
2122

@@ -33,6 +34,7 @@ export default class Contentpass {
3334
constructor(config: ContentpassConfig) {
3435
this.authStateStorage = new OidcAuthStateStorage(config.propertyId);
3536
this.config = config;
37+
setSentryExtraAttribute('propertyId', config.propertyId);
3638
this.initialiseAuthState();
3739
}
3840

@@ -52,7 +54,7 @@ export default class Contentpass {
5254
},
5355
});
5456
} catch (err: any) {
55-
// FIXME: logger for error
57+
reportError(err, { msg: 'Failed to authorize' });
5658

5759
this.changeContentpassState({
5860
state: ContentpassStateType.ERROR,
@@ -166,7 +168,7 @@ export default class Contentpass {
166168

167169
private refreshToken = async (counter: number) => {
168170
if (!this.oidcAuthState?.refreshToken) {
169-
// FIXME: logger for error
171+
reportError(new Error('No Refresh Token in oidcAuthState provided'));
170172
return;
171173
}
172174

@@ -183,17 +185,17 @@ export default class Contentpass {
183185
}
184186
);
185187
await this.onNewAuthState(refreshResult);
186-
} catch (err) {
188+
} catch (err: any) {
187189
await this.onRefreshTokenError(counter, err);
188190
}
189191
};
190192

191-
// @ts-expect-error remove when err starts being used
192-
// eslint-disable-next-line @typescript-eslint/no-unused-vars
193-
private onRefreshTokenError = async (counter: number, err: unknown) => {
194-
// FIXME: logger for error
193+
private onRefreshTokenError = async (counter: number, err: Error) => {
194+
reportError(err, {
195+
msg: `Failed to refresh token after ${counter} retries`,
196+
});
195197
// FIXME: add handling for specific error to not retry in every case
196-
if (counter <= REFRESH_TOKEN_RETRIES) {
198+
if (counter < REFRESH_TOKEN_RETRIES) {
197199
const delay = counter * 1000 * 10;
198200
await new Promise((resolve) => setTimeout(resolve, delay));
199201
await this.refreshToken(counter + 1);

src/index.test.tsx

Lines changed: 0 additions & 3 deletions
This file was deleted.

src/sentryIntegration.test.ts

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
import * as Sentry from '@sentry/react-native';
2+
import { defaultStackParser, makeFetchTransport } from '@sentry/react';
3+
import * as SentryReactNativeModule from '@sentry/react-native';
4+
import { reportError, setSentryExtraAttribute } from './sentryIntegration';
5+
6+
jest.mock('@sentry/react-native', () => {
7+
const scope = {
8+
setClient: jest.fn(),
9+
addBreadcrumb: jest.fn(),
10+
captureException: jest.fn(),
11+
setExtra: jest.fn(),
12+
};
13+
return {
14+
ReactNativeClient: jest.fn().mockImplementation(() => ({
15+
init: jest.fn(),
16+
captureException: jest.fn(),
17+
})),
18+
Scope: jest.fn().mockImplementation(() => scope),
19+
20+
// Only for internal testing
21+
__test_getScope: () => scope,
22+
};
23+
});
24+
25+
describe('sentryScope', () => {
26+
let addBreadcrumbMock: jest.Mock;
27+
let captureExceptionMock: jest.Mock;
28+
29+
beforeEach(() => {
30+
addBreadcrumbMock = jest.fn();
31+
captureExceptionMock = jest.fn();
32+
jest.spyOn(SentryReactNativeModule, 'Scope').mockReturnValue({
33+
setClient: jest.fn(),
34+
addBreadcrumb: addBreadcrumbMock,
35+
captureException: captureExceptionMock,
36+
} as any);
37+
});
38+
39+
afterEach(() => {
40+
jest.restoreAllMocks();
41+
jest.resetAllMocks();
42+
});
43+
44+
it('should initialise sentry scope with correct options', () => {
45+
expect(Sentry.ReactNativeClient).toHaveBeenCalledWith({
46+
attachStacktrace: true,
47+
autoInitializeNativeSdk: false,
48+
dsn: 'https://[email protected]/8',
49+
enableAppStartTracking: false,
50+
enableAutoPerformanceTracing: false,
51+
enableCaptureFailedRequests: false,
52+
enableNative: false,
53+
enableNativeCrashHandling: false,
54+
enableNativeFramesTracking: false,
55+
enableNativeNagger: false,
56+
enableNdk: false,
57+
enableStallTracking: true,
58+
enableUserInteractionTracing: false,
59+
enableWatchdogTerminationTracking: false,
60+
maxQueueSize: 30,
61+
parentSpanIsAlwaysRootSpan: true,
62+
patchGlobalPromise: true,
63+
sendClientReports: true,
64+
integrations: [],
65+
stackParser: defaultStackParser,
66+
transport: makeFetchTransport,
67+
});
68+
});
69+
70+
describe('reportError', () => {
71+
it('should add breadcrumb with message if provided', () => {
72+
const err = new Error('test');
73+
const msg = 'test message';
74+
// @ts-ignore
75+
const { addBreadcrumb, captureException } = Sentry.__test_getScope();
76+
reportError(err, { msg });
77+
78+
expect(addBreadcrumb).toHaveBeenCalledWith({
79+
category: 'Error',
80+
message: msg,
81+
level: 'log',
82+
});
83+
expect(captureException).toHaveBeenCalledWith(err);
84+
});
85+
86+
it('should not add breadcrumb if message is not provided', () => {
87+
const err = new Error('test');
88+
// @ts-ignore
89+
const { addBreadcrumb, captureException } = Sentry.__test_getScope();
90+
reportError(err);
91+
92+
expect(addBreadcrumb).not.toHaveBeenCalled();
93+
expect(captureException).toHaveBeenCalledWith(err);
94+
});
95+
});
96+
97+
describe('setSentryExtraAttribute', () => {
98+
it('should set extra attribute', () => {
99+
const key = 'testKey';
100+
const value = 'testValue';
101+
// @ts-ignore
102+
const { setExtra } = Sentry.__test_getScope();
103+
setSentryExtraAttribute(key, value);
104+
105+
expect(setExtra).toHaveBeenCalledWith(key, value);
106+
});
107+
});
108+
});

src/sentryIntegration.ts

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import * as Sentry from '@sentry/react-native';
2+
import { defaultStackParser, makeFetchTransport } from '@sentry/react';
3+
import { getDefaultIntegrations } from '@sentry/react-native/dist/js/integrations/default';
4+
5+
// as it's only the open source package, we want to have minimal sentry configuration here to not override sentry instance,
6+
// which can be used in the application
7+
const options = {
8+
attachStacktrace: true,
9+
autoInitializeNativeSdk: false,
10+
dsn: 'https://[email protected]/8',
11+
enableAppStartTracking: false,
12+
enableAutoPerformanceTracing: false,
13+
enableCaptureFailedRequests: false,
14+
enableNative: false,
15+
enableNativeCrashHandling: false,
16+
enableNativeFramesTracking: false,
17+
enableNativeNagger: false,
18+
enableNdk: false,
19+
enableStallTracking: true,
20+
enableUserInteractionTracing: false,
21+
enableWatchdogTerminationTracking: false,
22+
maxQueueSize: 30,
23+
parentSpanIsAlwaysRootSpan: true,
24+
patchGlobalPromise: true,
25+
sendClientReports: true,
26+
stackParser: defaultStackParser,
27+
transport: makeFetchTransport,
28+
integrations: [],
29+
};
30+
31+
const sentryClient = new Sentry.ReactNativeClient({
32+
...options,
33+
integrations: getDefaultIntegrations(options),
34+
});
35+
36+
const sentryScope = new Sentry.Scope();
37+
sentryScope.setClient(sentryClient);
38+
39+
sentryClient.init();
40+
41+
type ReportErrorOptions = {
42+
msg?: string;
43+
};
44+
45+
export const reportError = (err: Error, { msg }: ReportErrorOptions = {}) => {
46+
if (msg) {
47+
sentryScope.addBreadcrumb({
48+
category: 'Error',
49+
message: msg,
50+
level: 'log',
51+
});
52+
}
53+
54+
sentryScope.captureException(err);
55+
};
56+
57+
export const setSentryExtraAttribute = (key: string, value: string) => {
58+
sentryScope.setExtra(key, value);
59+
};

src/utils/parseContentpassToken.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,6 @@ describe('parseContentpassToken', () => {
3636

3737
expect(() => {
3838
parseContentpassToken(invalidToken);
39-
}).toThrow('Invalid token');
39+
}).toThrow('Invalid token, token should have at least 3 parts');
4040
});
4141
});

src/utils/parseContentpassToken.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ export default function parseContentpassToken(
1818
): ParsedToken {
1919
const tokenParts = contentpassToken.split('.');
2020
if (tokenParts.length < 3) {
21-
throw new Error('Invalid token');
21+
throw new Error('Invalid token, token should have at least 3 parts');
2222
}
2323

2424
const header = JSON.parse(atob(tokenParts[0]!));

0 commit comments

Comments
 (0)