diff --git a/README.md b/README.md
index ba02be9..4253636 100644
--- a/README.md
+++ b/README.md
@@ -41,6 +41,8 @@ Wrap your app's root component with ContentpassSdkProvider. The provider require
- `planId` - The ID of the plan you want to check the user's subscription status against (ask Contentpass team for details)
- `issuer` - The OAuth 2.0 server URL (e.g. `https://my.contentpass.net`)
- `redirectUrl` - the redirect URL of your app to which the OAuth2 server will redirect after the authentication
+- `samplingRate` - Optional: The rate at which the SDK will send impression events for unauthenticated users. Default is 0.05 (5%)
+- `logLevel` - Optional: The log level for the SDK. By default logger is disabled. Possible values are 'info', 'warn', 'error' and 'debug'
```jsx
@@ -52,6 +54,8 @@ const contentpassConfig = {
planId: 'plan-id',
issuer: 'https://my.contentpass.net',
redirectUrl: 'com.yourapp://oauthredirect',
+ samplingRate: 0.1,
+ logLevel: 'info'
};
const App = () => {
@@ -72,6 +76,10 @@ The SDK exposes the following methods through the `useContentpassSdk` hook:
Initiates the OAuth 2.0 authentication process via a modal interface. It validates the user’s active Contentpass subscriptions
upon successful authentication.
+### countImpression
+Tracks and increments the impression count for the current user. This method should be invoked whenever a user views a
+piece of content. It applies to all users, whether authenticated or unauthenticated.
+
### registerObserver
Registers a callback function to listen for changes in the user’s authentication and subscription status. The observer function
receives a state object describing the current status (see the exported [ContentpassState](./src/types/ContentpassState.ts) type).
@@ -96,6 +104,7 @@ import { Button, View } from 'react-native';
const YourApp = () => {
const {
authenticate,
+ countImpression,
registerObserver,
unregisterObserver,
logout,
@@ -117,6 +126,7 @@ const YourApp = () => {
return (
+
);
};
diff --git a/package.json b/package.json
index 48e6b15..9a66f5a 100644
--- a/package.json
+++ b/package.json
@@ -150,6 +150,8 @@
"version": "0.44.1"
},
"dependencies": {
- "@sentry/react-native": "^6.3.0"
+ "@sentry/react-native": "^6.3.0",
+ "react-native-logs": "^5.3.0",
+ "react-native-uuid": "^2.0.3"
}
}
diff --git a/sharedExample/src/ContentpassUsage.tsx b/sharedExample/src/ContentpassUsage.tsx
index 1168a34..6a49cdf 100644
--- a/sharedExample/src/ContentpassUsage.tsx
+++ b/sharedExample/src/ContentpassUsage.tsx
@@ -93,6 +93,10 @@ export default function ContentpassUsage() {
spConsentManager.current?.loadMessage();
};
+ const countImpression = async () => {
+ await contentpassSdk.countImpression();
+ };
+
return (
<>
@@ -100,6 +104,7 @@ export default function ContentpassUsage() {
title={'Clear sourcepoint data'}
onPress={clearSourcepointData}
/>
+
diff --git a/sharedExample/src/contentpassConfig.ts b/sharedExample/src/contentpassConfig.ts
index ffd0270..1b84d8a 100644
--- a/sharedExample/src/contentpassConfig.ts
+++ b/sharedExample/src/contentpassConfig.ts
@@ -5,10 +5,14 @@ export const contentpassConfig: ContentpassConfig = {
propertyId: 'cc3fc4ad-cbe5-4d09-bf85-a49796603b19',
planId: 'a4721db5-67df-4145-bbbf-cbd09f7e0397',
issuer: 'https://my.contentpass.dev',
+ apiUrl: 'https://cp.cmp-sourcepoint.contenttimes.dev',
// Staging app
// propertyId: '78da2fd3-8b25-4642-b7b7-4a0193d00f89',
// planId: '50abfd7f-8a5d-43c9-8a8c-0cb4b0cefe96',
// issuer: 'https://my.contentpass.io',
+ // apiUrl: 'cp.cmp-sourcepoint.contenttimes.io',
+ samplingRate: 1,
redirectUrl: 'de.contentpass.demo://oauth',
+ logLevel: 'debug',
};
diff --git a/src/Contentpass.test.ts b/src/Contentpass.test.ts
index 93cfc0b..183d874 100644
--- a/src/Contentpass.test.ts
+++ b/src/Contentpass.test.ts
@@ -5,15 +5,19 @@ import * as AppAuthModule from 'react-native-app-auth';
import * as OidcAuthStateStorageModule from './OidcAuthStateStorage';
import type { ContentpassState } from './types/ContentpassState';
import OidcAuthStateStorage from './OidcAuthStateStorage';
-import * as FetchContentpassTokenModule from './utils/fetchContentpassToken';
+import * as FetchContentpassTokenModule from './contentpassTokenUtils/fetchContentpassToken';
import { SCOPES } from './consts/oidcConsts';
import * as SentryIntegrationModule from './sentryIntegration';
+import * as SendStatsModule from './countImpressionUtils/sendStats';
+import * as SendPageViewEventModule from './countImpressionUtils/sendPageViewEvent';
const config: ContentpassConfig = {
- propertyId: 'propertyId-1',
+ propertyId: '5803179c-5b9f-40be-9a91-e67e8ea20593',
planId: 'planId-1',
redirectUrl: 'de.test.net://oauth',
issuer: 'https://issuer.net',
+ apiUrl: 'https://cp.property.com',
+ samplingRate: 1,
};
const NOW = new Date('2024-12-02T11:53:56.272Z').getTime();
@@ -37,6 +41,12 @@ const EXAMPLE_REFRESH_RESULT = {
accessTokenExpirationDate: '2024-12-03T10:00:50Z',
};
+const CONTENTPASS_TOKEN_WITH_PLANS =
+ 'eyJhbGciOiJSUzI1NiIsImtpZCI6IjY5NzUwYTZjLTNmYjctNDUyNi05NWY4LTVhZmYxMmIyZjFjOSJ9.eyJhdXRoIjp0cnVlLCJ0eXBlIjoiY3AiLCJwbGFucyI6WyIwYWNhZTkxNy1iZTk5LTQ4ZWEtYjhmMS0yMGZhNjhhNDdkM2EiLCI0NDIxNjI4Yy05NjA2LTRjMDEtOGU1ZC1jMmE5YmNhNjhhYjQiLCI3ZThkZTBjYy0zZTk3LTQ5YTItODgxZC05ZmZiNWI4NDE1MTUiLCJhNDcyMWRiNS02N2RmLTQxNDUtYmJiZi1jYmQwOWY3ZTAzOTciLCJjNGQzYjBmNS05ODlhLTRmN2ItOGFjNy0zZDhmZmE5NTcxN2YiLCI2NGRkOTkwNS05NmUxLTRmYjItOTgwZC01MDdmMTYzNzVmZTkiXSwiYXVkIjoiY2MzZmM0YWQiLCJpYXQiOjE3MzMxMzU2ODEsImV4cCI6MTczMzMxMjA4MX0.CMtH7HRLf2HVgw3_cZRN0en8tml_SQKM73iLGJAp72-vJuRJaq85xBp6Jgy9WD3L7x4itRlBAYZxX8tLxZGogU0WP4_dMGFQ2QlcwKshwJygwRM1YqvxGWX2Az_KxEMc2QGHvpE1qe2MAr_xOU7VFfc0-vWxFc3hRzpAM5j7YHctj2t1v6h9-M7V2Hkcn37569QmtgU8gJkUxXsgUTufbb1ikjjjAvnjvTluHJo51_utbimpUbCk3EFxXVCVEI_pAqiZQXNninUQ6dbSujLb3L2UlEdQzLeUiBdYroeFzSyruLrR841ledLQ5ZP2OqzF5oUMuAGVOOhmgGdwGMCDRQ';
+
+export const CONTENTPASS_TOKEN_WITHOUT_PLANS =
+ 'eyJhbGciOiJSUzI1NiIsImtpZCI6IjY5NzUwYTZjLTNmYjctNDUyNi05NWY4LTVhZmYxMmIyZjFjOSJ9.eyJhdXRoIjp0cnVlLCJ0eXBlIjoiY3AiLCJwbGFucyI6W10sImF1ZCI6ImNjM2ZjNGFkIiwiaWF0IjoxNzMzMTM1NjgxLCJleHAiOjE3MzMzMTIwODF9.CMtH7HRLf2HVgw3_cZRN0en8tml_SQKM73iLGJAp72-vJuRJaq85xBp6Jgy9WD3L7x4itRlBAYZxX8tLxZGogU0WP4_dMGFQ2QlcwKshwJygwRM1YqvxGWX2Az_KxEMc2QGHvpE1qe2MAr_xOU7VFfc0-vWxFc3hRzpAM5j7YHctj2t1v6h9-M7V2Hkcn37569QmtgU8gJkUxXsgUTufbb1ikjjjAvnjvTluHJo51_utbimpUbCk3EFxXVCVEI_pAqiZQXNninUQ6dbSujLb3L2UlEdQzLeUiBdYroeFzSyruLrR841ledLQ5ZP2OqzF5oUMuAGVOOhmgGdwGMCDRQ';
+
describe('Contentpass', () => {
let contentpass: Contentpass;
let authorizeSpy: jest.SpyInstance;
@@ -44,6 +54,8 @@ describe('Contentpass', () => {
let reportErrorSpy: jest.SpyInstance;
let fetchContentpassTokenSpy: jest.SpyInstance;
let oidcAuthStorageMock: OidcAuthStateStorage;
+ let sendStatsSpy: jest.SpyInstance;
+ let sendPageViewEventSpy: jest.SpyInstance;
beforeEach(() => {
jest.useFakeTimers({ now: NOW });
@@ -69,9 +81,15 @@ describe('Contentpass', () => {
fetchContentpassTokenSpy = jest
.spyOn(FetchContentpassTokenModule, 'default')
- .mockResolvedValue(
- 'eyJhbGciOiJSUzI1NiIsImtpZCI6IjY5NzUwYTZjLTNmYjctNDUyNi05NWY4LTVhZmYxMmIyZjFjOSJ9.eyJhdXRoIjp0cnVlLCJ0eXBlIjoiY3AiLCJwbGFucyI6WyIwYWNhZTkxNy1iZTk5LTQ4ZWEtYjhmMS0yMGZhNjhhNDdkM2EiLCI0NDIxNjI4Yy05NjA2LTRjMDEtOGU1ZC1jMmE5YmNhNjhhYjQiLCI3ZThkZTBjYy0zZTk3LTQ5YTItODgxZC05ZmZiNWI4NDE1MTUiLCJhNDcyMWRiNS02N2RmLTQxNDUtYmJiZi1jYmQwOWY3ZTAzOTciLCJjNGQzYjBmNS05ODlhLTRmN2ItOGFjNy0zZDhmZmE5NTcxN2YiLCI2NGRkOTkwNS05NmUxLTRmYjItOTgwZC01MDdmMTYzNzVmZTkiXSwiYXVkIjoiY2MzZmM0YWQiLCJpYXQiOjE3MzMxMzU2ODEsImV4cCI6MTczMzMxMjA4MX0.CMtH7HRLf2HVgw3_cZRN0en8tml_SQKM73iLGJAp72-vJuRJaq85xBp6Jgy9WD3L7x4itRlBAYZxX8tLxZGogU0WP4_dMGFQ2QlcwKshwJygwRM1YqvxGWX2Az_KxEMc2QGHvpE1qe2MAr_xOU7VFfc0-vWxFc3hRzpAM5j7YHctj2t1v6h9-M7V2Hkcn37569QmtgU8gJkUxXsgUTufbb1ikjjjAvnjvTluHJo51_utbimpUbCk3EFxXVCVEI_pAqiZQXNninUQ6dbSujLb3L2UlEdQzLeUiBdYroeFzSyruLrR841ledLQ5ZP2OqzF5oUMuAGVOOhmgGdwGMCDRQ'
- );
+ .mockResolvedValue(CONTENTPASS_TOKEN_WITH_PLANS);
+
+ sendStatsSpy = jest
+ .spyOn(SendStatsModule, 'default')
+ .mockResolvedValue({ ok: true } as any);
+
+ sendPageViewEventSpy = jest
+ .spyOn(SendPageViewEventModule, 'default')
+ .mockResolvedValue({ ok: true } as any);
contentpass = new Contentpass(config);
});
@@ -83,6 +101,24 @@ describe('Contentpass', () => {
});
describe('constructor', () => {
+ it('should throw an error if sampling rate is not between 0 and 1', () => {
+ expect(
+ () =>
+ new Contentpass({
+ ...config,
+ samplingRate: -1,
+ })
+ ).toThrow('Sampling rate must be between 0 and 1');
+
+ expect(
+ () =>
+ new Contentpass({
+ ...config,
+ samplingRate: 2,
+ })
+ ).toThrow('Sampling rate must be between 0 and 1');
+ });
+
it('should initialise contentpass state', () => {
const contentpassStates: ContentpassState[] = [];
contentpass.registerObserver((state) => {
@@ -138,9 +174,9 @@ describe('Contentpass', () => {
expect(refreshSpy).toHaveBeenCalledTimes(1);
expect(refreshSpy).toHaveBeenCalledWith(
{
- clientId: 'propertyId-1',
- redirectUrl: 'de.test.net://oauth',
- issuer: 'https://issuer.net',
+ clientId: config.propertyId,
+ redirectUrl: config.redirectUrl,
+ issuer: config.issuer,
scopes: SCOPES,
},
{ refreshToken: EXAMPLE_AUTH_RESULT.refreshToken }
@@ -159,14 +195,14 @@ describe('Contentpass', () => {
expect(authorizeSpy).toHaveBeenCalledWith({
additionalParameters: {
- cp_property: 'propertyId-1',
- cp_plan: 'planId-1',
+ cp_property: config.propertyId,
+ cp_plan: config.planId,
cp_route: 'login',
prompt: 'consent',
},
- clientId: 'propertyId-1',
- issuer: 'https://issuer.net',
- redirectUrl: 'de.test.net://oauth',
+ clientId: config.propertyId,
+ issuer: config.issuer,
+ redirectUrl: config.redirectUrl,
scopes: ['openid', 'offline_access', 'contentpass'],
});
});
@@ -250,9 +286,9 @@ describe('Contentpass', () => {
expect(refreshSpy).toHaveBeenCalledTimes(1);
expect(refreshSpy).toHaveBeenCalledWith(
{
- clientId: 'propertyId-1',
- redirectUrl: 'de.test.net://oauth',
- issuer: 'https://issuer.net',
+ clientId: config.propertyId,
+ redirectUrl: config.redirectUrl,
+ issuer: config.issuer,
scopes: SCOPES,
},
{ refreshToken: EXAMPLE_AUTH_RESULT.refreshToken }
@@ -325,7 +361,7 @@ describe('Contentpass', () => {
// after 6 retries the state should change to error
await jest.advanceTimersByTimeAsync(120001);
- expect(reportErrorSpy).toHaveBeenCalledTimes(7);
+ expect(reportErrorSpy).toHaveBeenCalledTimes(1);
expect(reportErrorSpy).toHaveBeenCalledWith(refreshError, {
msg: 'Failed to refresh token after 6 retries',
});
@@ -438,4 +474,78 @@ describe('Contentpass', () => {
});
});
});
+
+ describe('countImpression', () => {
+ it('should count paid impression if user has valid subscription and access token', async () => {
+ await contentpass.authenticate();
+
+ await contentpass.countImpression();
+
+ expect(sendPageViewEventSpy).toHaveBeenCalledWith(config.apiUrl, {
+ propertyId: config.propertyId,
+ impressionId: expect.any(String),
+ accessToken: EXAMPLE_AUTH_RESULT.accessToken,
+ });
+ expect(sendStatsSpy).toHaveBeenCalled();
+ });
+
+ it('should not count paid impression if user is authenticated, but does not have valid subscription', async () => {
+ fetchContentpassTokenSpy.mockResolvedValue(
+ CONTENTPASS_TOKEN_WITHOUT_PLANS
+ );
+ await contentpass.authenticate();
+
+ await contentpass.countImpression();
+
+ expect(sendPageViewEventSpy).not.toHaveBeenCalled();
+ expect(sendStatsSpy).toHaveBeenCalled();
+ });
+
+ it('should report error if counting paid impression fails', async () => {
+ await contentpass.authenticate();
+
+ const error = new Error('Send page view event error');
+ sendPageViewEventSpy.mockRejectedValue(error);
+
+ await contentpass.countImpression();
+
+ expect(reportErrorSpy).toHaveBeenCalledWith(error, {
+ msg: 'Failed to count paid impression',
+ });
+ expect(sendStatsSpy).toHaveBeenCalled();
+ });
+
+ it('should count sampled impression even if user does not have valid subscription', async () => {
+ await contentpass.countImpression();
+
+ expect(sendPageViewEventSpy).not.toHaveBeenCalled();
+ expect(sendStatsSpy).toHaveBeenCalledWith(config.apiUrl, {
+ cpabid: expect.any(String),
+ cppid: '5803179c',
+ cpsr: config.samplingRate,
+ ea: 'load',
+ ec: 'tcf-sampled',
+ });
+ });
+
+ it('should report error if counting sampled impression fails', async () => {
+ const error = new Error('Send stats error');
+ sendStatsSpy.mockRejectedValue(error);
+
+ await contentpass.countImpression();
+
+ expect(reportErrorSpy).toHaveBeenCalledWith(error, {
+ msg: 'Failed to count sampled impression',
+ });
+ });
+
+ it('should not send sampled impression when generatedSample is higher than samplingRate', async () => {
+ jest.spyOn(Math, 'random').mockReturnValue(0.5);
+ contentpass = new Contentpass({ ...config, samplingRate: 0.4 });
+
+ await contentpass.countImpression();
+ expect(sendStatsSpy).not.toHaveBeenCalled();
+ expect(sendPageViewEventSpy).not.toHaveBeenCalled();
+ });
+ });
});
diff --git a/src/Contentpass.ts b/src/Contentpass.ts
index eb49913..c4a9acb 100644
--- a/src/Contentpass.ts
+++ b/src/Contentpass.ts
@@ -1,3 +1,4 @@
+import uuid from 'react-native-uuid';
import OidcAuthStateStorage, {
type OidcAuthState,
} from './OidcAuthStateStorage';
@@ -12,16 +13,31 @@ import {
} from 'react-native-app-auth';
import { REFRESH_TOKEN_RETRIES, SCOPES } from './consts/oidcConsts';
import { RefreshTokenStrategy } from './types/RefreshTokenStrategy';
-import fetchContentpassToken from './utils/fetchContentpassToken';
-import validateSubscription from './utils/validateSubscription';
+import fetchContentpassToken from './contentpassTokenUtils/fetchContentpassToken';
+import validateSubscription from './contentpassTokenUtils/validateSubscription';
import type { ContentpassConfig } from './types/ContentpassConfig';
import { reportError, setSentryExtraAttribute } from './sentryIntegration';
+import sendStats from './countImpressionUtils/sendStats';
+import sendPageViewEvent from './countImpressionUtils/sendPageViewEvent';
+import logger, { enableLogger } from './logger';
+
+const DEFAULT_SAMPLING_RATE = 0.05;
export type ContentpassObserver = (state: ContentpassState) => void;
-export default class Contentpass {
+interface ContentpassInterface {
+ authenticate: () => Promise;
+ registerObserver: (observer: ContentpassObserver) => void;
+ unregisterObserver: (observer: ContentpassObserver) => void;
+ logout: () => Promise;
+ recoverFromError: () => Promise;
+ countImpression: () => Promise;
+}
+
+export default class Contentpass implements ContentpassInterface {
private authStateStorage: OidcAuthStateStorage;
private readonly config: ContentpassConfig;
+ private readonly samplingRate: number;
private contentpassState: ContentpassState = {
state: ContentpassStateType.INITIALISING,
@@ -31,6 +47,19 @@ export default class Contentpass {
private refreshTimer: NodeJS.Timeout | null = null;
constructor(config: ContentpassConfig) {
+ if (config.logLevel) {
+ enableLogger(config.logLevel);
+ }
+
+ logger.debug('Contentpass initialised with config', config);
+ if (
+ config.samplingRate &&
+ (config.samplingRate < 0 || config.samplingRate > 1)
+ ) {
+ logger.error('Sampling rate must be between 0 and 1');
+ throw new Error('Sampling rate must be between 0 and 1');
+ }
+ this.samplingRate = config.samplingRate || DEFAULT_SAMPLING_RATE;
this.authStateStorage = new OidcAuthStateStorage(config.propertyId);
this.config = config;
setSentryExtraAttribute('propertyId', config.propertyId);
@@ -38,6 +67,7 @@ export default class Contentpass {
}
public authenticate = async (): Promise => {
+ logger.info('Starting authentication flow');
let result: AuthorizeResult;
try {
@@ -62,11 +92,13 @@ export default class Contentpass {
throw err;
}
+ logger.info('Authentication flow finished, checking subscription...');
await this.onNewAuthState(result);
};
public registerObserver(observer: ContentpassObserver) {
+ logger.info('Registering observer');
if (this.contentpassStateObservers.includes(observer)) {
return;
}
@@ -76,12 +108,14 @@ export default class Contentpass {
}
public unregisterObserver(observer: ContentpassObserver) {
+ logger.info('Unregistering observer');
this.contentpassStateObservers = this.contentpassStateObservers.filter(
(o) => o !== observer
);
}
public logout = async () => {
+ logger.info('Logging out and clearing auth state');
await this.authStateStorage.clearOidcAuthState();
this.changeContentpassState({
state: ContentpassStateType.UNAUTHENTICATED,
@@ -90,6 +124,7 @@ export default class Contentpass {
};
public recoverFromError = async () => {
+ logger.info('Recovering from error');
this.changeContentpassState({
state: ContentpassStateType.INITIALISING,
});
@@ -97,13 +132,66 @@ export default class Contentpass {
await this.initialiseAuthState();
};
+ public countImpression = async () => {
+ await Promise.all([
+ this.countPaidImpressionWhenUserHasValidSub(),
+ this.countSampledImpression(),
+ ]);
+ };
+
+ private countPaidImpressionWhenUserHasValidSub = async () => {
+ if (!this.hasValidSubscriptionAndAccessToken()) {
+ return;
+ }
+
+ logger.info('Counting paid impression');
+ const impressionId = uuid.v4();
+
+ try {
+ await sendPageViewEvent(this.config.apiUrl, {
+ propertyId: this.config.propertyId,
+ impressionId,
+ accessToken: this.oidcAuthState!.accessToken,
+ });
+ } catch (err: any) {
+ reportError(err, { msg: 'Failed to count paid impression' });
+ }
+ };
+
+ private countSampledImpression = async () => {
+ const generatedSample = Math.random();
+ const publicId = this.config.propertyId.slice(0, 8);
+ const instanceId = uuid.v4();
+
+ if (generatedSample >= this.samplingRate) {
+ return;
+ }
+
+ logger.info('Counting sampled impression');
+ try {
+ await sendStats(this.config.apiUrl, {
+ ea: 'load',
+ ec: 'tcf-sampled',
+ cpabid: instanceId,
+ cppid: publicId,
+ cpsr: this.samplingRate,
+ });
+ } catch (err: any) {
+ reportError(err, { msg: 'Failed to count sampled impression' });
+ }
+ };
+
private initialiseAuthState = async () => {
const authState = await this.authStateStorage.getOidcAuthState();
if (authState) {
+ logger.debug('Found auth state in storage, initialising with it');
await this.onNewAuthState(authState);
return;
}
+ logger.debug(
+ 'No auth state found in storage, initialising unauthenticated'
+ );
this.changeContentpassState({
state: ContentpassStateType.UNAUTHENTICATED,
hasValidSubscription: false,
@@ -111,27 +199,34 @@ export default class Contentpass {
};
private onNewAuthState = async (authState: OidcAuthState) => {
+ logger.debug('New auth state received');
this.oidcAuthState = authState;
await this.authStateStorage.storeOidcAuthState(authState);
const strategy = this.setupRefreshTimer();
// if instant refresh, no need to check subscription as it will happen in the refresh
if (strategy === RefreshTokenStrategy.INSTANTLY) {
+ logger.debug('Instant refresh, skipping subscription check');
return;
}
try {
+ logger.info('Checking subscription');
const contentpassToken = await fetchContentpassToken({
issuer: this.config.issuer,
propertyId: this.config.propertyId,
idToken: this.oidcAuthState.idToken,
});
const hasValidSubscription = validateSubscription(contentpassToken);
+ logger.info({ hasValidSubscription }, 'Subscription check successful');
this.changeContentpassState({
state: ContentpassStateType.AUTHENTICATED,
hasValidSubscription,
});
} catch (err: any) {
+ reportError(err, {
+ msg: 'Failed to fetch contentpass token and validate subscription',
+ });
this.changeContentpassState({
state: ContentpassStateType.ERROR,
error: err,
@@ -144,6 +239,7 @@ export default class Contentpass {
this.oidcAuthState?.accessTokenExpirationDate;
if (!accessTokenExpirationDate) {
+ logger.warn('No access token expiration date provided');
return RefreshTokenStrategy.NO_REFRESH;
}
@@ -151,6 +247,7 @@ export default class Contentpass {
const expirationDate = new Date(accessTokenExpirationDate);
const timeDiff = expirationDate.getTime() - now.getTime();
if (timeDiff <= 0) {
+ logger.debug('Access token expired, refreshing instantly');
this.refreshToken(0);
return RefreshTokenStrategy.INSTANTLY;
}
@@ -159,6 +256,7 @@ export default class Contentpass {
clearTimeout(this.refreshTimer);
}
+ logger.debug({ timeDiff }, 'Setting up refresh timer');
this.refreshTimer = setTimeout(async () => {
await this.refreshToken(0);
}, timeDiff);
@@ -173,6 +271,7 @@ export default class Contentpass {
}
try {
+ logger.info('Refreshing token');
const refreshResult = await refresh(
{
clientId: this.config.propertyId,
@@ -185,23 +284,26 @@ export default class Contentpass {
}
);
await this.onNewAuthState(refreshResult);
+ logger.info('Token refreshed successfully');
} catch (err: any) {
await this.onRefreshTokenError(counter, err);
}
};
private onRefreshTokenError = async (counter: number, err: Error) => {
- reportError(err, {
- msg: `Failed to refresh token after ${counter} retries`,
- });
// FIXME: add handling for specific error to not retry in every case
if (counter < REFRESH_TOKEN_RETRIES) {
+ logger.warn({ err, counter }, 'Failed to refresh token, retrying');
const delay = counter * 1000 * 10;
await new Promise((resolve) => setTimeout(resolve, delay));
await this.refreshToken(counter + 1);
return;
}
+ reportError(err, {
+ msg: `Failed to refresh token after ${counter} retries`,
+ });
+
await this.logout();
};
@@ -209,4 +311,12 @@ export default class Contentpass {
this.contentpassState = state;
this.contentpassStateObservers.forEach((observer) => observer(state));
};
+
+ private hasValidSubscriptionAndAccessToken = () => {
+ return (
+ this.contentpassState.state === ContentpassStateType.AUTHENTICATED &&
+ this.contentpassState.hasValidSubscription &&
+ this.oidcAuthState?.accessToken
+ );
+ };
}
diff --git a/src/utils/fetchContentpassToken.test.ts b/src/contentpassTokenUtils/fetchContentpassToken.test.ts
similarity index 67%
rename from src/utils/fetchContentpassToken.test.ts
rename to src/contentpassTokenUtils/fetchContentpassToken.test.ts
index 2430f61..069b412 100644
--- a/src/utils/fetchContentpassToken.test.ts
+++ b/src/contentpassTokenUtils/fetchContentpassToken.test.ts
@@ -8,6 +8,7 @@ describe('fetchContentpassToken', () => {
it('should return the contentpass token', async () => {
jest.spyOn(global, 'fetch').mockResolvedValue({
+ ok: true,
json: jest
.fn()
.mockResolvedValue({ contentpass_token: 'example_contentpass_token' }),
@@ -31,4 +32,19 @@ describe('fetchContentpassToken', () => {
}
);
});
+
+ it('should throw an error if the fetch fails', async () => {
+ jest.spyOn(global, 'fetch').mockResolvedValue({
+ ok: false,
+ statusText: 'Not Found',
+ } as any);
+
+ await expect(async () => {
+ await fetchContentpassToken({
+ idToken: '123456',
+ propertyId: '987654321',
+ issuer: 'https://issuer.com',
+ });
+ }).rejects.toThrow('Failed to fetch Contentpass token, status: Not Found');
+ });
});
diff --git a/src/utils/fetchContentpassToken.ts b/src/contentpassTokenUtils/fetchContentpassToken.ts
similarity index 80%
rename from src/utils/fetchContentpassToken.ts
rename to src/contentpassTokenUtils/fetchContentpassToken.ts
index 1bc23ba..7c68f96 100644
--- a/src/utils/fetchContentpassToken.ts
+++ b/src/contentpassTokenUtils/fetchContentpassToken.ts
@@ -21,6 +21,12 @@ export default async function fetchContentpassToken({
}).toString(),
});
+ if (!tokenEndpointResponse.ok) {
+ throw new Error(
+ `Failed to fetch Contentpass token, status: ${tokenEndpointResponse.statusText}`
+ );
+ }
+
const { contentpass_token } = await tokenEndpointResponse.json();
return contentpass_token;
diff --git a/src/utils/parseContentpassToken.test.ts b/src/contentpassTokenUtils/parseContentpassToken.test.ts
similarity index 100%
rename from src/utils/parseContentpassToken.test.ts
rename to src/contentpassTokenUtils/parseContentpassToken.test.ts
diff --git a/src/utils/parseContentpassToken.ts b/src/contentpassTokenUtils/parseContentpassToken.ts
similarity index 100%
rename from src/utils/parseContentpassToken.ts
rename to src/contentpassTokenUtils/parseContentpassToken.ts
diff --git a/src/utils/validateSubscription.test.ts b/src/contentpassTokenUtils/validateSubscription.test.ts
similarity index 100%
rename from src/utils/validateSubscription.test.ts
rename to src/contentpassTokenUtils/validateSubscription.test.ts
diff --git a/src/utils/validateSubscription.ts b/src/contentpassTokenUtils/validateSubscription.ts
similarity index 100%
rename from src/utils/validateSubscription.ts
rename to src/contentpassTokenUtils/validateSubscription.ts
diff --git a/src/countImpressionUtils/sendPageViewEvent.test.ts b/src/countImpressionUtils/sendPageViewEvent.test.ts
new file mode 100644
index 0000000..bd81d0c
--- /dev/null
+++ b/src/countImpressionUtils/sendPageViewEvent.test.ts
@@ -0,0 +1,43 @@
+import sendPageViewEvent from './sendPageViewEvent';
+
+describe('sendPageViewEvent', () => {
+ const apiUrl = 'https://api.example.com';
+ const payload = {
+ propertyId: 'property123',
+ impressionId: 'impression456',
+ accessToken: 'token789',
+ };
+
+ afterEach(() => {
+ jest.resetAllMocks();
+ jest.restoreAllMocks();
+ });
+
+ it('should send a page view event successfully', async () => {
+ jest.spyOn(global, 'fetch').mockResolvedValue({
+ ok: true,
+ } as any);
+
+ const response = await sendPageViewEvent(apiUrl, payload);
+
+ expect(response.ok).toBe(true);
+ expect(global.fetch).toHaveBeenCalledWith(
+ `${apiUrl}/pass/hit?pid=${payload.propertyId}&iid=${payload.impressionId}&t=pageview`,
+ {
+ headers: {
+ Authorization: `Bearer ${payload.accessToken}`,
+ },
+ }
+ );
+ });
+
+ it('should throw an error if the fetch fails', async () => {
+ jest.spyOn(global, 'fetch').mockResolvedValue({
+ ok: false,
+ } as any);
+
+ await expect(sendPageViewEvent(apiUrl, payload)).rejects.toThrow(
+ 'Failed send page view event'
+ );
+ });
+});
diff --git a/src/countImpressionUtils/sendPageViewEvent.ts b/src/countImpressionUtils/sendPageViewEvent.ts
new file mode 100644
index 0000000..40cae54
--- /dev/null
+++ b/src/countImpressionUtils/sendPageViewEvent.ts
@@ -0,0 +1,27 @@
+type HitEndpointArgs = {
+ propertyId: string;
+ impressionId: string;
+ accessToken: string;
+};
+
+export default async function sendPageViewEvent(
+ apiUrl: string,
+ payload: HitEndpointArgs
+) {
+ const { propertyId, impressionId, accessToken } = payload;
+ const path = `pass/hit?pid=${propertyId}&iid=${impressionId}&t=pageview`;
+
+ const response = await fetch(`${apiUrl}/${path}`, {
+ headers: {
+ Authorization: `Bearer ${accessToken}`,
+ },
+ });
+
+ if (!response.ok) {
+ throw new Error(
+ `Failed send page view event, status: ${response.statusText}`
+ );
+ }
+
+ return response;
+}
diff --git a/src/countImpressionUtils/sendStats.test.ts b/src/countImpressionUtils/sendStats.test.ts
new file mode 100644
index 0000000..a847635
--- /dev/null
+++ b/src/countImpressionUtils/sendStats.test.ts
@@ -0,0 +1,44 @@
+import sendStats from './sendStats';
+
+describe('sendStats', () => {
+ const apiUrl = 'https://api.example.com';
+ const payload = {
+ ea: 'eventAction',
+ ec: 'eventCategory',
+ cpabid: 'cpabid123',
+ cppid: 'cppid456',
+ cpsr: 1,
+ };
+
+ afterEach(() => {
+ jest.resetAllMocks();
+ jest.restoreAllMocks();
+ });
+
+ it('should send stats successfully', async () => {
+ jest.spyOn(global, 'fetch').mockResolvedValue({
+ ok: true,
+ } as any);
+
+ const response = await sendStats(apiUrl, payload);
+
+ expect(response.ok).toBe(true);
+ expect(global.fetch).toHaveBeenCalledWith(`${apiUrl}/stats`, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json; charset=UTF-8',
+ },
+ body: JSON.stringify(payload),
+ });
+ });
+
+ it('should throw an error if the fetch fails', async () => {
+ jest.spyOn(global, 'fetch').mockResolvedValue({
+ ok: false,
+ } as any);
+
+ await expect(sendStats(apiUrl, payload)).rejects.toThrow(
+ 'Failed to send stats'
+ );
+ });
+});
diff --git a/src/countImpressionUtils/sendStats.ts b/src/countImpressionUtils/sendStats.ts
new file mode 100644
index 0000000..2932b15
--- /dev/null
+++ b/src/countImpressionUtils/sendStats.ts
@@ -0,0 +1,23 @@
+type StatsPayload = {
+ ea: string;
+ ec: string;
+ cpabid: string;
+ cppid: string;
+ cpsr: number;
+};
+
+export default async function sendStats(apiUrl: string, payload: StatsPayload) {
+ const response = await fetch(`${apiUrl}/stats`, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json; charset=UTF-8',
+ },
+ body: JSON.stringify(payload),
+ });
+
+ if (!response.ok) {
+ throw new Error(`Failed to send stats, status: ${response.statusText}`);
+ }
+
+ return response;
+}
diff --git a/src/logger.ts b/src/logger.ts
new file mode 100644
index 0000000..3e734c8
--- /dev/null
+++ b/src/logger.ts
@@ -0,0 +1,33 @@
+import {
+ consoleTransport,
+ logger,
+ type transportFunctionType,
+} from 'react-native-logs';
+import type { Severity } from './types/ContentpassConfig';
+import type { ConsoleTransportOptions } from 'react-native-logs/dist/transports/consoleTransport';
+
+type Logger = ReturnType<
+ typeof logger.createLogger>
+>;
+
+const log: Logger = logger.createLogger({
+ // by default loggger is disabled
+ enabled: false,
+ transport: consoleTransport,
+ transportOptions: {
+ colors: {
+ info: 'blueBright',
+ warn: 'yellowBright',
+ error: 'redBright',
+ },
+ },
+});
+
+export const enableLogger = (severity: Severity) => {
+ log.setSeverity(severity);
+ log.enable();
+
+ log.debug('Logger enabled with severity', severity);
+};
+
+export default log;
diff --git a/src/sdkContext/ContentpassSdkProvder.test.tsx b/src/sdkContext/ContentpassSdkProvder.test.tsx
index 0a41988..b003729 100644
--- a/src/sdkContext/ContentpassSdkProvder.test.tsx
+++ b/src/sdkContext/ContentpassSdkProvder.test.tsx
@@ -12,6 +12,7 @@ describe('ContentpassSdkProvider', () => {
propertyId: 'my-property-id',
planId: 'my-plan-id',
redirectUrl: 'de.contentpass.test://oauth',
+ apiUrl: 'https://cp.propert.com',
};
afterEach(() => {
diff --git a/src/sentryIntegration.ts b/src/sentryIntegration.ts
index 66f4fb6..e32183f 100644
--- a/src/sentryIntegration.ts
+++ b/src/sentryIntegration.ts
@@ -1,6 +1,7 @@
import * as Sentry from '@sentry/react-native';
import { defaultStackParser, makeFetchTransport } from '@sentry/react';
import { getDefaultIntegrations } from '@sentry/react-native/dist/js/integrations/default';
+import logger from './logger';
// as it's only the open source package, we want to have minimal sentry configuration here to not override sentry instance,
// which can be used in the application
@@ -43,6 +44,7 @@ type ReportErrorOptions = {
};
export const reportError = (err: Error, { msg }: ReportErrorOptions = {}) => {
+ logger.error({ err }, msg || 'Unexpected error');
if (msg) {
sentryScope.addBreadcrumb({
category: 'Error',
diff --git a/src/types/ContentpassConfig.ts b/src/types/ContentpassConfig.ts
index 44a080d..a38f7be 100644
--- a/src/types/ContentpassConfig.ts
+++ b/src/types/ContentpassConfig.ts
@@ -1,8 +1,13 @@
/* istanbul ignore file */
+export type Severity = 'debug' | 'info' | 'warn' | 'error';
+
export type ContentpassConfig = {
propertyId: string;
planId: string;
redirectUrl: string;
issuer: string;
+ apiUrl: string;
+ samplingRate?: number;
+ logLevel?: Severity;
};
diff --git a/yarn.lock b/yarn.lock
index d4a03c9..f7dccc8 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -1844,6 +1844,8 @@ __metadata:
react-native-app-auth: ^8.0.0
react-native-builder-bob: ^0.32.1
react-native-encrypted-storage: ^4.0.3
+ react-native-logs: ^5.3.0
+ react-native-uuid: ^2.0.3
react-test-renderer: 18.3.1
release-it: ^17.10.0
turbo: ^1.10.7
@@ -12587,6 +12589,20 @@ __metadata:
languageName: node
linkType: hard
+"react-native-logs@npm:^5.3.0":
+ version: 5.3.0
+ resolution: "react-native-logs@npm:5.3.0"
+ checksum: b7fa58007e737fe337fbd664529054cfe71a1fa8f2f4cf50621ffca0f50b44f70dcb7d37f3222093c7b36a287a79b00f1b84d243fb15dc67650c72b6c514343a
+ languageName: node
+ linkType: hard
+
+"react-native-uuid@npm:^2.0.3":
+ version: 2.0.3
+ resolution: "react-native-uuid@npm:2.0.3"
+ checksum: e47774481feaed5d38f75fb4b03f5189c03e7452038f1b0fa56677add8a8bdef64ec7ad5e83f9fa761d8788cac5c8e554ade07e82a2818145337df7c883e2124
+ languageName: node
+ linkType: hard
+
"react-native@npm:0.76.2":
version: 0.76.2
resolution: "react-native@npm:0.76.2"