Skip to content

Commit

Permalink
Analytics: track api errors (#3035)
Browse files Browse the repository at this point in the history
* feat(analytics): track api errors

- makePayments: '620'
- submitPaymentDetails: '621'
- submitThreeDS2Fingerprint: '622'
- createOrder: '623'

* test(UIElement): added tests for sending error event to the analytics
  • Loading branch information
longyulongyu authored Jan 2, 2025
1 parent a27d6dc commit 933f970
Show file tree
Hide file tree
Showing 14 changed files with 229 additions and 26 deletions.
9 changes: 9 additions & 0 deletions .changeset/fresh-lamps-raise.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
'@adyen/adyen-web': minor
---

Start tracking API errors for the following endpoints for analytics purposes:
- `/sessions/${session.id}/payments`
- `/sessions/${session.id}/orders`
- `/sessions/${session.id}/paymentDetails`
- `v1/submitThreeDS2Fingerprint`
50 changes: 50 additions & 0 deletions packages/lib/src/components/ANCV/ANCV.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { render } from '@testing-library/preact';
import { mockDeep } from 'jest-mock-extended';
import { AnalyticsModule } from '../../types/global-types';
import AdyenCheckoutError from '../../core/Errors/AdyenCheckoutError';
import { ANALYTICS_ERROR_TYPE, ANALYTICS_EVENT } from '../../core/Analytics/constants';
import ANCV from './ANCV';

const flushPromises = () => new Promise(process.nextTick);

describe('ANCV', () => {
const resources = global.resources;
const i18n = global.i18n;

const baseProps = {
amount: { value: 1000, currency: 'EUR' },
i18n,
loadingContext: 'mock'
};

describe('createOrder', () => {
test('should send an error event to the analytics if the createOrder call fails for the session flow', async () => {
const code = 'mockErrorCode';
const analytics = mockDeep<AnalyticsModule>();
const mockedSendAnalytics = analytics.sendAnalytics as jest.Mock;

const ancv = new ANCV(global.core, {
...baseProps,
modules: {
resources,
analytics
},
onError: () => {},
// @ts-ignore test only
session: {
createOrder: () => {
return Promise.reject(new AdyenCheckoutError('NETWORK_ERROR', '', { code }));
}
}
});
render(ancv.render());
await ancv.createOrder();
await flushPromises();
expect(mockedSendAnalytics).toHaveBeenCalledWith(
'ancv',
{ code, errorType: ANALYTICS_ERROR_TYPE.apiError, type: ANALYTICS_EVENT.error },
undefined
);
});
});
});
8 changes: 7 additions & 1 deletion packages/lib/src/components/ANCV/ANCV.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,13 @@ export class ANCVElement extends UIElement<ANCVConfiguration> {
})
.catch(error => {
this.setStatus(error?.message || 'error');
if (this.props.onError) this.handleError(new AdyenCheckoutError('ERROR', error));
if (this.props.onError) {
if (error instanceof AdyenCheckoutError) {
this.handleError(error);
} else {
this.handleError(new AdyenCheckoutError('ERROR', error));
}
}
});
};

Expand Down
39 changes: 39 additions & 0 deletions packages/lib/src/components/Giftcard/Giftcard.test.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
import Giftcard from './Giftcard';
import { render, screen } from '@testing-library/preact';
import userEvent from '@testing-library/user-event';
import AdyenCheckoutError from '../../core/Errors/AdyenCheckoutError';
import { AnalyticsModule } from '../../types/global-types';
import { mockDeep } from 'jest-mock-extended';
import { ANALYTICS_ERROR_TYPE, ANALYTICS_EVENT } from '../../core/Analytics/constants';

const flushPromises = () => new Promise(process.nextTick);

Expand Down Expand Up @@ -135,6 +139,41 @@ describe('Giftcard', () => {
expect(onOrderRequest).toHaveBeenCalled();
});

