Skip to content

Commit d336df2

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

12 files changed

+443
-22
lines changed

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 SentryScopeModule from './sentryScope';
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(SentryScopeModule, '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 sentryScope, { reportError } from './sentryScope';
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+
sentryScope.setExtra('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/sentryScope.test.ts

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
// src/sentryScope.test.ts
2+
import * as Sentry from '@sentry/react-native';
3+
import sentryScope, { reportError } from './sentryScope';
4+
import { defaultStackParser, makeFetchTransport } from '@sentry/react';
5+
6+
jest.mock('@sentry/react-native', () => {
7+
return {
8+
ReactNativeClient: jest.fn().mockImplementation(() => ({
9+
init: jest.fn(),
10+
captureException: jest.fn(),
11+
})),
12+
Scope: jest.fn().mockImplementation(() => ({
13+
setClient: jest.fn(),
14+
addBreadcrumb: jest.fn(),
15+
captureException: jest.fn(),
16+
})),
17+
};
18+
});
19+
20+
describe('sentryScope', () => {
21+
afterEach(() => {
22+
jest.restoreAllMocks();
23+
jest.resetAllMocks();
24+
});
25+
26+
it('should initialise sentry scope with correct options', () => {
27+
expect(Sentry.ReactNativeClient).toHaveBeenCalledWith({
28+
attachStacktrace: true,
29+
autoInitializeNativeSdk: false,
30+
dsn: 'https://[email protected]/8',
31+
enableAppStartTracking: false,
32+
enableAutoPerformanceTracing: false,
33+
enableCaptureFailedRequests: false,
34+
enableNative: false,
35+
enableNativeCrashHandling: false,
36+
enableNativeFramesTracking: false,
37+
enableNativeNagger: false,
38+
enableNdk: false,
39+
enableStallTracking: true,
40+
enableUserInteractionTracing: false,
41+
enableWatchdogTerminationTracking: false,
42+
maxQueueSize: 30,
43+
parentSpanIsAlwaysRootSpan: true,
44+
patchGlobalPromise: true,
45+
sendClientReports: true,
46+
integrations: [],
47+
stackParser: defaultStackParser,
48+
transport: makeFetchTransport,
49+
});
50+
});
51+
});
52+
53+
describe('reportError', () => {
54+
afterEach(() => {
55+
jest.restoreAllMocks();
56+
jest.resetAllMocks();
57+
});
58+
59+
it('should add a breadcrumb if message is provided', () => {
60+
const error = new Error('Test error');
61+
const msg = 'Test message';
62+
reportError(error, { msg });
63+
64+
expect(sentryScope.addBreadcrumb).toHaveBeenCalledWith({
65+
category: 'Error',
66+
message: msg,
67+
level: 'log',
68+
});
69+
expect(sentryScope.captureException).toHaveBeenCalledWith(error);
70+
});
71+
72+
it('should capture exception without breadcrumb if no message is provided', () => {
73+
const error = new Error('Test error');
74+
reportError(error);
75+
76+
expect(sentryScope.addBreadcrumb).not.toHaveBeenCalled();
77+
expect(sentryScope.captureException).toHaveBeenCalledWith(error);
78+
});
79+
});

src/sentryScope.ts

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
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 default sentryScope;

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]!));

src/utils/validateSubscription.test.ts

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import parseContentpassToken, * as ParseContentpassTokenModule from './parseContentpassToken';
2+
import * as SentryScopeModule from '../sentryScope';
23
import validateSubscription from './validateSubscription';
34

45
const EXAMPLE_CONTENTPASS_TOKEN: ReturnType<typeof parseContentpassToken> = {
@@ -18,10 +19,16 @@ const EXAMPLE_CONTENTPASS_TOKEN: ReturnType<typeof parseContentpassToken> = {
1819

1920
describe('validateSubscription', () => {
2021
let parseContentpassTokenSpy: jest.SpyInstance;
22+
let reportErrorSpy: jest.SpyInstance;
23+
2124
beforeEach(() => {
2225
parseContentpassTokenSpy = jest
2326
.spyOn(ParseContentpassTokenModule, 'default')
2427
.mockReturnValue(EXAMPLE_CONTENTPASS_TOKEN);
28+
29+
reportErrorSpy = jest
30+
.spyOn(SentryScopeModule, 'reportError')
31+
.mockReturnValue(undefined);
2532
});
2633

2734
afterEach(() => {
@@ -36,13 +43,17 @@ describe('validateSubscription', () => {
3643
expect(result).toBe(true);
3744
});
3845

39-
it('should return false if the token is invalid', () => {
46+
it('should return false and report error if the token is invalid', () => {
47+
const error = new Error('Invalid token');
4048
parseContentpassTokenSpy.mockImplementation(() => {
41-
throw new Error('Invalid token');
49+
throw error;
4250
});
4351
const result = validateSubscription('example_contentpass_token');
4452

4553
expect(result).toBe(false);
54+
expect(reportErrorSpy).toHaveBeenCalledWith(error, {
55+
msg: 'Failed to validate subscription',
56+
});
4657
});
4758

4859
it('should return false if the user is not authenticated', () => {

0 commit comments

Comments
 (0)