Skip to content

Commit

Permalink
Add SEARCH_RESPONSE_TIME analytics event to searches (#3632)
Browse files Browse the repository at this point in the history
* SEARCH_RESPONSE_TIME analytics event plus tests

* Amend tests

* Change to media-specific events, amend tests accordingly

* Revert extra changes to media-service.ts

* rm newline in media-service.ts

* Move sendCustomEvent line to fix playwright tests

* rm extra newlines
  • Loading branch information
adjeiv authored Feb 8, 2024
1 parent ff1b5d5 commit 920e245
Show file tree
Hide file tree
Showing 3 changed files with 224 additions and 0 deletions.
49 changes: 49 additions & 0 deletions frontend/src/data/media-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand All @@ -22,10 +24,52 @@ export interface MediaResult<
class MediaService<T extends Media> {
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,
})
}

/**
Expand Down Expand Up @@ -59,11 +103,16 @@ class MediaService<T extends Media> {
params.peaks = "true"
}

const requestDatetime = new Date()

const res = await this.apiService.query<MediaResult<T[]>>(
this.mediaType,
slug,
params as unknown as Record<string, string>
)

this.recordSearchTime(res, requestDatetime)

return this.transformResults(res.data)
}

Expand Down
28 changes: 28 additions & 0 deletions frontend/src/types/analytics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}

/**
Expand Down
147 changes: 147 additions & 0 deletions frontend/test/unit/specs/data/media-service.spec.ts
Original file line number Diff line number Diff line change
@@ -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<typeof useAnalytics>
>
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",
}
)
})
})

0 comments on commit 920e245

Please sign in to comment.