feat(contentful): Add statsd metrics to contentful

Because:

* Want to track number of requests and timings of those requests made
  to contentful.

This commit:

* Adds event emitter to Contentful Client, similar to PayPal Client.
* Add statsd to Contentful Manager to to track requests timings on
  contentful client event.

Closes #FXA-9031
This commit is contained in:
Reino Muhl 2024-02-08 13:53:27 -05:00
Родитель 7789678a7d
Коммит bbcc07802a
Не найден ключ, соответствующий данной подписи
Идентификатор ключа GPG: C86660FCF998897A
6 изменённых файлов: 176 добавлений и 5 удалений

Просмотреть файл

@ -26,9 +26,10 @@ describe('StripeMapperService', () => {
.mockResolvedValue(
mockContentfulConfigUtil as unknown as PurchaseWithDetailsOfferingContentUtil
);
const contentfulClient = {} as ContentfulClient;
const contentfulClient = new ContentfulClient({} as any);
const mockStatsd = {} as any;
stripeMapper = new StripeMapperService(
new ContentfulManager(contentfulClient)
new ContentfulManager(contentfulClient, mockStatsd)
);
});

Просмотреть файл

@ -24,6 +24,7 @@ jest.mock('graphql-request', () => ({
describe('ContentfulClient', () => {
let contentfulClient: ContentfulClient;
const onCallback = jest.fn();
beforeEach(() => {
contentfulClient = new ContentfulClient({
@ -33,6 +34,11 @@ describe('ContentfulClient', () => {
graphqlSpaceId: faker.string.uuid(),
graphqlEnvironment: faker.string.uuid(),
});
contentfulClient.on('response', onCallback);
});
afterEach(() => {
onCallback.mockClear();
});
describe('query', () => {
@ -67,6 +73,23 @@ describe('ContentfulClient', () => {
},
});
});
it('emits event and on second request emits event with cache true', async () => {
expect(onCallback).toHaveBeenCalledWith(
expect.objectContaining({ method: 'query', cache: false })
);
(contentfulClient.client.request as jest.Mock).mockResolvedValueOnce(
mockResponse
);
result = await contentfulClient.query(offeringQuery, {
id,
locale,
});
expect(onCallback).toHaveBeenCalledWith(
expect.objectContaining({ method: 'query', cache: true })
);
});
});
it('throws an error when the graphql request fails', async () => {
@ -81,6 +104,14 @@ describe('ContentfulClient', () => {
locale,
})
).rejects.toThrow(new ContentfulError([error]));
expect(onCallback).toHaveBeenCalledWith(
expect.objectContaining({
method: 'query',
cache: false,
error: expect.anything(),
})
);
});
});
@ -125,6 +156,18 @@ describe('ContentfulClient', () => {
await contentfulClient.getLocale(ACCEPT_LANGUAGE);
expect(global.fetch).toBeCalledTimes(1);
});
it('emits event and on second request emits event with cache true', async () => {
await contentfulClient.getLocale(ACCEPT_LANGUAGE);
expect(onCallback).toHaveBeenCalledWith(
expect.objectContaining({ method: 'getLocales', cache: false })
);
await contentfulClient.getLocale(ACCEPT_LANGUAGE);
expect(onCallback).toHaveBeenCalledWith(
expect.objectContaining({ method: 'getLocales', cache: true })
);
});
});
describe('errors', () => {
@ -145,6 +188,13 @@ describe('ContentfulClient', () => {
cdnErrorResult.message
)
);
expect(onCallback).toHaveBeenCalledWith(
expect.objectContaining({
method: 'getLocales',
cache: false,
error: expect.anything(),
})
);
});
it('throws a cdn execution error when contentful cant be reached', async () => {

Просмотреть файл

@ -15,9 +15,21 @@ import {
ContentfulError,
} from './contentful.error';
import { ContentfulErrorResponse } from './types';
import EventEmitter from 'events';
const DEFAULT_CACHE_TTL = 300000; // Milliseconds
interface EventResponse {
method: string;
requestStartTime: number;
requestEndTime: number;
elapsed: number;
cache: boolean;
query?: TypedDocumentNode;
variables?: string;
error?: Error;
}
@Injectable()
export class ContentfulClient {
client = new GraphQLClient(
@ -25,9 +37,16 @@ export class ContentfulClient {
);
private locales: string[] = [];
private graphqlResultCache: Record<string, unknown> = {};
private emitter: EventEmitter;
public on: (
event: 'response',
listener: (response: EventResponse) => void
) => EventEmitter;
constructor(private contentfulClientConfig: ContentfulClientConfig) {
this.setupCacheBust();
this.emitter = new EventEmitter();
this.on = this.emitter.on.bind(this.emitter);
}
async getLocale(acceptLanguage: string): Promise<string> {
@ -50,7 +69,21 @@ export class ContentfulClient {
);
const cacheKey = variablesString + query;
const emitterResponse = {
method: 'query',
query,
variables: variablesString,
requestStartTime: Date.now(),
cache: false,
};
if (this.graphqlResultCache[cacheKey]) {
this.emitter.emit('response', {
...emitterResponse,
requestEndTime: emitterResponse.requestStartTime,
elapsed: 0,
cache: true,
});
return this.graphqlResultCache[cacheKey] as Result;
}
@ -60,22 +93,57 @@ export class ContentfulClient {
variables,
});
const requestEndTime = Date.now();
this.emitter.emit('response', {
...emitterResponse,
elapsed: requestEndTime - emitterResponse.requestStartTime,
requestEndTime,
});
this.graphqlResultCache[cacheKey] = response;
return response;
} catch (e) {
const requestEndTime = Date.now();
this.emitter.emit('response', {
...emitterResponse,
elapsed: requestEndTime - emitterResponse.requestStartTime,
requestEndTime,
error: e,
});
throw new ContentfulError([e]);
}
}
private async getLocales(): Promise<string[]> {
const emitterResponse = {
method: 'getLocales',
requestStartTime: Date.now(),
cache: false,
};
if (!!this.locales?.length) {
this.emitter.emit('response', {
...emitterResponse,
cache: true,
elapsed: 0,
requestEndTime: emitterResponse.requestStartTime,
});
return this.locales;
}
try {
const localesUrl = `${this.contentfulClientConfig.cdnApiUri}/spaces/${this.contentfulClientConfig.graphqlSpaceId}/environments/${this.contentfulClientConfig.graphqlEnvironment}/locales?access_token=${this.contentfulClientConfig.graphqlApiKey}`;
const response = await fetch(localesUrl);
const requestEndTime = Date.now();
this.emitter.emit('response', {
...emitterResponse,
elapsed: requestEndTime - emitterResponse.requestStartTime,
requestEndTime,
});
const results = await response.json();
if (!response.ok) {
@ -91,6 +159,13 @@ export class ContentfulClient {
return this.locales;
} catch (error) {
const requestEndTime = Date.now();
this.emitter.emit('response', {
...emitterResponse,
elapsed: requestEndTime - emitterResponse.requestStartTime,
requestEndTime,
error,
});
if (error instanceof ContentfulCDNError) {
throw error;
} else {

Просмотреть файл

@ -19,15 +19,21 @@ import {
} from '../../src';
import { PurchaseWithDetailsOfferingContentUtil } from './queries/purchase-with-details-offering-content';
import { PurchaseWithDetailsOfferingContentByPlanIdsResultFactory } from './queries/purchase-with-details-offering-content/factories';
import { StatsD } from 'hot-shots';
describe('ContentfulManager', () => {
let manager: ContentfulManager;
let mockClient: ContentfulClient;
let mockStatsd: StatsD;
beforeEach(async () => {
mockClient = {} as any;
mockClient = new ContentfulClient({} as any);
mockStatsd = {
timing: jest.fn().mockReturnValue({}),
} as unknown as StatsD;
const module: TestingModule = await Test.createTestingModule({
providers: [
{ provide: StatsD, useValue: mockStatsd },
{ provide: ContentfulClient, useValue: mockClient },
ContentfulManager,
],
@ -40,6 +46,25 @@ describe('ContentfulManager', () => {
expect(manager).toBeDefined();
});
it('should call statsd for incoming events', async () => {
const queryData = EligibilityContentByPlanIdsQueryFactory({
purchaseCollection: { items: [], total: 0 },
});
mockClient.client.request = jest.fn().mockResolvedValue(queryData);
await manager.getPurchaseDetailsForEligibility(['test']);
expect(mockStatsd.timing).toHaveBeenCalledWith(
'contentful_request',
expect.any(Number),
undefined,
{
method: 'query',
error: 'false',
cache: 'false',
operationName: 'EligibilityContentByPlanIds',
}
);
});
describe('getPurchaseDetailsForEligibility', () => {
it('should return empty result', async () => {
const queryData = EligibilityContentByPlanIdsQueryFactory({

Просмотреть файл

@ -30,10 +30,30 @@ import {
servicesWithCapabilitiesQuery,
} from './queries/services-with-capabilities';
import { DeepNonNullable } from './types';
import { StatsD } from 'hot-shots';
import { getOperationName } from '@apollo/client/utilities';
@Injectable()
export class ContentfulManager {
constructor(private client: ContentfulClient) {}
constructor(private client: ContentfulClient, private statsd: StatsD) {
this.client.on('response', (response) => {
const defaultTags = {
method: response.method,
error: response.error ? 'true' : 'false',
cache: `${response.cache}`,
};
const operationName = response.query && getOperationName(response.query);
const tags = operationName
? { ...defaultTags, operationName }
: defaultTags;
this.statsd.timing(
'contentful_request',
response.elapsed,
undefined,
tags
);
});
}
async getPurchaseDetailsForCapabilityServiceByPlanIds(
stripePlanIds: string[]

Просмотреть файл

@ -129,7 +129,7 @@ async function run(config) {
graphqlSpaceId: config.contentful.spaceId,
graphqlEnvironment: config.contentful.environment,
});
const contentfulManager = new ContentfulManager(contentfulClient);
const contentfulManager = new ContentfulManager(contentfulClient, statsd);
Container.set(ContentfulManager, contentfulManager);
const capabilityManager = new CapabilityManager(contentfulManager);
const eligibilityManager = new EligibilityManager(contentfulManager);