diff --git a/packages/kyc-controller/CHANGELOG.md b/packages/kyc-controller/CHANGELOG.md index 0fb85ad7775..1305e32fe3f 100644 --- a/packages/kyc-controller/CHANGELOG.md +++ b/packages/kyc-controller/CHANGELOG.md @@ -7,6 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Add an optional `trackKycAnalytics` constructor option for KYC analytics ([#11041](https://github.com/MetaMask/core/pull/11041)) + - `verificationOutcome` is called after the state write for each SumSub launch attempt (`submitted`, `abandoned`, `failed`), including a repeat of the previous outcome and the failures that leave state unchanged. The payload is `status`, `kycStatus`, `providerFlowStatus` and `vendor` + - `statusChanged` is called after any state write that changes the analytics KYC status (for example `new` to `pending`, `pending` to `approved`, or `pending` to `retry`). The payload is `previousKycStatus`, `kycStatus`, `providerFlowStatus` and `vendor`. `previousKycStatus` is always present and is `none` when nothing was stored before, which covers both a session that was just created and a stored `finalStatus` outside `KYC_ANALYTICS_STATUSES`, so an absent field never has to be read as "first observation" + - Loading a session into an empty slot (for example after a reinstall) calls neither callback + - Neither payload carries session id, email, disclaimer or token data + - A callback that throws leaves the stored KYC outcome unchanged. `launchProviderFlow` still returns the outcome it wrote, and polling still stops when a session reaches `approved`, `rejected`, or `retry` +- Export `getKycAnalyticsStatus`, `KYC_ANALYTICS_STATUSES` and the `KycAnalyticsTrackers`, `KycAnalyticsStatus`, `KycAnalyticsVerificationOutcome`, `KycAnalyticsVerificationOutcomePayload` and `KycAnalyticsStatusChangedPayload` types + ### Changed - Bump `@metamask/utils` from `^12.0.0` to `^12.0.1` ([#11107](https://github.com/MetaMask/core/pull/11107)) diff --git a/packages/kyc-controller/src/KycController.test.ts b/packages/kyc-controller/src/KycController.test.ts index 86ac87e10bc..e85588e6233 100644 --- a/packages/kyc-controller/src/KycController.test.ts +++ b/packages/kyc-controller/src/KycController.test.ts @@ -8,6 +8,12 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import type { Mock, MockedFunction } from 'vitest'; import { flushPromises } from '../../../tests/vitest/helpers.js'; +import type { + KycAnalyticsStatusChangedPayload, + KycAnalyticsTrackers, + KycAnalyticsVerificationOutcomePayload, +} from './analytics.js'; +import { getKycAnalyticsStatus } from './analytics.js'; import { toBase64Url } from './encoding.js'; import { getDefaultKycControllerState, @@ -1614,6 +1620,497 @@ describe('KycController', () => { }); }); + describe('analytics', () => { + afterEach(() => { + vi.useRealTimers(); + }); + + /** + * Builds `trackKycAnalytics` and records each payload it receives. + * + * @returns The callbacks and the payloads they recorded. + */ + function createAnalyticsTrackers(): { + trackKycAnalytics: KycAnalyticsTrackers; + verification: KycAnalyticsVerificationOutcomePayload[]; + status: KycAnalyticsStatusChangedPayload[]; + } { + const verification: KycAnalyticsVerificationOutcomePayload[] = []; + const status: KycAnalyticsStatusChangedPayload[] = []; + return { + verification, + status, + trackKycAnalytics: { + verificationOutcome: (payload) => { + verification.push(payload); + }, + statusChanged: (payload) => { + status.push(payload); + }, + }, + }; + } + + it('tracks submitted and the move to pending after the SDK completes', async () => { + const analytics = createAnalyticsTrackers(); + await withController( + { + options: { + state: { vendor: 'iron', sessionStatus: sessionStatus('new') }, + trackKycAnalytics: analytics.trackKycAnalytics, + }, + }, + async ({ controller, handlers, launcher }) => { + launcher.launch.mockResolvedValue({ status: 'Completed' }); + handlers.getSessionStatus.mockRejectedValue(new Error('unavailable')); + + await controller.launchProviderFlow({}); + + expect(analytics.verification).toStrictEqual([ + { + status: 'submitted', + kycStatus: 'pending', + providerFlowStatus: 'submitted', + vendor: 'iron', + }, + ]); + expect(analytics.status).toStrictEqual([ + { + previousKycStatus: 'new', + kycStatus: 'pending', + providerFlowStatus: 'submitted', + vendor: 'iron', + }, + ]); + }, + ); + }); + + it('tracks abandoned without a KYC status change', async () => { + const analytics = createAnalyticsTrackers(); + await withController( + { + options: { + state: { sessionStatus: sessionStatus('new') }, + trackKycAnalytics: analytics.trackKycAnalytics, + }, + }, + async ({ controller, launcher }) => { + launcher.launch.mockResolvedValue({ status: 'Incomplete' }); + + await controller.launchProviderFlow({}); + + expect(analytics.verification).toStrictEqual([ + { + status: 'abandoned', + kycStatus: 'new', + providerFlowStatus: 'abandoned', + vendor: null, + }, + ]); + expect(analytics.status).toStrictEqual([]); + }, + ); + }); + + it('tracks failed for each SDK failure path', async () => { + const analytics = createAnalyticsTrackers(); + await withController( + { + options: { + state: { sessionStatus: sessionStatus('new') }, + trackKycAnalytics: analytics.trackKycAnalytics, + }, + }, + async ({ controller, launcher }) => { + launcher.launch.mockResolvedValueOnce({ status: 'Failed' }); + await controller.launchProviderFlow({}); + launcher.launch.mockRejectedValueOnce(new Error('launcher threw')); + await controller.launchProviderFlow({}); + launcher.isAvailable.mockReturnValueOnce(false); + await controller.launchProviderFlow({}); + + expect( + analytics.verification.map((event) => event.status), + ).toStrictEqual(['failed', 'failed', 'failed']); + expect(analytics.status).toStrictEqual([]); + }, + ); + }); + + it('tracks the same launch outcome again on a repeated attempt', async () => { + const analytics = createAnalyticsTrackers(); + await withController( + { + options: { + state: { sessionStatus: sessionStatus('new') }, + trackKycAnalytics: analytics.trackKycAnalytics, + }, + }, + async ({ controller, launcher }) => { + launcher.launch.mockResolvedValue({ status: 'Incomplete' }); + + await controller.launchProviderFlow({}); + await controller.launchProviderFlow({}); + + expect( + analytics.verification.map(({ status }) => status), + ).toStrictEqual(['abandoned', 'abandoned']); + }, + ); + }); + + it('tracks failed without writing state when the session changed during launch', async () => { + const analytics = createAnalyticsTrackers(); + await withController( + { + options: { + state: { sessionStatus: sessionStatus('new') }, + trackKycAnalytics: analytics.trackKycAnalytics, + }, + }, + async ({ controller, launcher }) => { + launcher.launch.mockImplementation(async () => { + await controller.reset(); + return { status: 'Completed' }; + }); + + await controller.launchProviderFlow({}); + + expect(controller.state.providerFlowStatus).toBe('not_started'); + expect(analytics.verification).toStrictEqual([ + { + status: 'failed', + kycStatus: 'none', + providerFlowStatus: 'not_started', + vendor: null, + }, + ]); + expect(analytics.status).toStrictEqual([]); + }, + ); + }); + + it('tracks failed without writing state when the launcher throws after a reset', async () => { + const analytics = createAnalyticsTrackers(); + await withController( + { + options: { + state: { sessionStatus: sessionStatus('new') }, + trackKycAnalytics: analytics.trackKycAnalytics, + }, + }, + async ({ controller, launcher }) => { + launcher.launch.mockImplementation(async () => { + controller.clearState(); + throw new Error('launcher unavailable'); + }); + + await controller.launchProviderFlow({}); + + expect( + analytics.verification.map(({ status }) => status), + ).toStrictEqual(['failed']); + }, + ); + }); + + it('tracks one KYC status change when a poll newly approves the session', async () => { + vi.useFakeTimers(); + const analytics = createAnalyticsTrackers(); + await withController( + { + options: { + state: { sessionStatus: sessionStatus('pending') }, + trackKycAnalytics: analytics.trackKycAnalytics, + }, + }, + async ({ controller, handlers }) => { + handlers.getSessionStatus.mockResolvedValue( + sessionStatus('approved'), + ); + + controller.startSessionStatusPolling(); + await vi.advanceTimersByTimeAsync(60_000); + + expect(analytics.status).toStrictEqual([ + { + previousKycStatus: 'pending', + kycStatus: 'approved', + providerFlowStatus: 'not_started', + vendor: null, + }, + ]); + expect(analytics.verification).toStrictEqual([]); + }, + ); + }); + + it.each([ + ['rejected', 'rejected'], + ['retry', 'retry'], + ] as const)( + 'tracks a KYC status change when fetchSessionStatusOnce moves the session to %s', + async (finalStatus, kycStatus) => { + const analytics = createAnalyticsTrackers(); + await withController( + { + options: { + state: { sessionStatus: sessionStatus('pending') }, + trackKycAnalytics: analytics.trackKycAnalytics, + }, + }, + async ({ controller, handlers }) => { + handlers.getSessionStatus.mockResolvedValue( + sessionStatus(finalStatus), + ); + + await controller.fetchSessionStatusOnce('sid'); + + expect( + analytics.status.map((event) => event.kycStatus), + ).toStrictEqual([kycStatus]); + expect(analytics.status[0].previousKycStatus).toBe('pending'); + }, + ); + }, + ); + + it('tracks a KYC status change when startSession refetches an existing session that is now approved', async () => { + const analytics = createAnalyticsTrackers(); + await withController( + { + options: { + state: { + email: 'a@b.co', + sessionStatus: sessionStatus('pending'), + }, + trackKycAnalytics: analytics.trackKycAnalytics, + }, + }, + async ({ controller, handlers }) => { + handlers.getSessionStatus.mockResolvedValue( + sessionStatus('approved'), + ); + + await controller.startSession({ vendor: 'iron' }); + + expect( + analytics.status.map((event) => event.kycStatus), + ).toStrictEqual(['approved']); + }, + ); + }); + + it('tracks a KYC status change with a none previous status when the session is first created', async () => { + const analytics = createAnalyticsTrackers(); + await withController( + { options: { trackKycAnalytics: analytics.trackKycAnalytics } }, + async ({ controller, handlers }) => { + handlers.setAuthorizations.mockResolvedValue(sessionStatus('new')); + + await controller.startSession({ + vendor: 'iron', + aal2Token: AAL2_TOKEN, + }); + + expect(analytics.status).toStrictEqual([ + { + previousKycStatus: 'none', + kycStatus: 'new', + providerFlowStatus: 'not_started', + vendor: 'iron', + }, + ]); + }, + ); + }); + + it('reports none rather than omitting the previous status when the stored status was unrecognized', async () => { + const analytics = createAnalyticsTrackers(); + await withController( + { + options: { + state: { sessionStatus: sessionStatus('mystery') }, + trackKycAnalytics: analytics.trackKycAnalytics, + }, + }, + async ({ controller, handlers }) => { + handlers.getSessionStatus.mockResolvedValue( + sessionStatus('approved'), + ); + + await controller.fetchSessionStatusOnce('sid'); + + expect(analytics.status).toStrictEqual([ + { + previousKycStatus: 'none', + kycStatus: 'approved', + providerFlowStatus: 'not_started', + vendor: null, + }, + ]); + }, + ); + }); + + it('does not track when an already-approved session is loaded into empty state', async () => { + const analytics = createAnalyticsTrackers(); + await withController( + { options: { trackKycAnalytics: analytics.trackKycAnalytics } }, + async ({ controller, handlers }) => { + handlers.getSessionStatusForVendor.mockResolvedValue( + sessionStatus('approved'), + ); + + await controller.startSession({ vendor: 'iron' }); + + expect(controller.state.sessionStatus?.finalStatus).toBe('approved'); + expect(analytics.status).toStrictEqual([]); + expect(analytics.verification).toStrictEqual([]); + }, + ); + }); + + it('does not track when the derived status is unchanged', async () => { + const analytics = createAnalyticsTrackers(); + await withController( + { + options: { + state: { sessionStatus: sessionStatus('approved') }, + trackKycAnalytics: analytics.trackKycAnalytics, + }, + }, + async ({ controller, handlers }) => { + handlers.getSessionStatus.mockResolvedValue( + sessionStatus('approved', { vendorStatus: 'still-approved' }), + ); + + await controller.fetchSessionStatusOnce('sid'); + + expect(analytics.status).toStrictEqual([]); + }, + ); + }); + + it('writes state before tracking', async () => { + const statusAtTrack: (string | undefined)[] = []; + let readFinalStatus: () => string | undefined = () => undefined; + await withController( + { + options: { + state: { sessionStatus: sessionStatus('pending') }, + trackKycAnalytics: { + verificationOutcome: vi.fn(), + statusChanged: () => { + statusAtTrack.push(readFinalStatus()); + }, + }, + }, + }, + async ({ controller, handlers }) => { + readFinalStatus = (): string | undefined => + controller.state.sessionStatus?.finalStatus; + handlers.getSessionStatus.mockResolvedValue( + sessionStatus('approved'), + ); + + await controller.fetchSessionStatusOnce('sid'); + + expect(statusAtTrack).toStrictEqual(['approved']); + }, + ); + }); + + it('keeps a submitted launch when the analytics callbacks throw', async () => { + await withController( + { + options: { + state: { vendor: 'iron', sessionStatus: sessionStatus('new') }, + trackKycAnalytics: { + verificationOutcome: () => { + throw new Error('analytics down'); + }, + statusChanged: () => { + throw new Error('analytics down'); + }, + }, + }, + }, + async ({ controller, handlers, launcher }) => { + launcher.launch.mockResolvedValue({ status: 'Completed' }); + handlers.getSessionStatus.mockRejectedValue(new Error('unavailable')); + + const result = await controller.launchProviderFlow({}); + + expect(result).toBe('submitted'); + expect(controller.state.providerFlowStatus).toBe('submitted'); + expect(controller.state.sessionStatus?.finalStatus).toBe('pending'); + expect(handlers.getSessionStatus).toHaveBeenCalled(); + }, + ); + }); + + it('stops polling an approval when the analytics callback throws', async () => { + vi.useFakeTimers(); + await withController( + { + options: { + state: { sessionStatus: sessionStatus('pending') }, + trackKycAnalytics: { + verificationOutcome: vi.fn(), + statusChanged: () => { + throw new Error('analytics down'); + }, + }, + }, + }, + async ({ controller, handlers }) => { + handlers.getSessionStatus.mockResolvedValue( + sessionStatus('approved'), + ); + + controller.startSessionStatusPolling(); + await vi.advanceTimersByTimeAsync(60_000); + + expect(controller.state.sessionStatus?.finalStatus).toBe('approved'); + expect(handlers.getSessionStatus).toHaveBeenCalledOnce(); + }, + ); + }); + + it('leaves KYC behavior unchanged when no tracker is passed', async () => { + await withController( + { options: { state: { sessionStatus: sessionStatus('new') } } }, + async ({ controller, launcher }) => { + launcher.launch.mockResolvedValue({ status: 'Incomplete' }); + + expect(await controller.launchProviderFlow({})).toBe('abandoned'); + }, + ); + }); + }); + + describe('getKycAnalyticsStatus', () => { + it.each([ + [null, 'none'], + [sessionStatus('new'), 'new'], + [sessionStatus('pending'), 'pending'], + [sessionStatus('approved'), 'approved'], + [sessionStatus('rejected'), 'rejected'], + [sessionStatus('retry'), 'retry'], + [sessionStatus('mystery'), 'none'], + ] as const)('maps %j to %s', (session, expected) => { + expect(getKycAnalyticsStatus(session)).toBe(expected); + }); + + it('ignores the relay kycStatus', () => { + expect( + getKycAnalyticsStatus(sessionStatus('new', { kycStatus: 'approved' })), + ).toBe('new'); + }); + }); + describe('messenger actions', () => { it('exposes fetchSessionDisclaimers', async () => { await withController(async ({ rootMessenger, handlers }) => { diff --git a/packages/kyc-controller/src/KycController.ts b/packages/kyc-controller/src/KycController.ts index b50ae8f115b..0ba33af0654 100644 --- a/packages/kyc-controller/src/KycController.ts +++ b/packages/kyc-controller/src/KycController.ts @@ -17,6 +17,12 @@ import type { import { stringToBytes, wrapError } from '@metamask/utils'; import { x25519 } from '@noble/curves/ed25519'; +import type { + KycAnalyticsStatus, + KycAnalyticsTrackers, + KycAnalyticsVerificationOutcomePayload, +} from './analytics.js'; +import { getKycAnalyticsStatus } from './analytics.js'; import { toBase64Url } from './encoding.js'; import type { KycControllerMethodActions } from './KycController-method-action-types.js'; import type { KycServiceMethodActions } from './KycService-method-action-types.js'; @@ -319,6 +325,11 @@ export type KycControllerOptions = { * the controller stays platform-agnostic. */ sumsubLauncher: KycSumSubLauncher; + /** + * Optional client callbacks that record KYC analytics. Omitted clients + * record nothing. See {@link KycAnalyticsTrackers}. + */ + trackKycAnalytics?: KycAnalyticsTrackers; }; // === CONTROLLER DEFINITION === @@ -339,6 +350,8 @@ export class KycController extends BaseController< readonly #localUserSecretStore: UkycLocalUserSecretStore; + readonly #trackKycAnalytics?: KycAnalyticsTrackers; + /** Handle for the scheduled next session-status poll, or `null`. */ #sessionStatusPollTimer: ReturnType | null = null; @@ -355,8 +368,14 @@ export class KycController extends BaseController< * @param options.messenger - The messenger suited for this controller. * @param options.state - Partial initial state; merged over defaults. * @param options.sumsubLauncher - The platform SumSub launcher adapter. + * @param options.trackKycAnalytics - Optional callbacks that record KYC analytics. */ - constructor({ messenger, state, sumsubLauncher }: KycControllerOptions) { + constructor({ + messenger, + state, + sumsubLauncher, + trackKycAnalytics, + }: KycControllerOptions) { super({ messenger, metadata: kycControllerMetadata, @@ -365,6 +384,7 @@ export class KycController extends BaseController< }); this.#sumsubLauncher = sumsubLauncher; + this.#trackKycAnalytics = trackKycAnalytics; this.#localUserSecretStore = new UkycLocalUserSecretStore(this.messenger); this.messenger.registerMethodActionHandlers( @@ -510,9 +530,7 @@ export class KycController extends BaseController< { sessionId: this.state.sessionStatus.id }, ); - this.update((state) => { - state.sessionStatus = sessionStatus; - }); + this.#updateSessionStatusAndTrackAnalytics(sessionStatus); // TODO: Should we resume polling here? return this.#refreshAuthorizationsIfNeeded(); } @@ -613,9 +631,7 @@ export class KycController extends BaseController< 'KycService:getSessionStatus', { sessionId }, ); - this.update((state) => { - state.sessionStatus = sessionStatus; - }); + this.#updateSessionStatusAndTrackAnalytics(sessionStatus); return sessionStatus; } @@ -685,9 +701,7 @@ export class KycController extends BaseController< JSON.stringify(this.state.sessionStatus) !== JSON.stringify(sessionStatus) ) { - this.update((state) => { - state.sessionStatus = sessionStatus; - }); + this.#updateSessionStatusAndTrackAnalytics(sessionStatus); } if (FINAL_STATUSES_TO_STOP_POLLING.has(sessionStatus.finalStatus)) { @@ -700,6 +714,113 @@ export class KycController extends BaseController< } } + // === ANALYTICS === + // + // The private methods from here to `#stopSessionStatusPolling` only compute + // analytics payloads and call `trackKycAnalytics`. Business methods call the + // `...AndTrackAnalytics` wrappers in place of a plain `this.update`, so the + // state write itself is unchanged and the analytics step is named at each + // call site. + + /** + * Computes the analytics KYC status from the current state. + * + * @returns The analytics KYC status. + */ + #getKycAnalyticsStatus(): KycAnalyticsStatus { + return getKycAnalyticsStatus(this.state.sessionStatus); + } + + /** + * Reports one SumSub launch outcome through `trackKycAnalytics`. + * + * A callback exception is ignored. The KYC outcome is already stored, and + * analytics must not replace it or skip the polling that follows a submit. + * + * @param status - The launch outcome. + */ + #trackAnalyticsVerificationOutcome( + status: KycAnalyticsVerificationOutcomePayload['status'], + ): void { + try { + this.#trackKycAnalytics?.verificationOutcome({ + status, + kycStatus: this.#getKycAnalyticsStatus(), + providerFlowStatus: this.state.providerFlowStatus, + vendor: this.state.vendor, + }); + } catch { + // The host's tracker failed. The launch outcome stays as written. + } + } + + /** + * Reports an analytics KYC status change when it differs from the status + * before the causing write. + * + * A callback exception is ignored, so a tracker failure cannot keep a + * finished session polling. + * + * @param previousAnalyticsStatus - Analytics KYC status before the causing write. + */ + #trackAnalyticsStatusIfChanged( + previousAnalyticsStatus: KycAnalyticsStatus, + ): void { + const kycStatus = this.#getKycAnalyticsStatus(); + if (kycStatus === previousAnalyticsStatus) { + return; + } + try { + this.#trackKycAnalytics?.statusChanged({ + previousKycStatus: previousAnalyticsStatus, + kycStatus, + providerFlowStatus: this.state.providerFlowStatus, + vendor: this.state.vendor, + }); + } catch { + // The host's tracker failed. The stored session stays as written. + } + } + + /** + * Replaces the stored session status, then reports an analytics KYC status + * change when one occurred. State is written before the callback runs. + * + * Writes that load a session into an empty slot must not use this helper, so + * an already-decided session found on a fresh install is not reported. + * + * @param sessionStatus - The session status to store. + */ + #updateSessionStatusAndTrackAnalytics(sessionStatus: KycSessionStatus): void { + const previousAnalyticsStatus = this.#getKycAnalyticsStatus(); + this.update((state) => { + state.sessionStatus = sessionStatus; + }); + this.#trackAnalyticsStatusIfChanged(previousAnalyticsStatus); + } + + /** + * Applies the state write for one SumSub launch attempt, then reports the + * launch outcome and, if the write changed the analytics KYC status (a + * submitted launch moves it to `pending`), the status change. Every attempt + * is reported, including a repeat of the previous outcome. Pass no `update` + * for failures that must not write state. + * + * @param status - The launch outcome. + * @param update - Optional state write applied before the callbacks run. + */ + #recordLaunchOutcomeAndTrackAnalytics( + status: 'submitted' | 'abandoned' | 'failed', + update?: (state: KycControllerState) => void, + ): void { + const previousAnalyticsStatus = this.#getKycAnalyticsStatus(); + if (update) { + this.update(update); + } + this.#trackAnalyticsVerificationOutcome(status); + this.#trackAnalyticsStatusIfChanged(previousAnalyticsStatus); + } + /** * Stops the session-status polling loop. */ @@ -817,9 +938,7 @@ export class KycController extends BaseController< }, ); - this.update((state) => { - state.sessionStatus = sessionStatus; - }); + this.#updateSessionStatusAndTrackAnalytics(sessionStatus); return sessionStatus; } @@ -1139,7 +1258,7 @@ export class KycController extends BaseController< }): Promise { const sessionId = this.state.sessionStatus?.id; if (!this.#sumsubLauncher.isAvailable() || !sessionId) { - this.update((state) => { + this.#recordLaunchOutcomeAndTrackAnalytics('failed', (state) => { state.providerFlowStatus = 'failed'; }); return 'failed'; @@ -1187,11 +1306,12 @@ export class KycController extends BaseController< const currentSessionStatus = this.state.sessionStatus; if (currentSessionStatus?.id !== sessionId) { + this.#recordLaunchOutcomeAndTrackAnalytics('failed'); return 'failed'; } if (sdkFailed) { - this.update((state) => { + this.#recordLaunchOutcomeAndTrackAnalytics('failed', (state) => { state.providerFlowStatus = 'failed'; }); return 'failed'; @@ -1200,7 +1320,7 @@ export class KycController extends BaseController< if (reachedCompletion) { // Once the SDK completes, the authoritative verification decision comes // from the UKYC backend, not the SDK result. - this.update((state) => { + this.#recordLaunchOutcomeAndTrackAnalytics('submitted', (state) => { state.sessionStatus = { ...currentSessionStatus, finalStatus: 'pending', @@ -1211,15 +1331,17 @@ export class KycController extends BaseController< return 'submitted'; } - this.update((state) => { + this.#recordLaunchOutcomeAndTrackAnalytics('abandoned', (state) => { state.providerFlowStatus = 'abandoned'; }); return 'abandoned'; } catch { if (this.state.sessionStatus?.id === sessionId) { - this.update((state) => { + this.#recordLaunchOutcomeAndTrackAnalytics('failed', (state) => { state.providerFlowStatus = 'failed'; }); + } else { + this.#recordLaunchOutcomeAndTrackAnalytics('failed'); } return 'failed'; } diff --git a/packages/kyc-controller/src/analytics.ts b/packages/kyc-controller/src/analytics.ts new file mode 100644 index 00000000000..a94c12179e3 --- /dev/null +++ b/packages/kyc-controller/src/analytics.ts @@ -0,0 +1,109 @@ +/** + * Analytics-only types and helpers for `KycController`. Nothing here is used + * by the KYC business logic; it describes the payloads passed to + * {@link KycAnalyticsTrackers} and the status they report. + */ + +import type { KycProviderFlowStatus } from './KycController.js'; +import type { KycSessionStatus } from './types.js'; + +/** + * KYC status vocabulary used by verification analytics. `'none'` means no + * session exists yet (or the vendor returned an unrecognized status). + */ +export const KYC_ANALYTICS_STATUSES = [ + 'none', + 'new', + 'retry', + 'pending', + 'approved', + 'rejected', +] as const; + +export type KycAnalyticsStatus = (typeof KYC_ANALYTICS_STATUSES)[number]; + +const KYC_ANALYTICS_STATUS_SET = new Set(KYC_ANALYTICS_STATUSES); + +/** + * SumSub launch outcomes reported through + * {@link KycAnalyticsTrackers.verificationOutcome}, one per launch attempt. + */ +export type KycAnalyticsVerificationOutcome = + | 'submitted' + | 'abandoned' + | 'failed'; + +/** + * Payload of {@link KycAnalyticsTrackers.verificationOutcome}. + * + * Carries no session id, email, disclaimer or token data. + */ +export type KycAnalyticsVerificationOutcomePayload = { + status: KycAnalyticsVerificationOutcome; + /** Analytics KYC status after the launch outcome was recorded. */ + kycStatus: KycAnalyticsStatus; + providerFlowStatus: KycProviderFlowStatus; + vendor: string | null; +}; + +/** + * Payload of {@link KycAnalyticsTrackers.statusChanged}. + * + * Carries no session id, email, disclaimer or token data. + */ +export type KycAnalyticsStatusChangedPayload = { + /** + * Analytics KYC status before the change. `'none'` when no status was + * stored before, which covers both a session that was just created and a + * stored `finalStatus` outside {@link KYC_ANALYTICS_STATUSES}. Always + * present, so a consumer never has to read an absent field as "first + * observation". + */ + previousKycStatus: KycAnalyticsStatus; + /** Analytics KYC status after the change. */ + kycStatus: KycAnalyticsStatus; + providerFlowStatus: KycProviderFlowStatus; + vendor: string | null; +}; + +/** + * Client callbacks that record KYC analytics. Passed to `KycController` as + * `trackKycAnalytics`. The controller calls them after the state write that + * caused the signal. + */ +export type KycAnalyticsTrackers = { + /** + * Called once per SumSub launch attempt (`submitted`, `abandoned`, or + * `failed`), including a repeat of the previous outcome and the failures + * that leave state unchanged. + * + * @param payload - The launch outcome and the analytics status after it. + */ + verificationOutcome: ( + payload: KycAnalyticsVerificationOutcomePayload, + ) => void; + /** + * Called after a state write that changes the analytics KYC status. Not + * called when a session is loaded into an empty slot. + * + * @param payload - The analytics status before and after the change. + */ + statusChanged: (payload: KycAnalyticsStatusChangedPayload) => void; +}; + +/** + * Maps the session's `finalStatus` onto the analytics KYC status. A missing + * session is `none`, and so is a `finalStatus` outside + * {@link KYC_ANALYTICS_STATUSES}. + * + * @param session - Persisted or freshly fetched KYC session, if any. + * @returns The analytics KYC status. + */ +export function getKycAnalyticsStatus( + session: KycSessionStatus | null, +): KycAnalyticsStatus { + if (session && KYC_ANALYTICS_STATUS_SET.has(session.finalStatus)) { + return session.finalStatus as KycAnalyticsStatus; + } + return 'none'; +} diff --git a/packages/kyc-controller/src/index.ts b/packages/kyc-controller/src/index.ts index c8f485a8d39..dddca35a924 100644 --- a/packages/kyc-controller/src/index.ts +++ b/packages/kyc-controller/src/index.ts @@ -36,6 +36,15 @@ export type { KycControllerStartSessionStatusPollingAction, } from './KycController-method-action-types.js'; +export { KYC_ANALYTICS_STATUSES, getKycAnalyticsStatus } from './analytics.js'; +export type { + KycAnalyticsStatus, + KycAnalyticsStatusChangedPayload, + KycAnalyticsTrackers, + KycAnalyticsVerificationOutcome, + KycAnalyticsVerificationOutcomePayload, +} from './analytics.js'; + export { KycService, serviceName } from './KycService.js'; export type { ApplicantAccessTokenResponse,