From 71b7b8666dc548b5444d41267802c7e71cfba800 Mon Sep 17 00:00:00 2001 From: vigneshrajsb Date: Wed, 1 Jul 2026 21:49:01 -0700 Subject: [PATCH 1/5] feat: add telemetry_events table and model --- .../db/migrations/028_add_telemetry_events.ts | 55 ++++++++++++++ src/server/models/TelemetryEvent.ts | 72 +++++++++++++++++++ src/server/models/index.ts | 3 + 3 files changed, 130 insertions(+) create mode 100644 src/server/db/migrations/028_add_telemetry_events.ts create mode 100644 src/server/models/TelemetryEvent.ts diff --git a/src/server/db/migrations/028_add_telemetry_events.ts b/src/server/db/migrations/028_add_telemetry_events.ts new file mode 100644 index 00000000..9ea5267c --- /dev/null +++ b/src/server/db/migrations/028_add_telemetry_events.ts @@ -0,0 +1,55 @@ +/** + * Copyright 2026 GoodRx, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Knex } from 'knex'; + +export const config = { + transaction: true, +}; + +const TELEMETRY_EVENTS_TABLE = 'telemetry_events'; + +export async function up(knex: Knex): Promise { + await knex.schema.createTable(TELEMETRY_EVENTS_TABLE, (table) => { + table.increments('id').primary(); + table.text('source').notNullable().checkIn(['cli', 'ui'], 'telemetry_events_source_check'); + table.uuid('clientId').notNullable(); + table.text('event').notNullable(); + table.jsonb('attributes').notNullable().defaultTo('{}'); + table.integer('durationMs').nullable(); + table.text('status').notNullable().checkIn(['success', 'error'], 'telemetry_events_status_check'); + table.integer('exitCode').nullable(); + table.text('errorClass').nullable(); + table.integer('errorHttpStatus').nullable(); + table.text('errorCode').nullable(); + table.text('clientVersion').notNullable(); + table.text('runtimeVersion').nullable(); + table.text('platform').nullable(); + table.text('arch').nullable(); + table.timestamp('createdAt').notNullable().defaultTo(knex.fn.now()); + table.timestamp('updatedAt').notNullable().defaultTo(knex.fn.now()); + + table.index(['createdAt']); + table.index(['source', 'createdAt']); + table.index(['event']); + table.index(['clientId']); + table.index(['clientVersion']); + }); +} + +export async function down(knex: Knex): Promise { + await knex.schema.dropTableIfExists(TELEMETRY_EVENTS_TABLE); +} diff --git a/src/server/models/TelemetryEvent.ts b/src/server/models/TelemetryEvent.ts new file mode 100644 index 00000000..549b7911 --- /dev/null +++ b/src/server/models/TelemetryEvent.ts @@ -0,0 +1,72 @@ +/** + * Copyright 2026 GoodRx, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import Model from './_Model'; + +export type TelemetrySource = 'cli' | 'ui'; +export type TelemetryStatus = 'success' | 'error'; +export type TelemetryAttributeValue = string | number | boolean | string[]; +export type TelemetryAttributes = Record; + +export default class TelemetryEvent extends Model { + source!: TelemetrySource; + clientId!: string; + event!: string; + attributes!: TelemetryAttributes; + durationMs?: number | null; + status!: TelemetryStatus; + exitCode?: number | null; + errorClass?: string | null; + errorHttpStatus?: number | null; + errorCode?: string | null; + clientVersion!: string; + runtimeVersion?: string | null; + platform?: string | null; + arch?: string | null; + + static tableName = 'telemetry_events'; + static timestamps = true; + static idColumn = 'id'; + + static jsonSchema = { + type: 'object', + required: ['source', 'clientId', 'event', 'status', 'clientVersion'], + properties: { + id: { type: 'integer' }, + source: { type: 'string', enum: ['cli', 'ui'] }, + clientId: { + type: 'string', + pattern: '^[0-9a-fA-F-]{36}$', + }, + event: { type: 'string', minLength: 1, maxLength: 200 }, + attributes: { type: 'object', default: {} }, + durationMs: { type: ['integer', 'null'], minimum: 0 }, + status: { type: 'string', enum: ['success', 'error'] }, + exitCode: { type: ['integer', 'null'] }, + errorClass: { type: ['string', 'null'] }, + errorHttpStatus: { type: ['integer', 'null'] }, + errorCode: { type: ['string', 'null'] }, + clientVersion: { type: 'string', minLength: 1 }, + runtimeVersion: { type: ['string', 'null'] }, + platform: { type: ['string', 'null'] }, + arch: { type: ['string', 'null'] }, + }, + }; + + static get jsonAttributes() { + return ['attributes']; + } +} diff --git a/src/server/models/index.ts b/src/server/models/index.ts index 5675e96b..c919b297 100644 --- a/src/server/models/index.ts +++ b/src/server/models/index.ts @@ -43,6 +43,7 @@ import AgentToolExecution from './AgentToolExecution'; import UserMcpConnection from './UserMcpConnection'; import Site from './Site'; import SiteVersion from './SiteVersion'; +import TelemetryEvent from './TelemetryEvent'; export interface IModels { Build: typeof Build; @@ -74,6 +75,7 @@ export interface IModels { UserMcpConnection: typeof UserMcpConnection; Site: typeof Site; SiteVersion: typeof SiteVersion; + TelemetryEvent: typeof TelemetryEvent; } export { @@ -106,4 +108,5 @@ export { UserMcpConnection, Site, SiteVersion, + TelemetryEvent, }; From 52a22fa81b021ac948dc182d6a4f0ac99043d4f1 Mon Sep 17 00:00:00 2001 From: vigneshrajsb Date: Wed, 1 Jul 2026 21:49:23 -0700 Subject: [PATCH 2/5] feat: add telemetry ingest and stats endpoints --- src/app/api/v2/telemetry/events/route.test.ts | 203 +++++++++++++ src/app/api/v2/telemetry/events/route.ts | 284 ++++++++++++++++++ src/app/api/v2/telemetry/stats/route.test.ts | 122 ++++++++ src/app/api/v2/telemetry/stats/route.ts | 152 ++++++++++ src/server/services/index.ts | 2 + src/server/services/telemetry.ts | 180 +++++++++++ src/server/services/types/index.ts | 2 + 7 files changed, 945 insertions(+) create mode 100644 src/app/api/v2/telemetry/events/route.test.ts create mode 100644 src/app/api/v2/telemetry/events/route.ts create mode 100644 src/app/api/v2/telemetry/stats/route.test.ts create mode 100644 src/app/api/v2/telemetry/stats/route.ts create mode 100644 src/server/services/telemetry.ts diff --git a/src/app/api/v2/telemetry/events/route.test.ts b/src/app/api/v2/telemetry/events/route.test.ts new file mode 100644 index 00000000..9ff7cce9 --- /dev/null +++ b/src/app/api/v2/telemetry/events/route.test.ts @@ -0,0 +1,203 @@ +/** + * Copyright 2026 GoodRx, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { NextRequest } from 'next/server'; + +const mockInsertEvent = jest.fn(); + +jest.mock('server/services/telemetry', () => ({ + __esModule: true, + default: jest.fn(() => ({ + insertEvent: (...args: unknown[]) => mockInsertEvent(...args), + })), +})); + +import { POST } from './route'; + +function makeRequest(body?: unknown, options: { invalidJson?: boolean } = {}): NextRequest { + return { + headers: new Headers([['x-request-id', 'req-test']]), + nextUrl: new URL('http://localhost/api/v2/telemetry/events'), + json: options.invalidJson + ? jest.fn().mockRejectedValue(new SyntaxError('Unexpected token')) + : jest.fn().mockResolvedValue(body), + } as unknown as NextRequest; +} + +const validPayload = { + source: 'cli', + clientId: '4c2c83f1-2a1f-4a3e-9b5d-1a2b3c4d5e6f', + event: 'builds list', + attributes: { flags: ['--json', '--verbose'] }, + durationMs: 1200, + status: 'success', + exitCode: 0, + clientVersion: '1.2.3', + runtimeVersion: 'v20.11.0', + platform: 'darwin', + arch: 'arm64', +}; + +describe('POST /api/v2/telemetry/events', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockInsertEvent.mockResolvedValue({ id: 42, createdAt: '2026-07-01T00:00:00.000Z' }); + }); + + it('inserts a valid event and returns 201', async () => { + const response = await POST(makeRequest(validPayload)); + const body = await response.json(); + + expect(response.status).toBe(201); + expect(body.data.event).toEqual({ id: 42, createdAt: '2026-07-01T00:00:00.000Z' }); + expect(body.error).toBeNull(); + expect(mockInsertEvent).toHaveBeenCalledWith({ + source: 'cli', + clientId: '4c2c83f1-2a1f-4a3e-9b5d-1a2b3c4d5e6f', + event: 'builds list', + attributes: { flags: ['--json', '--verbose'] }, + durationMs: 1200, + status: 'success', + exitCode: 0, + errorClass: null, + errorHttpStatus: null, + errorCode: null, + clientVersion: '1.2.3', + runtimeVersion: 'v20.11.0', + platform: 'darwin', + arch: 'arm64', + }); + }); + + it('accepts ui events with only required fields and defaults optionals', async () => { + const response = await POST( + makeRequest({ + source: 'ui', + clientId: validPayload.clientId, + event: 'builds page viewed', + status: 'success', + clientVersion: '2.0.0', + }) + ); + + expect(response.status).toBe(201); + expect(mockInsertEvent).toHaveBeenCalledWith( + expect.objectContaining({ + source: 'ui', + attributes: {}, + durationMs: null, + exitCode: null, + runtimeVersion: null, + platform: null, + arch: null, + }) + ); + }); + + it('accepts error events with error details', async () => { + const response = await POST( + makeRequest({ + source: 'cli', + clientId: validPayload.clientId, + event: 'builds get', + durationMs: 300, + status: 'error', + exitCode: 1, + errorClass: 'HttpError', + errorHttpStatus: 404, + errorCode: 'NOT_FOUND', + clientVersion: '1.2.3', + }) + ); + + expect(response.status).toBe(201); + expect(mockInsertEvent).toHaveBeenCalledWith( + expect.objectContaining({ + status: 'error', + exitCode: 1, + errorClass: 'HttpError', + errorHttpStatus: 404, + errorCode: 'NOT_FOUND', + }) + ); + }); + + it('never forwards unknown fields such as user identity', async () => { + const response = await POST( + makeRequest({ + ...validPayload, + userEmail: 'someone@example.com', + token: 'secret', + }) + ); + + expect(response.status).toBe(201); + const inserted = mockInsertEvent.mock.calls[0][0]; + expect(inserted).not.toHaveProperty('userEmail'); + expect(inserted).not.toHaveProperty('token'); + }); + + it('rejects invalid JSON bodies', async () => { + const response = await POST(makeRequest(undefined, { invalidJson: true })); + const body = await response.json(); + + expect(response.status).toBe(400); + expect(body.error.message).toContain('Invalid JSON'); + expect(mockInsertEvent).not.toHaveBeenCalled(); + }); + + it.each([ + ['missing source', { ...validPayload, source: undefined }], + ['invalid source', { ...validPayload, source: 'mobile' }], + ['missing clientId', { ...validPayload, clientId: undefined }], + ['non-uuid clientId', { ...validPayload, clientId: 'not-a-uuid' }], + ['empty event', { ...validPayload, event: '' }], + ['event too long', { ...validPayload, event: 'a'.repeat(201) }], + ['array attributes', { ...validPayload, attributes: ['--json'] }], + ['string attributes', { ...validPayload, attributes: 'flags' }], + ['object attribute value', { ...validPayload, attributes: { nested: { deep: true } } }], + ['non-string array attribute value', { ...validPayload, attributes: { flags: [1, 2] } }], + [ + 'oversized attributes', + { + ...validPayload, + attributes: { + blob: 'x'.repeat(500), + blob2: 'y'.repeat(500), + blob3: 'z'.repeat(500), + blob4: 'w'.repeat(500), + blob5: 'v'.repeat(500), + }, + }, + ], + ['non-integer durationMs', { ...validPayload, durationMs: 12.5 }], + ['negative durationMs', { ...validPayload, durationMs: -1 }], + ['missing status', { ...validPayload, status: undefined }], + ['invalid status', { ...validPayload, status: 'failed' }], + ['non-integer exitCode', { ...validPayload, exitCode: 1.5 }], + ['non-integer errorHttpStatus', { ...validPayload, errorHttpStatus: 'oops' }], + ['missing clientVersion', { ...validPayload, clientVersion: undefined }], + ['array body', [validPayload]], + ['string body', 'hello'], + ])('rejects %s with 400', async (_name, payload) => { + const response = await POST(makeRequest(payload)); + const body = await response.json(); + + expect(response.status).toBe(400); + expect(body.error.message).toContain('Validation failed'); + expect(mockInsertEvent).not.toHaveBeenCalled(); + }); +}); diff --git a/src/app/api/v2/telemetry/events/route.ts b/src/app/api/v2/telemetry/events/route.ts new file mode 100644 index 00000000..a47dc047 --- /dev/null +++ b/src/app/api/v2/telemetry/events/route.ts @@ -0,0 +1,284 @@ +/** + * Copyright 2026 GoodRx, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { NextRequest } from 'next/server'; +import { createApiHandler } from 'server/lib/createApiHandler'; +import { errorResponse, successResponse } from 'server/lib/response'; +import TelemetryService, { TelemetryEventInput } from 'server/services/telemetry'; +import type { TelemetryAttributes, TelemetrySource, TelemetryStatus } from 'server/models/TelemetryEvent'; + +export const runtime = 'nodejs'; + +const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; +const MAX_EVENT_LENGTH = 200; +const MAX_TEXT_FIELD_LENGTH = 200; +const MAX_ATTRIBUTES_SERIALIZED_BYTES = 2048; +const MAX_ATTRIBUTE_STRING_LENGTH = 500; +const SOURCES: TelemetrySource[] = ['cli', 'ui']; +const STATUSES: TelemetryStatus[] = ['success', 'error']; + +function isNonEmptyString(value: unknown, maxLength: number): value is string { + return typeof value === 'string' && value.length > 0 && value.length <= maxLength; +} + +function isOptionalString(value: unknown, maxLength: number): value is string | null | undefined { + return value == null || (typeof value === 'string' && value.length <= maxLength); +} + +function isInteger(value: unknown): value is number { + return typeof value === 'number' && Number.isInteger(value); +} + +function isOptionalInteger(value: unknown): value is number | null | undefined { + return value == null || isInteger(value); +} + +function isValidAttributeValue(value: unknown): boolean { + if (typeof value === 'string') { + return value.length <= MAX_ATTRIBUTE_STRING_LENGTH; + } + if (typeof value === 'number' || typeof value === 'boolean') { + return true; + } + if (Array.isArray(value)) { + return value.every((item) => typeof item === 'string' && item.length <= MAX_ATTRIBUTE_STRING_LENGTH); + } + return false; +} + +function validateAttributes(value: unknown): { attributes?: TelemetryAttributes; error?: string } { + if (value === undefined) { + return { attributes: {} }; + } + + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return { error: 'attributes must be an object when provided.' }; + } + + const entries = Object.entries(value as Record); + if (!entries.every(([, entryValue]) => isValidAttributeValue(entryValue))) { + return { error: 'attributes values must be strings, numbers, booleans, or arrays of strings.' }; + } + + if (JSON.stringify(value).length > MAX_ATTRIBUTES_SERIALIZED_BYTES) { + return { error: `attributes must serialize to at most ${MAX_ATTRIBUTES_SERIALIZED_BYTES} bytes.` }; + } + + return { attributes: value as TelemetryAttributes }; +} + +function validateEventPayload(body: unknown): { event?: TelemetryEventInput; errors: string[] } { + const errors: string[] = []; + + if (!body || typeof body !== 'object' || Array.isArray(body)) { + return { errors: ['Request body must be a JSON object.'] }; + } + + const payload = body as Record; + + if (typeof payload.source !== 'string' || !SOURCES.includes(payload.source as TelemetrySource)) { + errors.push(`source must be one of: ${SOURCES.join(', ')}.`); + } + + if (!isNonEmptyString(payload.clientId, 36) || !UUID_PATTERN.test(payload.clientId)) { + errors.push('clientId must be a UUID string.'); + } + + if (!isNonEmptyString(payload.event, MAX_EVENT_LENGTH)) { + errors.push(`event must be a non-empty string of at most ${MAX_EVENT_LENGTH} characters.`); + } + + const { attributes, error: attributesError } = validateAttributes(payload.attributes); + if (attributesError) { + errors.push(attributesError); + } + + if (payload.durationMs != null && (!isInteger(payload.durationMs) || payload.durationMs < 0)) { + errors.push('durationMs must be a non-negative integer when provided.'); + } + + if (typeof payload.status !== 'string' || !STATUSES.includes(payload.status as TelemetryStatus)) { + errors.push(`status must be one of: ${STATUSES.join(', ')}.`); + } + + if (!isOptionalInteger(payload.exitCode)) { + errors.push('exitCode must be an integer when provided.'); + } + + if (!isOptionalString(payload.errorClass, MAX_TEXT_FIELD_LENGTH)) { + errors.push(`errorClass must be a string of at most ${MAX_TEXT_FIELD_LENGTH} characters when provided.`); + } + + if (!isOptionalInteger(payload.errorHttpStatus)) { + errors.push('errorHttpStatus must be an integer when provided.'); + } + + if (!isOptionalString(payload.errorCode, MAX_TEXT_FIELD_LENGTH)) { + errors.push(`errorCode must be a string of at most ${MAX_TEXT_FIELD_LENGTH} characters when provided.`); + } + + if (!isNonEmptyString(payload.clientVersion, MAX_TEXT_FIELD_LENGTH)) { + errors.push(`clientVersion must be a non-empty string of at most ${MAX_TEXT_FIELD_LENGTH} characters.`); + } + + if (!isOptionalString(payload.runtimeVersion, MAX_TEXT_FIELD_LENGTH)) { + errors.push(`runtimeVersion must be a string of at most ${MAX_TEXT_FIELD_LENGTH} characters when provided.`); + } + + if (!isOptionalString(payload.platform, MAX_TEXT_FIELD_LENGTH)) { + errors.push(`platform must be a string of at most ${MAX_TEXT_FIELD_LENGTH} characters when provided.`); + } + + if (!isOptionalString(payload.arch, MAX_TEXT_FIELD_LENGTH)) { + errors.push(`arch must be a string of at most ${MAX_TEXT_FIELD_LENGTH} characters when provided.`); + } + + if (errors.length) { + return { errors }; + } + + // Whitelist known fields only: the table is deliberately anonymous, so no + // user identity or unexpected attributes can flow through to storage. + return { + errors: [], + event: { + source: payload.source as TelemetrySource, + clientId: (payload.clientId as string).toLowerCase(), + event: payload.event as string, + attributes, + durationMs: (payload.durationMs as number | null | undefined) ?? null, + status: payload.status as TelemetryStatus, + exitCode: (payload.exitCode as number | null | undefined) ?? null, + errorClass: (payload.errorClass as string | null | undefined) ?? null, + errorHttpStatus: (payload.errorHttpStatus as number | null | undefined) ?? null, + errorCode: (payload.errorCode as string | null | undefined) ?? null, + clientVersion: payload.clientVersion as string, + runtimeVersion: (payload.runtimeVersion as string | null | undefined) ?? null, + platform: (payload.platform as string | null | undefined) ?? null, + arch: (payload.arch as string | null | undefined) ?? null, + }, + }; +} + +/** + * @openapi + * /api/v2/telemetry/events: + * post: + * summary: Record a telemetry event + * description: Stores one anonymous telemetry event from a reporting client (CLI or UI). No user identity is stored. + * tags: + * - Telemetry + * operationId: createTelemetryEvent + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: + * - source + * - clientId + * - event + * - status + * - clientVersion + * properties: + * source: + * type: string + * enum: [cli, ui] + * description: Which client type reported the event. + * clientId: + * type: string + * format: uuid + * description: Anonymous per-client identifier. + * event: + * type: string + * maxLength: 200 + * description: Event name. For the CLI this is the space-joined command path, e.g. "builds list". + * attributes: + * type: object + * description: Arbitrary event attributes. Values limited to strings, numbers, booleans, or string arrays; at most 2KB serialized. + * additionalProperties: true + * durationMs: + * type: integer + * minimum: 0 + * nullable: true + * status: + * type: string + * enum: [success, error] + * exitCode: + * type: integer + * nullable: true + * description: Process exit code (CLI-only). + * errorClass: + * type: string + * nullable: true + * errorHttpStatus: + * type: integer + * nullable: true + * errorCode: + * type: string + * nullable: true + * clientVersion: + * type: string + * description: Version of the reporting client. + * runtimeVersion: + * type: string + * nullable: true + * platform: + * type: string + * nullable: true + * arch: + * type: string + * nullable: true + * responses: + * '201': + * description: Telemetry event recorded. + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/SuccessApiResponse' + * '400': + * description: Invalid payload. + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ApiErrorResponse' + * '401': + * description: Unauthorized + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ApiErrorResponse' + */ +const postHandler = async (req: NextRequest) => { + let body: unknown; + try { + body = await req.json(); + } catch { + return errorResponse(new Error('Invalid JSON in request body'), { status: 400 }, req); + } + + const { event, errors } = validateEventPayload(body); + if (!event) { + return errorResponse(new Error(`Validation failed: ${errors.join(' ')}`), { status: 400 }, req); + } + + const service = new TelemetryService(); + const inserted = await service.insertEvent(event); + return successResponse({ event: { id: inserted.id, createdAt: inserted.createdAt ?? null } }, { status: 201 }, req); +}; + +export const POST = createApiHandler(postHandler); diff --git a/src/app/api/v2/telemetry/stats/route.test.ts b/src/app/api/v2/telemetry/stats/route.test.ts new file mode 100644 index 00000000..568664b8 --- /dev/null +++ b/src/app/api/v2/telemetry/stats/route.test.ts @@ -0,0 +1,122 @@ +/** + * Copyright 2026 GoodRx, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { NextRequest } from 'next/server'; + +const mockGetStats = jest.fn(); + +jest.mock('server/services/telemetry', () => ({ + __esModule: true, + default: jest.fn(() => ({ + getStats: (...args: unknown[]) => mockGetStats(...args), + })), +})); + +import { GET } from './route'; + +function makeRequest(params: Record = {}): NextRequest { + const url = new URL('http://localhost/api/v2/telemetry/stats'); + for (const [key, value] of Object.entries(params)) { + url.searchParams.set(key, value); + } + + return { + headers: new Headers([['x-request-id', 'req-test']]), + nextUrl: url, + } as unknown as NextRequest; +} + +const statsFixture = { + usageOverTime: [{ bucket: '2026-06-01T00:00:00.000Z', count: 10 }], + topEvents: [ + { + event: 'builds list', + count: 10, + errorCount: 1, + errorRate: 0.1, + p50DurationMs: 120, + p95DurationMs: 900, + }, + ], + activeClients: { + total: 4, + overTime: [{ bucket: '2026-06-01T00:00:00.000Z', count: 4 }], + }, + versions: [{ clientVersion: '1.2.3', count: 4 }], + platforms: [{ platform: 'darwin', count: 3 }], +}; + +describe('GET /api/v2/telemetry/stats', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockGetStats.mockResolvedValue(statsFixture); + }); + + it('returns stats for a valid query', async () => { + const response = await GET( + makeRequest({ + source: 'cli', + from: '2026-06-01T00:00:00.000Z', + to: '2026-06-30T00:00:00.000Z', + interval: 'week', + }) + ); + const body = await response.json(); + + expect(response.status).toBe(200); + expect(body.error).toBeNull(); + expect(body.data.range).toEqual({ + source: 'cli', + from: '2026-06-01T00:00:00.000Z', + to: '2026-06-30T00:00:00.000Z', + interval: 'week', + }); + expect(body.data.stats).toEqual(statsFixture); + expect(mockGetStats).toHaveBeenCalledWith({ + source: 'cli', + from: new Date('2026-06-01T00:00:00.000Z'), + to: new Date('2026-06-30T00:00:00.000Z'), + interval: 'week', + }); + }); + + it('defaults to the last 30 days with day interval', async () => { + const response = await GET(makeRequest({ source: 'ui' })); + + expect(response.status).toBe(200); + const query = mockGetStats.mock.calls[0][0]; + expect(query.source).toBe('ui'); + expect(query.interval).toBe('day'); + const rangeMs = query.to.getTime() - query.from.getTime(); + expect(rangeMs).toBe(30 * 24 * 60 * 60 * 1000); + }); + + it.each([ + ['missing source', {}], + ['invalid source', { source: 'mobile' }], + ['invalid from', { source: 'cli', from: 'not-a-date' }], + ['invalid to', { source: 'cli', to: 'not-a-date' }], + ['from after to', { source: 'cli', from: '2026-06-30T00:00:00.000Z', to: '2026-06-01T00:00:00.000Z' }], + ['invalid interval', { source: 'cli', interval: 'month' }], + ])('rejects %s with 400', async (_name, params) => { + const response = await GET(makeRequest(params as Record)); + const body = await response.json(); + + expect(response.status).toBe(400); + expect(body.error.message).toContain('Validation failed'); + expect(mockGetStats).not.toHaveBeenCalled(); + }); +}); diff --git a/src/app/api/v2/telemetry/stats/route.ts b/src/app/api/v2/telemetry/stats/route.ts new file mode 100644 index 00000000..057918f6 --- /dev/null +++ b/src/app/api/v2/telemetry/stats/route.ts @@ -0,0 +1,152 @@ +/** + * Copyright 2026 GoodRx, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { NextRequest } from 'next/server'; +import { createApiHandler } from 'server/lib/createApiHandler'; +import { errorResponse, successResponse } from 'server/lib/response'; +import TelemetryService, { TelemetryStatsInterval, TelemetryStatsQuery } from 'server/services/telemetry'; +import type { TelemetrySource } from 'server/models/TelemetryEvent'; + +export const runtime = 'nodejs'; + +const DEFAULT_RANGE_DAYS = 30; +const SOURCES: TelemetrySource[] = ['cli', 'ui']; +const INTERVALS: TelemetryStatsInterval[] = ['day', 'week']; + +function parseDateParam(value: string | null, fallback: Date): Date | null { + if (value == null || value === '') { + return fallback; + } + + const parsed = new Date(value); + return Number.isNaN(parsed.getTime()) ? null : parsed; +} + +function parseStatsQuery(searchParams: URLSearchParams): { query?: TelemetryStatsQuery; error?: string } { + const source = searchParams.get('source') as TelemetrySource | null; + if (!source || !SOURCES.includes(source)) { + return { error: `source is required and must be one of: ${SOURCES.join(', ')}.` }; + } + + const now = new Date(); + const defaultFrom = new Date(now.getTime() - DEFAULT_RANGE_DAYS * 24 * 60 * 60 * 1000); + + const to = parseDateParam(searchParams.get('to'), now); + if (!to) { + return { error: 'to must be a valid ISO date.' }; + } + + const from = parseDateParam(searchParams.get('from'), defaultFrom); + if (!from) { + return { error: 'from must be a valid ISO date.' }; + } + + if (from.getTime() > to.getTime()) { + return { error: 'from must be earlier than or equal to to.' }; + } + + const interval = (searchParams.get('interval') || 'day') as TelemetryStatsInterval; + if (!INTERVALS.includes(interval)) { + return { error: `interval must be one of: ${INTERVALS.join(', ')}.` }; + } + + return { query: { source, from, to, interval } }; +} + +/** + * @openapi + * /api/v2/telemetry/stats: + * get: + * summary: Get telemetry statistics + * description: Returns aggregated telemetry statistics (usage over time, top events, active clients, versions, and platforms) for one source over the requested time range. + * tags: + * - Telemetry + * operationId: getTelemetryStats + * parameters: + * - name: source + * in: query + * required: true + * description: Which client type to aggregate. + * schema: + * type: string + * enum: [cli, ui] + * - name: from + * in: query + * required: false + * description: ISO date for the start of the range. Defaults to 30 days before now. + * schema: + * type: string + * format: date-time + * - name: to + * in: query + * required: false + * description: ISO date for the end of the range. Defaults to now. + * schema: + * type: string + * format: date-time + * - name: interval + * in: query + * required: false + * description: Bucket size for time series. + * schema: + * type: string + * enum: [day, week] + * default: day + * responses: + * '200': + * description: Aggregated telemetry statistics. + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/SuccessApiResponse' + * '400': + * description: Invalid query parameters. + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ApiErrorResponse' + * '401': + * description: Unauthorized + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ApiErrorResponse' + */ +const getHandler = async (req: NextRequest) => { + const { query, error } = parseStatsQuery(req.nextUrl.searchParams); + if (!query) { + return errorResponse(new Error(`Validation failed: ${error}`), { status: 400 }, req); + } + + const service = new TelemetryService(); + const stats = await service.getStats(query); + + return successResponse( + { + range: { + source: query.source, + from: query.from.toISOString(), + to: query.to.toISOString(), + interval: query.interval, + }, + stats, + }, + { status: 200 }, + req + ); +}; + +export const GET = createApiHandler(getHandler); diff --git a/src/server/services/index.ts b/src/server/services/index.ts index a98d9464..1641fa6e 100644 --- a/src/server/services/index.ts +++ b/src/server/services/index.ts @@ -32,6 +32,7 @@ import LabelService from 'server/services/label'; import TTLCleanupService from 'server/services/ttlCleanup'; import DeployCleanupService from 'server/services/deployCleanup'; import SitesService from 'server/services/sites'; +import TelemetryService from 'server/services/telemetry'; import { IServices } from 'server/services/types'; export default function createAndBindServices(): IServices { @@ -54,5 +55,6 @@ export default function createAndBindServices(): IServices { TTLCleanupService: new TTLCleanupService(), DeployCleanupService: new DeployCleanupService(), SitesService: new SitesService(), + TelemetryService: new TelemetryService(), }; } diff --git a/src/server/services/telemetry.ts b/src/server/services/telemetry.ts new file mode 100644 index 00000000..6ff2df1f --- /dev/null +++ b/src/server/services/telemetry.ts @@ -0,0 +1,180 @@ +/** + * Copyright 2026 GoodRx, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import Service from './_service'; +import type TelemetryEvent from 'server/models/TelemetryEvent'; +import type { TelemetryAttributes, TelemetrySource, TelemetryStatus } from 'server/models/TelemetryEvent'; + +const TELEMETRY_EVENTS_TABLE = 'telemetry_events'; +const TOP_EVENTS_LIMIT = 20; + +export type TelemetryEventInput = { + source: TelemetrySource; + clientId: string; + event: string; + attributes?: TelemetryAttributes; + durationMs?: number | null; + status: TelemetryStatus; + exitCode?: number | null; + errorClass?: string | null; + errorHttpStatus?: number | null; + errorCode?: string | null; + clientVersion: string; + runtimeVersion?: string | null; + platform?: string | null; + arch?: string | null; +}; + +export type TelemetryStatsInterval = 'day' | 'week'; + +export type TelemetryStatsQuery = { + source: TelemetrySource; + from: Date; + to: Date; + interval: TelemetryStatsInterval; +}; + +export type TelemetryBucketCount = { + bucket: string; + count: number; +}; + +export type TelemetryEventStats = { + event: string; + count: number; + errorCount: number; + errorRate: number; + p50DurationMs: number | null; + p95DurationMs: number | null; +}; + +export type TelemetryStats = { + usageOverTime: TelemetryBucketCount[]; + topEvents: TelemetryEventStats[]; + activeClients: { + total: number; + overTime: TelemetryBucketCount[]; + }; + versions: Array<{ clientVersion: string; count: number }>; + platforms: Array<{ platform: string | null; count: number }>; +}; + +function toBucketString(bucket: unknown): string { + return bucket instanceof Date ? bucket.toISOString() : String(bucket); +} + +function toCount(value: unknown): number { + return Number(value) || 0; +} + +function toDurationMs(value: unknown): number | null { + return value == null ? null : Number(value); +} + +export default class TelemetryService extends Service { + async insertEvent(event: TelemetryEventInput): Promise { + return this.db.models.TelemetryEvent.query().insert({ + ...event, + attributes: event.attributes ?? {}, + }); + } + + async getStats({ source, from, to, interval }: TelemetryStatsQuery): Promise { + const knex = this.db.knex; + const range: [string, string] = [from.toISOString(), to.toISOString()]; + + const [usageRows, eventRows, clientTotalRow, clientOverTimeRows, versionRows, platformRows] = await Promise.all([ + knex(TELEMETRY_EVENTS_TABLE) + .select(knex.raw('date_trunc(?, "createdAt") as bucket', [interval])) + .select(knex.raw('count(*)::int as count')) + .where('source', source) + .whereBetween('createdAt', range) + .groupByRaw('1') + .orderByRaw('1'), + knex(TELEMETRY_EVENTS_TABLE) + .select('event') + .select(knex.raw('count(*)::int as count')) + .select(knex.raw(`count(*) filter (where status = 'error')::int as "errorCount"`)) + .select(knex.raw('percentile_cont(0.5) within group (order by "durationMs") as "p50DurationMs"')) + .select(knex.raw('percentile_cont(0.95) within group (order by "durationMs") as "p95DurationMs"')) + .where('source', source) + .whereBetween('createdAt', range) + .groupBy('event') + .orderBy('count', 'desc') + .limit(TOP_EVENTS_LIMIT), + knex(TELEMETRY_EVENTS_TABLE) + .select(knex.raw('count(distinct "clientId")::int as count')) + .where('source', source) + .whereBetween('createdAt', range) + .first(), + knex(TELEMETRY_EVENTS_TABLE) + .select(knex.raw('date_trunc(?, "createdAt") as bucket', [interval])) + .select(knex.raw('count(distinct "clientId")::int as count')) + .where('source', source) + .whereBetween('createdAt', range) + .groupByRaw('1') + .orderByRaw('1'), + knex(TELEMETRY_EVENTS_TABLE) + .select('clientVersion') + .select(knex.raw('count(distinct "clientId")::int as count')) + .where('source', source) + .whereBetween('createdAt', range) + .groupBy('clientVersion') + .orderBy('count', 'desc'), + knex(TELEMETRY_EVENTS_TABLE) + .select('platform') + .select(knex.raw('count(distinct "clientId")::int as count')) + .where('source', source) + .whereBetween('createdAt', range) + .groupBy('platform') + .orderBy('count', 'desc'), + ]); + + return { + usageOverTime: usageRows.map((row) => ({ + bucket: toBucketString(row.bucket), + count: toCount(row.count), + })), + topEvents: eventRows.map((row) => { + const count = toCount(row.count); + const errorCount = toCount(row.errorCount); + return { + event: row.event, + count, + errorCount, + errorRate: count > 0 ? errorCount / count : 0, + p50DurationMs: toDurationMs(row.p50DurationMs), + p95DurationMs: toDurationMs(row.p95DurationMs), + }; + }), + activeClients: { + total: toCount(clientTotalRow?.count), + overTime: clientOverTimeRows.map((row) => ({ + bucket: toBucketString(row.bucket), + count: toCount(row.count), + })), + }, + versions: versionRows.map((row) => ({ + clientVersion: row.clientVersion, + count: toCount(row.count), + })), + platforms: platformRows.map((row) => ({ + platform: row.platform ?? null, + count: toCount(row.count), + })), + }; + } +} diff --git a/src/server/services/types/index.ts b/src/server/services/types/index.ts index 57c928ac..26237869 100644 --- a/src/server/services/types/index.ts +++ b/src/server/services/types/index.ts @@ -32,6 +32,7 @@ import LabelService from 'server/services/label'; import TTLCleanupService from 'server/services/ttlCleanup'; import DeployCleanupService from 'server/services/deployCleanup'; import SitesService from 'server/services/sites'; +import TelemetryService from 'server/services/telemetry'; export interface IServices { BuildService: BuildService; @@ -52,6 +53,7 @@ export interface IServices { TTLCleanupService: TTLCleanupService; DeployCleanupService: DeployCleanupService; SitesService: SitesService; + TelemetryService: TelemetryService; } export * from 'server/services/types/github'; From 81dfd7bea9c01f38b86443003e54d23152eefa3c Mon Sep 17 00:00:00 2001 From: vigneshrajsb Date: Wed, 1 Jul 2026 22:55:49 -0700 Subject: [PATCH 3/5] fix: source telemetry timestamps from database defaults --- src/server/models/TelemetryEvent.ts | 5 ++++- src/server/services/telemetry.ts | 10 ++++++---- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/src/server/models/TelemetryEvent.ts b/src/server/models/TelemetryEvent.ts index 549b7911..f5449a8f 100644 --- a/src/server/models/TelemetryEvent.ts +++ b/src/server/models/TelemetryEvent.ts @@ -38,7 +38,10 @@ export default class TelemetryEvent extends Model { arch?: string | null; static tableName = 'telemetry_events'; - static timestamps = true; + // Timestamps come from the table's CURRENT_TIMESTAMP defaults: stats bucketing + // compares createdAt against now() in SQL, and the app-side getUtcTimestamp() + // writes naive UTC strings that Postgres misreads on non-UTC hosts. + static timestamps = false; static idColumn = 'id'; static jsonSchema = { diff --git a/src/server/services/telemetry.ts b/src/server/services/telemetry.ts index 6ff2df1f..f46958c5 100644 --- a/src/server/services/telemetry.ts +++ b/src/server/services/telemetry.ts @@ -86,10 +86,12 @@ function toDurationMs(value: unknown): number | null { export default class TelemetryService extends Service { async insertEvent(event: TelemetryEventInput): Promise { - return this.db.models.TelemetryEvent.query().insert({ - ...event, - attributes: event.attributes ?? {}, - }); + return this.db.models.TelemetryEvent.query() + .insert({ + ...event, + attributes: event.attributes ?? {}, + }) + .returning('*'); } async getStats({ source, from, to, interval }: TelemetryStatsQuery): Promise { From d07089d9b92485a51d619349594740f342a51629 Mon Sep 17 00:00:00 2001 From: vigneshrajsb Date: Thu, 2 Jul 2026 09:17:49 -0700 Subject: [PATCH 4/5] fix: type telemetry stats rows for the server tsc build --- src/server/services/telemetry.ts | 22 ++++++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/src/server/services/telemetry.ts b/src/server/services/telemetry.ts index f46958c5..cc5c96ac 100644 --- a/src/server/services/telemetry.ts +++ b/src/server/services/telemetry.ts @@ -98,7 +98,18 @@ export default class TelemetryService extends Service { const knex = this.db.knex; const range: [string, string] = [from.toISOString(), to.toISOString()]; - const [usageRows, eventRows, clientTotalRow, clientOverTimeRows, versionRows, platformRows] = await Promise.all([ + type BucketCountRow = { bucket: Date | string; count: number }; + type TopEventRow = { + event: string; + count: number; + errorCount: number; + p50DurationMs: number | string | null; + p95DurationMs: number | string | null; + }; + type VersionRow = { clientVersion: string; count: number }; + type PlatformRow = { platform: string | null; count: number }; + + const [usageRows, eventRows, clientTotalRow, clientOverTimeRows, versionRows, platformRows] = (await Promise.all([ knex(TELEMETRY_EVENTS_TABLE) .select(knex.raw('date_trunc(?, "createdAt") as bucket', [interval])) .select(knex.raw('count(*)::int as count')) @@ -143,7 +154,14 @@ export default class TelemetryService extends Service { .whereBetween('createdAt', range) .groupBy('platform') .orderBy('count', 'desc'), - ]); + ])) as unknown as [ + BucketCountRow[], + TopEventRow[], + { count: number } | undefined, + BucketCountRow[], + VersionRow[], + PlatformRow[] + ]; return { usageOverTime: usageRows.map((row) => ({ From 5a2dcfcb463c6eeed598e6e3c48094f317e72f4d Mon Sep 17 00:00:00 2001 From: vigneshrajsb Date: Thu, 2 Jul 2026 11:59:31 -0700 Subject: [PATCH 5/5] feat: add named response schemas for telemetry endpoints --- src/app/api/v2/telemetry/events/route.ts | 2 +- src/app/api/v2/telemetry/stats/route.ts | 2 +- src/shared/openApiSpec.ts | 145 +++++++++++++++++++++++ 3 files changed, 147 insertions(+), 2 deletions(-) diff --git a/src/app/api/v2/telemetry/events/route.ts b/src/app/api/v2/telemetry/events/route.ts index a47dc047..fd94e1c2 100644 --- a/src/app/api/v2/telemetry/events/route.ts +++ b/src/app/api/v2/telemetry/events/route.ts @@ -249,7 +249,7 @@ function validateEventPayload(body: unknown): { event?: TelemetryEventInput; err * content: * application/json: * schema: - * $ref: '#/components/schemas/SuccessApiResponse' + * $ref: '#/components/schemas/CreateTelemetryEventSuccessResponse' * '400': * description: Invalid payload. * content: diff --git a/src/app/api/v2/telemetry/stats/route.ts b/src/app/api/v2/telemetry/stats/route.ts index 057918f6..fbe1ce88 100644 --- a/src/app/api/v2/telemetry/stats/route.ts +++ b/src/app/api/v2/telemetry/stats/route.ts @@ -111,7 +111,7 @@ function parseStatsQuery(searchParams: URLSearchParams): { query?: TelemetryStat * content: * application/json: * schema: - * $ref: '#/components/schemas/SuccessApiResponse' + * $ref: '#/components/schemas/GetTelemetryStatsSuccessResponse' * '400': * description: Invalid query parameters. * content: diff --git a/src/shared/openApiSpec.ts b/src/shared/openApiSpec.ts index f9c6faa4..079d828b 100644 --- a/src/shared/openApiSpec.ts +++ b/src/shared/openApiSpec.ts @@ -3981,6 +3981,151 @@ export const openApiSpecificationForV2Api: OAS3Options = { ], }, + /** + * @description One time bucket of telemetry activity. + */ + TelemetryBucketCount: { + type: 'object', + properties: { + bucket: { type: 'string', format: 'date-time' }, + count: { type: 'integer' }, + }, + required: ['bucket', 'count'], + }, + + /** + * @description Aggregated stats for one telemetry event name. + */ + TelemetryEventStats: { + type: 'object', + properties: { + event: { type: 'string' }, + count: { type: 'integer' }, + errorCount: { type: 'integer' }, + errorRate: { type: 'number' }, + p50DurationMs: { type: 'number', nullable: true }, + p95DurationMs: { type: 'number', nullable: true }, + }, + required: ['event', 'count', 'errorCount', 'errorRate', 'p50DurationMs', 'p95DurationMs'], + }, + + /** + * @description Aggregated telemetry statistic blocks for one source over a time range. + */ + TelemetryStats: { + type: 'object', + properties: { + usageOverTime: { + type: 'array', + items: { $ref: '#/components/schemas/TelemetryBucketCount' }, + }, + topEvents: { + type: 'array', + items: { $ref: '#/components/schemas/TelemetryEventStats' }, + }, + activeClients: { + type: 'object', + properties: { + total: { type: 'integer' }, + overTime: { + type: 'array', + items: { $ref: '#/components/schemas/TelemetryBucketCount' }, + }, + }, + required: ['total', 'overTime'], + }, + versions: { + type: 'array', + items: { + type: 'object', + properties: { + clientVersion: { type: 'string' }, + count: { type: 'integer' }, + }, + required: ['clientVersion', 'count'], + }, + }, + platforms: { + type: 'array', + items: { + type: 'object', + properties: { + platform: { type: 'string', nullable: true }, + count: { type: 'integer' }, + }, + required: ['platform', 'count'], + }, + }, + }, + required: ['usageOverTime', 'topEvents', 'activeClients', 'versions', 'platforms'], + }, + + /** + * @description The resolved query range echoed back by GET /telemetry/stats. + */ + TelemetryStatsRange: { + type: 'object', + properties: { + source: { type: 'string', enum: ['cli', 'ui'] }, + from: { type: 'string', format: 'date-time' }, + to: { type: 'string', format: 'date-time' }, + interval: { type: 'string', enum: ['day', 'week'] }, + }, + required: ['source', 'from', 'to', 'interval'], + }, + + /** + * @description The specific success response for the GET /telemetry/stats endpoint. + */ + GetTelemetryStatsSuccessResponse: { + allOf: [ + { $ref: '#/components/schemas/SuccessApiResponse' }, + { + type: 'object', + properties: { + data: { + type: 'object', + properties: { + range: { $ref: '#/components/schemas/TelemetryStatsRange' }, + stats: { $ref: '#/components/schemas/TelemetryStats' }, + }, + required: ['range', 'stats'], + }, + }, + required: ['data'], + }, + ], + }, + + /** + * @description The specific success response for the POST /telemetry/events endpoint. + */ + CreateTelemetryEventSuccessResponse: { + allOf: [ + { $ref: '#/components/schemas/SuccessApiResponse' }, + { + type: 'object', + properties: { + data: { + type: 'object', + properties: { + event: { + type: 'object', + properties: { + id: { type: 'integer' }, + createdAt: { type: 'string', format: 'date-time' }, + }, + required: ['id', 'createdAt'], + }, + }, + required: ['event'], + }, + }, + required: ['data'], + }, + ], + }, + /** * @description The specific success response for the GET /builds/{uuid} endpoint. */