diff --git a/frontend/src/data/media-service.ts b/frontend/src/data/media-service.ts index e377e15233a..214d3fb8568 100644 --- a/frontend/src/data/media-service.ts +++ b/frontend/src/data/media-service.ts @@ -6,6 +6,8 @@ import type { import type { ApiService } from "~/data/api-service" import type { DetailFromMediaType, Media } from "~/types/media" import { AUDIO, type SupportedMediaType } from "~/constants/media" +import { useAnalytics } from "~/composables/use-analytics" +import type { EventName } from "~/types/analytics" import type { AxiosResponse } from "axios" @@ -22,10 +24,52 @@ export interface MediaResult< class MediaService { private readonly apiService: ApiService private readonly mediaType: T["frontendMediaType"] + private readonly searchEvent: EventName constructor(apiService: ApiService, mediaType: T["frontendMediaType"]) { this.apiService = apiService this.mediaType = mediaType + this.searchEvent = + `${this.mediaType.toUpperCase()}_SEARCH_RESPONSE_TIME` as EventName + } + + /** + * Processes AxiosResponse from a search query to + * construct SEARCH_RESPONSE_TIME analytics event. + * @param response - Axios response + * @param requestDatetime - datetime before request was sent + */ + recordSearchTime(response: AxiosResponse, requestDatetime: Date) { + const REQUIRED_HEADERS = ["date", "cf-cache-status", "cf-ray"] + + const responseHeaders = response.headers + if (!REQUIRED_HEADERS.every((header) => header in responseHeaders)) { + return + } + + const responseDatetime = new Date(responseHeaders["date"]) + if (responseDatetime < requestDatetime) { + // response returned was from the local cache + return + } + + const cfRayIATA = responseHeaders["cf-ray"].split("-")[1] + if (cfRayIATA === undefined) { + return + } + + const elapsedSeconds = Math.floor( + (responseDatetime.getTime() - requestDatetime.getTime()) / 1000 + ) + const url = new URL(response.request?.responseURL) + + const { sendCustomEvent } = useAnalytics() + sendCustomEvent(this.searchEvent, { + cfCacheStatus: responseHeaders["cf-cache-status"], + cfRayIATA: cfRayIATA, + elapsedTime: elapsedSeconds, + queryString: url.search, + }) } /** @@ -59,11 +103,16 @@ class MediaService { params.peaks = "true" } + const requestDatetime = new Date() + const res = await this.apiService.query>( this.mediaType, slug, params as unknown as Record ) + + this.recordSearchTime(res, requestDatetime) + return this.transformResults(res.data) } diff --git a/frontend/src/types/analytics.ts b/frontend/src/types/analytics.ts index f37f81c7026..ada52aa1424 100644 --- a/frontend/src/types/analytics.ts +++ b/frontend/src/types/analytics.ts @@ -412,6 +412,34 @@ export type Events = { /** the reasons for why this result is considered sensitive */ sensitivities: string } + + /** + * Description: Time client-side search responses. Gives us observability into + * real user experience of search timings. + * Questions: + * - How long does it take for the client to receive a response to search requests? + */ + IMAGE_SEARCH_RESPONSE_TIME: { + /** the Cloudflare cache status, denoting whether the request hit Cloudflare or went all the way to our servers */ + cfCacheStatus: string + /** the IATA location identifier as part of the `cf-ray` header, indicating the data centre the request passed through */ + cfRayIATA: string + /** how many seconds it took to receive a response for the request */ + elapsedTime: number + /** full query string */ + queryString: string + } + + AUDIO_SEARCH_RESPONSE_TIME: { + /** the Cloudflare cache status, denoting whether the request hit Cloudflare or went all the way to our servers */ + cfCacheStatus: string + /** the IATA location identifier as part of the `cf-ray` header, indicating the data centre the request passed through */ + cfRayIATA: string + /** how many seconds it took to receive a response for the request */ + elapsedTime: number + /** full query string */ + queryString: string + } } /** diff --git a/frontend/test/unit/specs/data/media-service.spec.ts b/frontend/test/unit/specs/data/media-service.spec.ts new file mode 100644 index 00000000000..73aea1581a5 --- /dev/null +++ b/frontend/test/unit/specs/data/media-service.spec.ts @@ -0,0 +1,147 @@ +import { mockCreateApiService } from "~~/test/unit/test-utils/api-service-mock" + +import { initServices } from "~/stores/media/services" +import { useAnalytics } from "~/composables/use-analytics" + +const API_IMAGES_ENDPOINT = "images/" +const API_AUDIO_ENDPOINT = "audio/" +const BASE_URL = "https://www.mockapiservice.openverse.engineering/v1/" + +jest.mock("~/composables/use-analytics") + +const sendCustomEventMock = jest.fn() +const mockedUseAnalytics = useAnalytics as jest.Mock< + ReturnType +> +mockedUseAnalytics.mockImplementation(() => ({ + sendCustomEvent: sendCustomEventMock, +})) + +beforeAll(() => { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + jest.useFakeTimers("modern") + jest.setSystemTime(new Date("Tue, 17 Dec 2019 20:20:00 GMT")) +}) + +afterAll(() => { + jest.useRealTimers() +}) + +describe("Media Service search and recordSearchTime", () => { + beforeEach(() => { + sendCustomEventMock.mockClear() + }) + + it("should not send a SEARCH_RESPONSE_TIME analytics event if any required header is missing", async () => { + mockCreateApiService((axiosMockAdapter) => { + axiosMockAdapter.onGet().reply(200, {}) + }) + + await initServices.image().search({}) + + expect(sendCustomEventMock).not.toHaveBeenCalled() + }) + + it("should not send a SEARCH_RESPONSE_TIME analytics event if the response was locally cached", async () => { + mockCreateApiService((axiosMockAdapter) => { + axiosMockAdapter.onGet().reply(() => { + return [ + 200, + {}, + { + date: "Tue, 17 Dec 2019 19:00:00 GMT", + "cf-ray": "230b030023ae284c-SJC", + "cf-cache-status": "HIT", + }, + ] + }) + }) + + await initServices.audio().search({}) + + expect(sendCustomEventMock).not.toHaveBeenCalled() + }) + + it("should not send a SEARCH_RESPONSE_TIME analytics event if the cf-ray is malformed", async () => { + mockCreateApiService((axiosMockAdapter) => { + axiosMockAdapter.onGet().reply((config) => { + // force config.url so the responseURL is set in the AxiosRequest + config.url = BASE_URL + config.url + return [ + 200, + {}, + { + date: "Tue, 17 Dec 2019 20:30:00 GMT", + "cf-ray": "230b030023ae284c", + "cf-cache-status": "HIT", + }, + ] + }) + }) + + await initServices.audio().search({}) + + expect(sendCustomEventMock).not.toHaveBeenCalled() + }) + + it("should send SEARCH_RESPONSE_TIME analytics with correct parameters", async () => { + mockCreateApiService((axiosMockAdapter) => { + axiosMockAdapter + .onGet(API_IMAGES_ENDPOINT, { params: { q: "apple" } }) + .reply((config) => { + config.url = BASE_URL + config.url + "?q=apple" + return [ + 200, + {}, + { + date: "Tue, 17 Dec 2019 20:20:02 GMT", + "cf-ray": "230b030023ae2822-SJC", + "cf-cache-status": "HIT", + }, + ] + }) + + axiosMockAdapter + .onGet(API_AUDIO_ENDPOINT, { params: { q: "table", peaks: "true" } }) + .reply((config) => { + config.url = BASE_URL + config.url + "?q=table&peaks=true" + return [ + 200, + {}, + { + date: "Tue, 17 Dec 2019 20:20:03 GMT", + "cf-ray": "240b030b23ae2822-LHR", + "cf-cache-status": "MISS", + }, + ] + }) + }) + + const IMAGE_QUERY_PARAMS = { q: "apple" } + await initServices.image().search(IMAGE_QUERY_PARAMS) + + expect(sendCustomEventMock).toHaveBeenCalledWith( + "IMAGE_SEARCH_RESPONSE_TIME", + { + cfCacheStatus: "HIT", + cfRayIATA: "SJC", + elapsedTime: 2, + queryString: "?q=apple", + } + ) + + const AUDIO_QUERY_PARAMS = { q: "table" } + await initServices.audio().search(AUDIO_QUERY_PARAMS) + + expect(sendCustomEventMock).toHaveBeenCalledWith( + "AUDIO_SEARCH_RESPONSE_TIME", + { + cfCacheStatus: "MISS", + cfRayIATA: "LHR", + elapsedTime: 3, + queryString: "?q=table&peaks=true", + } + ) + }) +})