Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Analytics: track api errors #3035

Merged
merged 2 commits into from
Jan 2, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
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
Loading