test('should send an error event to the analytics if the createOrder call fails for the session flow', async () => {
const code = 'mockErrorCode';
const analytics = mockDeep<AnalyticsModule>();
const mockedSendAnalytics = analytics.sendAnalytics as jest.Mock;
const onBalanceCheck = jest.fn(resolve =>
resolve({
balance: { value: 500, currency: 'EUR' }
})
);
const giftcard = new Giftcard(global.core, {
...baseProps,
modules: {
resources,
analytics
},
onError: () => {},
onBalanceCheck,
// @ts-ignore test only
session: {
createOrder: () => {
return Promise.reject(new AdyenCheckoutError('NETWORK_ERROR', '', { code }));
}
}
});
render(giftcard.render());
giftcard.setState({ isValid: true });
giftcard.balanceCheck();
await flushPromises();
expect(mockedSendAnalytics).toHaveBeenCalledWith(
'giftcard',
{ code, errorType: ANALYTICS_ERROR_TYPE.apiError, type: ANALYTICS_EVENT.error },
undefined
);
});

test('if there is enough balance for checkout we should require confirmation', async () => {
const onBalanceCheck = jest.fn(resolve =>
resolve({
Expand Down
9 changes: 7 additions & 2 deletions packages/lib/src/components/Giftcard/Giftcard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,6 @@ export class GiftcardElement extends UIElement<GiftCardConfiguration> {
return new Promise((resolve, reject) => {
void this.props.onOrderRequest(resolve, reject, data);
});

if (this.props.session) {
return this.props.session.createOrder();
}
Expand Down Expand Up @@ -102,7 +101,13 @@ export class GiftcardElement extends UIElement<GiftCardConfiguration> {
})
.catch(error => {
this.setStatus(error?.message || 'error');
if (this.props.onError) this.handleError(new AdyenCheckoutError('ERROR', error));
if (this.props.onError) {
if (error instanceof AdyenCheckoutError) {
this.handleError(error);
} else {
this.handleError(new AdyenCheckoutError('ERROR', error));
}
}
});
};

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import AdyenCheckoutError from '../../core/Errors/AdyenCheckoutError';
import { THREEDS2_ERROR, THREEDS2_FINGERPRINT_SUBMIT } from './constants';
import { ANALYTICS_ERROR_TYPE, Analytics3DS2Errors } from '../../core/Analytics/constants';
import { SendAnalyticsObject } from '../../core/Analytics/types';
import { API_ERROR_CODE } from '../../core/Services/sessions/constants';

/**
* ThreeDS2DeviceFingerprint, onComplete, calls a new, internal, endpoint which
Expand All @@ -15,7 +16,8 @@ export default function callSubmit3DS2Fingerprint({ data }): void {
{
path: `v1/submitThreeDS2Fingerprint?token=${this.props.clientKey}`,
loadingContext: this.props.loadingContext,
errorLevel: 'fatal'
errorLevel: 'fatal',
errorCode: API_ERROR_CODE.submitThreeDS2Fingerprint
},
{
...data
Expand Down
65 changes: 62 additions & 3 deletions packages/lib/src/components/internal/UIElement/UIElement.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@ import { any, mock, mockDeep } from 'jest-mock-extended';
import { AdyenCheckout, ThreeDS2Challenge, ThreeDS2DeviceFingerprint } from '../../../index';
import { UIElementProps } from './types';
import { Resources } from '../../../core/Context/Resources';
import { PaymentActionsType } from '../../../types/global-types';
import { AnalyticsModule, PaymentActionsType } from '../../../types/global-types';
import AdyenCheckoutError from '../../../core/Errors/AdyenCheckoutError';
import { ANALYTICS_ERROR_TYPE, ANALYTICS_EVENT } from '../../../core/Analytics/constants';

jest.mock('../../../core/Services/get-translations');

Expand All @@ -23,6 +25,9 @@ class MyElement extends UIElement<MyElementProps> {
public callOnChange() {
super.onChange();
}
public handleAdditionalDetails(data) {
super.handleAdditionalDetails(data);
}
render() {
return '';
}
Expand All @@ -32,8 +37,15 @@ const submitMock = jest.fn();
(global as any).HTMLFormElement.prototype.submit = () => submitMock;

let core;
let analytics;
beforeEach(() => {
core = mockDeep<ICore>();
analytics = mockDeep<AnalyticsModule>();
});

afterEach(() => {
jest.clearAllMocks();
jest.resetAllMocks();
});

describe('UIElement', () => {
Expand Down Expand Up @@ -231,7 +243,7 @@ describe('UIElement', () => {
test('should trigger showValidation() and not call makePaymentsCall() if component is not valid', () => {
const showValidation = jest.fn();

const element = new MyElement(core);
const element = new MyElement(core, { modules: { analytics } });

// @ts-ignore Checking that internal method is not reached
const makePaymentsCallSpy = jest.spyOn(element, 'makePaymentsCall');
Expand Down Expand Up @@ -263,6 +275,7 @@ describe('UIElement', () => {
});

const element = new MyElement(core, {
modules: { analytics },
onSubmit: onSubmitMock,
onPaymentCompleted: onPaymentCompletedMock
});
Expand All @@ -275,7 +288,7 @@ describe('UIElement', () => {
expect(onPaymentCompletedMock).toHaveBeenCalledWith({ resultCode: 'Authorized' }, element);
});

test('should make successfull payment using sessions flow', async () => {
test('should make successful payment using sessions flow', async () => {
const onPaymentCompletedMock = jest.fn();

core.session.submitPayment.calledWith(any()).mockResolvedValue({
Expand All @@ -293,6 +306,7 @@ describe('UIElement', () => {
});

const element = new MyElement(core, {
modules: { analytics },
onPaymentCompleted: onPaymentCompletedMock
});

Expand Down Expand Up @@ -327,6 +341,7 @@ describe('UIElement', () => {
});

const element = new MyElement(core, {
modules: { analytics },
onSubmit: onSubmitMock,
onPaymentFailed: onPaymentFailedMock
});
Expand All @@ -347,6 +362,7 @@ describe('UIElement', () => {
jest.spyOn(MyElement.prototype, 'isValid', 'get').mockReturnValue(true);

const element = new MyElement(core, {
modules: { analytics },
onSubmit: onSubmitMock,
onPaymentFailed: onPaymentFailedMock
});
Expand Down Expand Up @@ -374,6 +390,7 @@ describe('UIElement', () => {
jest.spyOn(MyElement.prototype, 'isValid', 'get').mockReturnValue(true);

const element = new MyElement(core, {
modules: { analytics },
onSubmit: onSubmitMock
});

Expand Down Expand Up @@ -420,6 +437,7 @@ describe('UIElement', () => {
});

const element = new MyElement(core, {
modules: { analytics },
onOrderUpdated: onOrderUpdatedMock
});

Expand Down Expand Up @@ -469,6 +487,7 @@ describe('UIElement', () => {
core.session = null;

const element = new MyElement(core, {
modules: { analytics },
onSubmit: onSubmitMock,
onPaymentMethodsRequest: onPaymentMethodsRequestMock,
onOrderUpdated: onOrderUpdatedMock
Expand Down Expand Up @@ -531,6 +550,7 @@ describe('UIElement', () => {
core.session = null;

const element = new MyElement(core, {
modules: { analytics },
onSubmit: onSubmitMock,
onOrderUpdated: onOrderUpdatedMock,
onError: onErrorMock
Expand All @@ -551,6 +571,26 @@ describe('UIElement', () => {
expect(onOrderUpdatedMock).toHaveBeenCalledTimes(1);
expect(onOrderUpdatedMock).toHaveBeenCalledWith({ order });
});

test('should send an error event to analytic module with correct errorType and error code, if makePayment call fails', async () => {
const errorCode = 'mockedErrorCode';
const txVariant = 'scheme';

core.session.submitPayment.mockImplementation(() => Promise.reject(new AdyenCheckoutError('NETWORK_ERROR', '', { code: errorCode })));
const analytics = mock<AnalyticsModule>();
const mockedSendAnalytics = analytics.sendAnalytics as jest.Mock;
jest.spyOn(MyElement.prototype, 'isValid', 'get').mockReturnValue(true);

const element = new MyElement(core, { type: txVariant, modules: { analytics } });
element.submit();
await new Promise(process.nextTick);

expect(mockedSendAnalytics).toHaveBeenCalledWith(
txVariant,
{ code: errorCode, errorType: ANALYTICS_ERROR_TYPE.apiError, type: ANALYTICS_EVENT.error },
undefined
);
});
});

describe('[Internal] handleAdditionalDetails()', () => {
Expand Down Expand Up @@ -684,5 +724,24 @@ describe('UIElement', () => {
expect(onPaymentFailedMock).toHaveBeenCalledTimes(1);
expect(onPaymentFailedMock).toHaveBeenCalledWith(undefined, element);
});

test('should send an error event to analytic module with correct errorType and error code, if payment/details call fails', async () => {
const errorCode = 'mockedErrorCode';
const txVariant = 'scheme';

core.session.submitDetails.mockImplementation(() => Promise.reject(new AdyenCheckoutError('NETWORK_ERROR', '', { code: errorCode })));

const mockedSendAnalytics = analytics.sendAnalytics as jest.Mock;

const element = new MyElement(core, { type: txVariant, modules: { analytics } });
element.handleAdditionalDetails({});
await new Promise(process.nextTick);

expect(mockedSendAnalytics).toHaveBeenCalledWith(
txVariant,
{ code: errorCode, errorType: ANALYTICS_ERROR_TYPE.apiError, type: ANALYTICS_EVENT.error },
undefined
);
});
});
});
17 changes: 12 additions & 5 deletions packages/lib/src/components/internal/UIElement/UIElement.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,10 @@ import { h } from 'preact';
import BaseElement from '../BaseElement/BaseElement';
import PayButton from '../PayButton';
import { assertIsDropin, cleanupFinalResult, getRegulatoryDefaults, sanitizeResponse, verifyPaymentDidNotFail } from './utils';
import AdyenCheckoutError from '../../../core/Errors/AdyenCheckoutError';
import AdyenCheckoutError, { NETWORK_ERROR } from '../../../core/Errors/AdyenCheckoutError';
import { hasOwnProperty } from '../../../utils/hasOwnProperty';
import { Resources } from '../../../core/Context/Resources';
import { ANALYTICS_SUBMIT_STR } from '../../../core/Analytics/constants';
import { ANALYTICS_ERROR_TYPE, ANALYTICS_EVENT, ANALYTICS_SUBMIT_STR } from '../../../core/Analytics/constants';

import type { AnalyticsInitialEvent, SendAnalyticsObject } from '../../../core/Analytics/types';
import type { CoreConfiguration, ICore, AdditionalDetailsData } from '../../../core/types';
Expand Down Expand Up @@ -177,7 +177,7 @@ export abstract class UIElement<P extends UIElementProps = UIElementProps> exten
if (this.constructor['type'] === 'scheme' || this.constructor['type'] === 'bcmc') {
return this.constructor['type'];
}
return this.props.type;
return this.type;
}

public submit(): void {
Expand Down Expand Up @@ -249,8 +249,11 @@ export abstract class UIElement<P extends UIElementProps = UIElementProps> exten
try {
return await this.core.session.submitPayment(data);
} catch (error: unknown) {
if (error instanceof AdyenCheckoutError) this.handleError(error);
else this.handleError(new AdyenCheckoutError('ERROR', 'Error when making /payments call', { cause: error }));
if (error instanceof AdyenCheckoutError) {
this.handleError(error);
} else {
this.handleError(new AdyenCheckoutError('ERROR', 'Error when making /payments call', { cause: error }));
}

return Promise.reject(error);
}
Expand All @@ -277,6 +280,10 @@ export abstract class UIElement<P extends UIElementProps = UIElementProps> exten
*/
this.setElementStatus('ready');

if (error.name === NETWORK_ERROR && error.options.code) {
this.submitAnalytics({ type: ANALYTICS_EVENT.error, errorType: ANALYTICS_ERROR_TYPE.apiError, code: error.options.code });
}

if (this.props.onError) {
this.props.onError(error, this.elementRef);
}
Expand Down
Loading

0 comments on commit 933f970

Please sign in to comment.