From b8439efd188cf300bbfd6480f6b3cfdf7003ca79 Mon Sep 17 00:00:00 2001 From: vmelikyan Date: Sat, 3 Oct 2026 01:08:39 -0700 Subject: [PATCH 1/9] feat(analytics): add admin aggregates and indexed readiness lookup --- .../admin/analytics/agents/options/route.ts | 47 ++ .../v2/admin/analytics/agents/route.test.ts | 153 ++++ .../api/v2/admin/analytics/agents/route.ts | 67 ++ .../v2/admin/analytics/agents/runs/route.ts | 77 ++ .../analytics/environments/records/route.ts | 61 ++ .../analytics/environments/route.test.ts | 134 ++++ .../v2/admin/analytics/environments/route.ts | 58 ++ .../api/v2/admin/analytics/inventory/route.ts | 54 ++ .../api/v2/admin/analytics/options/route.ts | 54 ++ ...6_add_analytics_deployment_lookup_index.ts | 25 + src/server/lib/v2RoutePolicyManifest.ts | 7 + .../analytics/AgentAnalyticsService.test.ts | 351 +++++++++ .../analytics/AgentAnalyticsService.ts | 586 ++++++++++++++ .../EnvironmentAnalyticsService.test.ts | 262 +++++++ .../analytics/EnvironmentAnalyticsService.ts | 726 ++++++++++++++++++ src/server/services/analytics/query.test.ts | 165 ++++ src/server/services/analytics/query.ts | 205 +++++ src/shared/agentAnalyticsOpenApi.ts | 153 ++++ .../environmentAnalyticsOpenApi.test.ts | 74 ++ src/shared/environmentAnalyticsOpenApi.ts | 187 +++++ src/shared/openApiSpec.ts | 4 + 21 files changed, 3450 insertions(+) create mode 100644 src/app/api/v2/admin/analytics/agents/options/route.ts create mode 100644 src/app/api/v2/admin/analytics/agents/route.test.ts create mode 100644 src/app/api/v2/admin/analytics/agents/route.ts create mode 100644 src/app/api/v2/admin/analytics/agents/runs/route.ts create mode 100644 src/app/api/v2/admin/analytics/environments/records/route.ts create mode 100644 src/app/api/v2/admin/analytics/environments/route.test.ts create mode 100644 src/app/api/v2/admin/analytics/environments/route.ts create mode 100644 src/app/api/v2/admin/analytics/inventory/route.ts create mode 100644 src/app/api/v2/admin/analytics/options/route.ts create mode 100644 src/server/db/migrations/036_add_analytics_deployment_lookup_index.ts create mode 100644 src/server/services/analytics/AgentAnalyticsService.test.ts create mode 100644 src/server/services/analytics/AgentAnalyticsService.ts create mode 100644 src/server/services/analytics/EnvironmentAnalyticsService.test.ts create mode 100644 src/server/services/analytics/EnvironmentAnalyticsService.ts create mode 100644 src/server/services/analytics/query.test.ts create mode 100644 src/server/services/analytics/query.ts create mode 100644 src/shared/agentAnalyticsOpenApi.ts create mode 100644 src/shared/environmentAnalyticsOpenApi.test.ts create mode 100644 src/shared/environmentAnalyticsOpenApi.ts diff --git a/src/app/api/v2/admin/analytics/agents/options/route.ts b/src/app/api/v2/admin/analytics/agents/options/route.ts new file mode 100644 index 00000000..e0dfea97 --- /dev/null +++ b/src/app/api/v2/admin/analytics/agents/options/route.ts @@ -0,0 +1,47 @@ +/** + * 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 { successResponse, withNoStore } from 'server/lib/response'; +import AgentAnalyticsService from 'server/services/analytics/AgentAnalyticsService'; + +/** + * @openapi + * /api/v2/admin/analytics/agents/options: + * get: + * summary: List bounded recorded repository, session owner, and model filter options + * operationId: getAgentAnalyticsOptions + * tags: [Admin Analytics] + * responses: + * '200': + * description: Recorded-name options with independent truncation flags. + * content: + * application/json: + * schema: {$ref: '#/components/schemas/GetAgentAnalyticsOptionsSuccessResponse'} + * '401': + * description: Authentication required. + * content: + * application/json: + * schema: {$ref: '#/components/schemas/ApiErrorResponse'} + * '403': + * description: Admin permission required. + * content: + * application/json: + * schema: {$ref: '#/components/schemas/ApiErrorResponse'} + */ +export const GET = createApiHandler( + async (req: NextRequest) => { + return withNoStore(successResponse(await new AgentAnalyticsService().getOptions(), { status: 200 }, req)); + }, + { auth: 'session', roles: ['admin'] } +); diff --git a/src/app/api/v2/admin/analytics/agents/route.test.ts b/src/app/api/v2/admin/analytics/agents/route.test.ts new file mode 100644 index 00000000..796b69ef --- /dev/null +++ b/src/app/api/v2/admin/analytics/agents/route.test.ts @@ -0,0 +1,153 @@ +/** + * 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 mockGetUser = jest.fn(); +const mockIdentity = jest.fn(); +const mockSummary = jest.fn(); +const mockRuns = jest.fn(); +const mockOptions = jest.fn(); + +jest.mock('server/lib/dependencies', () => ({ defaultDb: {} })); +jest.mock('server/lib/get-user', () => ({ + getUser: (...args: unknown[]) => mockGetUser(...args), + getRequestUserIdentity: (...args: unknown[]) => mockIdentity(...args), +})); +jest.mock('server/services/analytics/AgentAnalyticsService', () => ({ + ...jest.requireActual('server/services/analytics/AgentAnalyticsService'), + __esModule: true, + default: jest + .fn() + .mockImplementation(() => ({ getSummary: mockSummary, listRuns: mockRuns, getOptions: mockOptions })), +})); + +import { GET as summary } from './route'; +import { GET as runs } from './runs/route'; +import { GET as options } from './options/route'; + +function request(query = ''): NextRequest { + return { + headers: new Headers([['x-request-id', 'analytics-test']]), + nextUrl: new URL(`http://localhost/api/v2/admin/analytics/agents${query}`), + } as unknown as NextRequest; +} + +describe('Agent analytics admin API boundaries', () => { + const originalAuth = process.env.ENABLE_AUTH; + beforeEach(() => { + jest.clearAllMocks(); + process.env.ENABLE_AUTH = 'true'; + mockGetUser.mockReturnValue({ sub: 'admin', realm_access: { roles: ['admin'] } }); + mockIdentity.mockReturnValue({ userId: 'admin', roles: ['admin'] }); + mockSummary.mockResolvedValue({ totals: { runs: 2 } }); + mockRuns.mockResolvedValue({ runs: [], pagination: { page: 1, limit: 25, total: 0, hasMore: false } }); + mockOptions.mockResolvedValue({ repositories: [], owners: [], models: [] }); + }); + afterEach(() => { + if (originalAuth === undefined) delete process.env.ENABLE_AUTH; + else process.env.ENABLE_AUTH = originalAuth; + }); + + it.each([ + ['summary', summary], + ['runs', runs], + ['options', options], + ] as const)('prevents caching successful admin %s responses', async (_name, handler) => { + const response = await handler(request()); + expect(response.status).toBe(200); + expect(response.headers.get('Cache-Control')).toBe('private, no-store'); + expect(response.headers.get('Pragma')).toBe('no-cache'); + }); + + it.each([ + ['summary', summary], + ['runs', runs], + ['options', options], + ] as const)('rejects unauthenticated %s requests before data access', async (_name, handler) => { + mockIdentity.mockReturnValue(null); + expect((await handler(request())).status).toBe(401); + expect(mockSummary).not.toHaveBeenCalled(); + expect(mockRuns).not.toHaveBeenCalled(); + expect(mockOptions).not.toHaveBeenCalled(); + }); + + it.each([ + ['summary', summary], + ['runs', runs], + ['options', options], + ] as const)('rejects non-admin %s requests before data access', async (_name, handler) => { + mockGetUser.mockReturnValue({ sub: 'user', realm_access: { roles: ['user'] } }); + mockIdentity.mockReturnValue({ userId: 'user', roles: ['user'] }); + expect((await handler(request())).status).toBe(403); + expect(mockSummary).not.toHaveBeenCalled(); + expect(mockRuns).not.toHaveBeenCalled(); + expect(mockOptions).not.toHaveBeenCalled(); + }); + + it.each([ + ['summary', summary], + ['runs', runs], + ['options', options], + ] as const)('rejects API-key bearer credentials for %s', async (_name, handler) => { + const req = request(); + req.headers.set('authorization', 'Bearer lfc_test'); + expect((await handler(req)).status).toBe(403); + expect(mockSummary).not.toHaveBeenCalled(); + expect(mockRuns).not.toHaveBeenCalled(); + expect(mockOptions).not.toHaveBeenCalled(); + }); + + it('passes the calendar and independent Agent scope to summary', async () => { + const response = await summary( + request( + '?from=2026-03-08&to=2026-03-10&timezone=America%2FLos_Angeles&compare=true&repository=Org%2FRepo&owner=owner&model=model' + ) + ); + expect(response.status).toBe(200); + expect(mockSummary).toHaveBeenCalledWith({ + from: '2026-03-08', + to: '2026-03-10', + timezone: 'America/Los_Angeles', + interval: 'day', + compare: true, + repository: 'org/repo', + owner: 'owner', + model: 'model', + }); + expect(await response.json()).toMatchObject({ data: { totals: { runs: 2 } }, error: null }); + }); + + it.each(['?from=2026-02-30&to=2026-03-02', '?repositoryId=5', '?from=2025-01-01&to=2026-01-02'])( + 'rejects invalid summary range or unsupported installation scope %s', + async (query) => { + expect((await summary(request(query))).status).toBe(400); + expect(mockSummary).not.toHaveBeenCalled(); + } + ); + + it('enforces run pagination and outcome filters', async () => { + expect((await runs(request('?runStatus=failed&page=2&limit=100'))).status).toBe(200); + expect(mockRuns).toHaveBeenCalledWith(expect.objectContaining({ runStatus: 'failed' }), 2, 100); + mockRuns.mockClear(); + expect((await runs(request('?limit=101'))).status).toBe(400); + expect(mockRuns).not.toHaveBeenCalled(); + }); + + it('returns filter options independently and propagates its own failure', async () => { + expect((await options(request())).status).toBe(200); + expect(mockOptions).toHaveBeenCalledTimes(1); + mockOptions.mockRejectedValueOnce(new Error('Options unavailable')); + expect((await options(request())).status).toBe(500); + expect((await summary(request())).status).toBe(200); + }); +}); diff --git a/src/app/api/v2/admin/analytics/agents/route.ts b/src/app/api/v2/admin/analytics/agents/route.ts new file mode 100644 index 00000000..39f1d2e4 --- /dev/null +++ b/src/app/api/v2/admin/analytics/agents/route.ts @@ -0,0 +1,67 @@ +/** + * 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 { successResponse, withNoStore } from 'server/lib/response'; +import AgentAnalyticsService, { parseAgentAnalyticsQuery } from 'server/services/analytics/AgentAnalyticsService'; + +/** + * @openapi + * /api/v2/admin/analytics/agents: + * get: + * summary: Summarize retained Agent activity and recorded model usage + * description: Submitted-run cohorts with current outcomes, cumulative usage, independent reported and estimated cost coverage, and first-recorded-run session attribution. Repository names do not carry GitHub installation identity. + * operationId: getAgentAnalytics + * tags: [Admin Analytics] + * parameters: + * - {in: query, name: from, schema: {type: string, format: date}, description: Inclusive local calendar date.} + * - {in: query, name: to, schema: {type: string, format: date}, description: Exclusive local calendar date.} + * - {in: query, name: timezone, schema: {type: string, default: UTC}, description: IANA timezone.} + * - {in: query, name: compare, schema: {type: boolean, default: true}} + * - {in: query, name: interval, schema: {type: string, enum: [day, week], default: day}} + * - {in: query, name: repository, schema: {type: string}, description: Exact recorded owner/repository name or unattributed.} + * - {in: query, name: organization, schema: {type: string}, description: Recorded repository owner grouping.} + * - {in: query, name: owner, schema: {type: string}, description: Exact session owner identity.} + * - {in: query, name: sessionKind, schema: {type: string, enum: [chat, environment, sandbox]}} + * - {in: query, name: agentId, schema: {type: string}} + * - {in: query, name: provider, schema: {type: string}} + * - {in: query, name: model, schema: {type: string}} + * responses: + * '200': + * description: Bounded aggregate data and coverage metadata. + * content: + * application/json: + * schema: {$ref: '#/components/schemas/GetAgentAnalyticsSuccessResponse'} + * '400': + * description: Invalid calendar range or scope. + * content: + * application/json: + * schema: {$ref: '#/components/schemas/ApiErrorResponse'} + * '401': + * description: Authentication required. + * content: + * application/json: + * schema: {$ref: '#/components/schemas/ApiErrorResponse'} + * '403': + * description: Admin permission required. + * content: + * application/json: + * schema: {$ref: '#/components/schemas/ApiErrorResponse'} + */ +export const GET = createApiHandler( + async (req: NextRequest) => { + const result = await new AgentAnalyticsService().getSummary(parseAgentAnalyticsQuery(req.nextUrl.searchParams)); + return withNoStore(successResponse(result, { status: 200 }, req)); + }, + { auth: 'session', roles: ['admin'] } +); diff --git a/src/app/api/v2/admin/analytics/agents/runs/route.ts b/src/app/api/v2/admin/analytics/agents/runs/route.ts new file mode 100644 index 00000000..17fa09cf --- /dev/null +++ b/src/app/api/v2/admin/analytics/agents/runs/route.ts @@ -0,0 +1,77 @@ +/** + * 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 { successResponse, withNoStore } from 'server/lib/response'; +import AgentAnalyticsService, { + parseAgentAnalyticsRunsQuery, + parseAgentAnalyticsPagination, +} from 'server/services/analytics/AgentAnalyticsService'; + +/** + * @openapi + * /api/v2/admin/analytics/agents/runs: + * get: + * summary: Drill into retained submitted Agent runs without conversation content + * operationId: getAgentAnalyticsRuns + * tags: [Admin Analytics] + * parameters: + * - {in: query, name: from, schema: {type: string, format: date}} + * - {in: query, name: to, schema: {type: string, format: date}} + * - {in: query, name: timezone, schema: {type: string, default: UTC}} + * - {in: query, name: compare, schema: {type: boolean, default: true}} + * - {in: query, name: interval, schema: {type: string, enum: [day, week], default: day}} + * - {in: query, name: repository, schema: {type: string}, description: Exact recorded owner/repository name or unattributed.} + * - {in: query, name: organization, schema: {type: string}} + * - {in: query, name: owner, schema: {type: string}} + * - {in: query, name: sessionKind, schema: {type: string, enum: [chat, environment, sandbox]}} + * - {in: query, name: agentId, schema: {type: string}} + * - {in: query, name: provider, schema: {type: string}} + * - {in: query, name: model, schema: {type: string}} + * - {in: query, name: runStatus, schema: {type: string, enum: [queued, starting, running, waiting_for_approval, waiting_for_input, transitioned, completed, failed, cancelled, unknown]}} + * - {in: query, name: page, schema: {type: integer, minimum: 1, maximum: 10000, default: 1}} + * - {in: query, name: limit, schema: {type: integer, minimum: 1, maximum: 100, default: 25}} + * responses: + * '200': + * description: SQL-paginated run metadata and recorded usage. + * content: + * application/json: + * schema: {$ref: '#/components/schemas/GetAgentAnalyticsRunsSuccessResponse'} + * '400': + * description: Invalid range, scope, or pagination. + * content: + * application/json: + * schema: {$ref: '#/components/schemas/ApiErrorResponse'} + * '401': + * description: Authentication required. + * content: + * application/json: + * schema: {$ref: '#/components/schemas/ApiErrorResponse'} + * '403': + * description: Admin permission required. + * content: + * application/json: + * schema: {$ref: '#/components/schemas/ApiErrorResponse'} + */ +export const GET = createApiHandler( + async (req: NextRequest) => { + const { page, limit } = parseAgentAnalyticsPagination(req.nextUrl.searchParams); + const result = await new AgentAnalyticsService().listRuns( + parseAgentAnalyticsRunsQuery(req.nextUrl.searchParams), + page, + limit + ); + return withNoStore(successResponse(result, { status: 200 }, req)); + }, + { auth: 'session', roles: ['admin'] } +); diff --git a/src/app/api/v2/admin/analytics/environments/records/route.ts b/src/app/api/v2/admin/analytics/environments/records/route.ts new file mode 100644 index 00000000..93aa8e35 --- /dev/null +++ b/src/app/api/v2/admin/analytics/environments/records/route.ts @@ -0,0 +1,61 @@ +/** + * 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 { successResponse, withNoStore } from 'server/lib/response'; +import EnvironmentAnalyticsService, { + parseEnvironmentAnalyticsRecordsQuery, +} from 'server/services/analytics/EnvironmentAnalyticsService'; + +export const runtime = 'nodejs'; + +/** + * @openapi + * /api/v2/admin/analytics/environments/records: + * get: + * summary: Get paginated retained or current environment records + * description: Administrator-only installation-wide aggregation over retained records; no historical attempt tracking. + * tags: [Admin Analytics] + * operationId: getEnvironmentAnalyticsRecords + * parameters: + * - { in: query, name: repositoryId, schema: { type: integer, minimum: 1 }, description: Stable installation-aware repository row ID. } + * - { in: query, name: unattributed, schema: { type: boolean, default: false }, description: Mutually exclusive with repositoryId. } + * - { in: query, name: organization, schema: { type: string, maxLength: 128 } } + * - { in: query, name: environmentType, schema: { type: string, enum: [all, ephemeral, static], default: all } } + * - { in: query, name: environmentAuthor, schema: { type: string, maxLength: 128 }, description: Stored GitHub login; distinct from Agent Session owner. } + * - { in: query, name: from, schema: { type: string, format: date }, description: Inclusive local calendar date. Defaults to 30 complete days before to. } + * - { in: query, name: to, schema: { type: string, format: date }, description: Exclusive local calendar date. Defaults to today in timezone. } + * - { in: query, name: timezone, schema: { type: string, default: UTC }, description: IANA timezone for boundaries and buckets. } + * - { in: query, name: compare, schema: { type: boolean, default: true } } + * - { in: query, name: interval, schema: { type: string, enum: [day, week], default: day } } + * - { in: query, name: cohort, schema: { type: string, enum: [first_seen, current], default: first_seen }, description: Current records ignore date controls. } + * - { in: query, name: phase, schema: { type: string, enum: [ready, deployed_not_ready, in_progress, paused, failed, tearing_down] }, description: Available for current cohort only. } + * - { in: query, name: page, schema: { type: integer, minimum: 1, maximum: 1000000, default: 1 } } + * - { in: query, name: limit, schema: { type: integer, minimum: 1, maximum: 100, default: 25 } } + * responses: + * '200': + * description: Analytics result. + * content: + * application/json: + * schema: { $ref: '#/components/schemas/GetEnvironmentAnalyticsRecordsSuccessResponse' } + * '400': { description: Invalid query, content: { application/json: { schema: { $ref: '#/components/schemas/ApiErrorResponse' } } } } + * '401': { description: Authentication required, content: { application/json: { schema: { $ref: '#/components/schemas/ApiErrorResponse' } } } } + * '403': { description: Administrator role required, content: { application/json: { schema: { $ref: '#/components/schemas/ApiErrorResponse' } } } } + */ +const getHandler = async (req: NextRequest) => { + const query = parseEnvironmentAnalyticsRecordsQuery(req.nextUrl.searchParams); + const result = await new EnvironmentAnalyticsService().getRecords(query); + return withNoStore(successResponse(result, { status: 200 }, req)); +}; + +export const GET = createApiHandler(getHandler, { auth: 'session', roles: ['admin'] }); diff --git a/src/app/api/v2/admin/analytics/environments/route.test.ts b/src/app/api/v2/admin/analytics/environments/route.test.ts new file mode 100644 index 00000000..4e08a207 --- /dev/null +++ b/src/app/api/v2/admin/analytics/environments/route.test.ts @@ -0,0 +1,134 @@ +import { NextRequest } from 'next/server'; + +const mockIdentity = jest.fn(), + mockGetUser = jest.fn(); +const mockEnvironments = jest.fn(), + mockInventory = jest.fn(), + mockOptions = jest.fn(), + mockRecords = jest.fn(); +jest.mock('server/lib/dependencies', () => ({ defaultDb: {} })); +jest.mock('server/lib/get-user', () => ({ + getRequestUserIdentity: (...args: unknown[]) => mockIdentity(...args), + getUser: (...args: unknown[]) => mockGetUser(...args), +})); +jest.mock('server/services/analytics/EnvironmentAnalyticsService', () => ({ + ...jest.requireActual('server/services/analytics/EnvironmentAnalyticsService'), + __esModule: true, + default: jest.fn().mockImplementation(() => ({ + getEnvironments: mockEnvironments, + getInventory: mockInventory, + getOptions: mockOptions, + getRecords: mockRecords, + })), +})); + +import { GET as environments } from './route'; +import { GET as inventory } from '../inventory/route'; +import { GET as options } from '../options/route'; +import { GET as records } from './records/route'; + +const handlers = [ + ['environments', environments], + ['inventory', inventory], + ['options', options], + ['records', records], +] as const; +function request(query = ''): NextRequest { + return { + headers: new Headers([['x-request-id', 'analytics-test']]), + nextUrl: new URL(`http://localhost/api/v2/admin/analytics/environments${query}`), + } as unknown as NextRequest; +} +function expectNoAccess() { + for (const mock of [mockEnvironments, mockInventory, mockOptions, mockRecords]) expect(mock).not.toHaveBeenCalled(); +} + +describe('environment analytics administrator boundaries', () => { + const originalAuth = process.env.ENABLE_AUTH; + beforeEach(() => { + jest.clearAllMocks(); + process.env.ENABLE_AUTH = 'true'; + mockIdentity.mockReturnValue({ userId: 'admin', roles: ['admin'] }); + mockGetUser.mockReturnValue({ sub: 'admin', realm_access: { roles: ['admin'] } }); + for (const mock of [mockEnvironments, mockInventory, mockOptions, mockRecords]) mock.mockResolvedValue({}); + }); + afterEach(() => { + if (originalAuth === undefined) delete process.env.ENABLE_AUTH; + else process.env.ENABLE_AUTH = originalAuth; + }); + + it.each(handlers)('rejects unauthenticated %s before aggregation', async (_name, handler) => { + mockIdentity.mockReturnValue(null); + expect((await handler(request())).status).toBe(401); + expectNoAccess(); + }); + it.each(handlers)('rejects ordinary users for %s before aggregation', async (_name, handler) => { + mockIdentity.mockReturnValue({ userId: 'user', roles: ['user'] }); + mockGetUser.mockReturnValue({ sub: 'user', realm_access: { roles: ['user'] } }); + expect((await handler(request())).status).toBe(403); + expectNoAccess(); + }); + it.each(handlers)('rejects API-key bearer credentials for %s', async (_name, handler) => { + const req = request(); + req.headers.set('authorization', 'Bearer lfc_pat_fake'); + expect((await handler(req)).status).toBe(403); + expectNoAccess(); + }); + it('passes calendar and installation scope and disables response caching', async () => { + const response = await environments( + request( + '?from=2026-03-08&to=2026-03-10&timezone=America%2FLos_Angeles&repositoryId=4&environmentAuthor=Alice&rankBy=pr_coverage' + ) + ); + expect(response.status).toBe(200); + expect(mockEnvironments).toHaveBeenCalledWith( + expect.objectContaining({ + from: '2026-03-08', + to: '2026-03-10', + timezone: 'America/Los_Angeles', + repositoryId: 4, + environmentAuthor: 'Alice', + rankBy: 'pr_coverage', + compare: true, + }) + ); + expect(response.headers.get('Cache-Control')).toBe('private, no-store'); + }); + it('current inventory ignores dates while retaining scope and records filter by actual phases', async () => { + expect((await inventory(request('?from=invalid&environmentType=static'))).status).toBe(200); + expect(mockInventory).toHaveBeenCalledWith({ + repositoryId: null, + organization: null, + environmentType: 'static', + environmentAuthor: null, + unattributed: false, + }); + expect( + (await records(request('?cohort=current&phase=deployed_not_ready&unattributed=true&page=2&limit=100'))).status + ).toBe(200); + expect(mockRecords).toHaveBeenCalledWith( + expect.objectContaining({ + cohort: 'current', + phase: 'deployed_not_ready', + unattributed: true, + page: 2, + limit: 100, + }) + ); + }); + it.each([ + '?from=2026-02-30&to=2026-03-02', + '?from=2025-01-01&to=2026-01-02', + '?repositoryId=4&unattributed=true', + '?environmentType=workspace', + ])('rejects invalid period query %s', async (query) => { + expect((await environments(request(query))).status).toBe(400); + expectNoAccess(); + }); + it('keeps endpoint failures isolated', async () => { + mockInventory.mockRejectedValueOnce(new Error('Inventory unavailable')); + expect((await inventory(request())).status).toBe(500); + expect((await environments(request())).status).toBe(200); + expect((await options(request())).status).toBe(200); + }); +}); diff --git a/src/app/api/v2/admin/analytics/environments/route.ts b/src/app/api/v2/admin/analytics/environments/route.ts new file mode 100644 index 00000000..0f6572b6 --- /dev/null +++ b/src/app/api/v2/admin/analytics/environments/route.ts @@ -0,0 +1,58 @@ +/** + * 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 { successResponse, withNoStore } from 'server/lib/response'; +import EnvironmentAnalyticsService, { + parseEnvironmentAnalyticsQuery, +} from 'server/services/analytics/EnvironmentAnalyticsService'; + +export const runtime = 'nodejs'; + +/** + * @openapi + * /api/v2/admin/analytics/environments: + * get: + * summary: Get retained first-seen environment and observed PR analytics + * description: Administrator-only installation-wide aggregation over retained records; no historical attempt tracking. + * tags: [Admin Analytics] + * operationId: getEnvironmentAnalytics + * parameters: + * - { in: query, name: repositoryId, schema: { type: integer, minimum: 1 }, description: Stable installation-aware repository row ID. } + * - { in: query, name: unattributed, schema: { type: boolean, default: false }, description: Mutually exclusive with repositoryId. } + * - { in: query, name: organization, schema: { type: string, maxLength: 128 } } + * - { in: query, name: environmentType, schema: { type: string, enum: [all, ephemeral, static], default: all } } + * - { in: query, name: environmentAuthor, schema: { type: string, maxLength: 128 }, description: Stored GitHub login; distinct from Agent Session owner. } + * - { in: query, name: from, schema: { type: string, format: date }, description: Inclusive local calendar date. Defaults to 30 complete days before to. } + * - { in: query, name: to, schema: { type: string, format: date }, description: Exclusive local calendar date. Defaults to today in timezone. } + * - { in: query, name: timezone, schema: { type: string, default: UTC }, description: IANA timezone for boundaries and buckets. } + * - { in: query, name: compare, schema: { type: boolean, default: true } } + * - { in: query, name: interval, schema: { type: string, enum: [day, week], default: day } } + * - { in: query, name: rankBy, schema: { type: string, enum: [first_seen, observed_prs, pr_coverage], default: first_seen } } + * responses: + * '200': + * description: Analytics result. + * content: + * application/json: + * schema: { $ref: '#/components/schemas/GetEnvironmentAnalyticsSuccessResponse' } + * '400': { description: Invalid query, content: { application/json: { schema: { $ref: '#/components/schemas/ApiErrorResponse' } } } } + * '401': { description: Authentication required, content: { application/json: { schema: { $ref: '#/components/schemas/ApiErrorResponse' } } } } + * '403': { description: Administrator role required, content: { application/json: { schema: { $ref: '#/components/schemas/ApiErrorResponse' } } } } + */ +const getHandler = async (req: NextRequest) => { + const query = parseEnvironmentAnalyticsQuery(req.nextUrl.searchParams); + const result = await new EnvironmentAnalyticsService().getEnvironments(query); + return withNoStore(successResponse(result, { status: 200 }, req)); +}; + +export const GET = createApiHandler(getHandler, { auth: 'session', roles: ['admin'] }); diff --git a/src/app/api/v2/admin/analytics/inventory/route.ts b/src/app/api/v2/admin/analytics/inventory/route.ts new file mode 100644 index 00000000..d437a3ae --- /dev/null +++ b/src/app/api/v2/admin/analytics/inventory/route.ts @@ -0,0 +1,54 @@ +/** + * 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 { successResponse, withNoStore } from 'server/lib/response'; +import EnvironmentAnalyticsService, { + parseEnvironmentAnalyticsScope, +} from 'server/services/analytics/EnvironmentAnalyticsService'; + +export const runtime = 'nodejs'; + +/** + * @openapi + * /api/v2/admin/analytics/inventory: + * get: + * summary: Get current service-aware environment inventory + * description: Administrator-only installation-wide aggregation over retained records; no historical attempt tracking. + * tags: [Admin Analytics] + * operationId: getInventoryAnalytics + * parameters: + * - { in: query, name: repositoryId, schema: { type: integer, minimum: 1 }, description: Stable installation-aware repository row ID. } + * - { in: query, name: unattributed, schema: { type: boolean, default: false }, description: Mutually exclusive with repositoryId. } + * - { in: query, name: organization, schema: { type: string, maxLength: 128 } } + * - { in: query, name: environmentType, schema: { type: string, enum: [all, ephemeral, static], default: all } } + * - { in: query, name: environmentAuthor, schema: { type: string, maxLength: 128 }, description: Stored GitHub login; distinct from Agent Session owner. } + + + * responses: + * '200': + * description: Analytics result. + * content: + * application/json: + * schema: { $ref: '#/components/schemas/GetInventoryAnalyticsSuccessResponse' } + * '400': { description: Invalid query, content: { application/json: { schema: { $ref: '#/components/schemas/ApiErrorResponse' } } } } + * '401': { description: Authentication required, content: { application/json: { schema: { $ref: '#/components/schemas/ApiErrorResponse' } } } } + * '403': { description: Administrator role required, content: { application/json: { schema: { $ref: '#/components/schemas/ApiErrorResponse' } } } } + */ +const getHandler = async (req: NextRequest) => { + const query = parseEnvironmentAnalyticsScope(req.nextUrl.searchParams); + const result = await new EnvironmentAnalyticsService().getInventory(query); + return withNoStore(successResponse(result, { status: 200 }, req)); +}; + +export const GET = createApiHandler(getHandler, { auth: 'session', roles: ['admin'] }); diff --git a/src/app/api/v2/admin/analytics/options/route.ts b/src/app/api/v2/admin/analytics/options/route.ts new file mode 100644 index 00000000..723d9308 --- /dev/null +++ b/src/app/api/v2/admin/analytics/options/route.ts @@ -0,0 +1,54 @@ +/** + * 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 { successResponse, withNoStore } from 'server/lib/response'; +import EnvironmentAnalyticsService, { + parseEnvironmentAnalyticsScope, +} from 'server/services/analytics/EnvironmentAnalyticsService'; + +export const runtime = 'nodejs'; + +/** + * @openapi + * /api/v2/admin/analytics/options: + * get: + * summary: Get bounded administrator analytics filter options + * description: Administrator-only installation-wide aggregation over retained records; no historical attempt tracking. + * tags: [Admin Analytics] + * operationId: getAnalyticsOptions + * parameters: + * - { in: query, name: repositoryId, schema: { type: integer, minimum: 1 }, description: Stable installation-aware repository row ID. } + * - { in: query, name: unattributed, schema: { type: boolean, default: false }, description: Mutually exclusive with repositoryId. } + * - { in: query, name: organization, schema: { type: string, maxLength: 128 } } + * - { in: query, name: environmentType, schema: { type: string, enum: [all, ephemeral, static], default: all } } + * - { in: query, name: environmentAuthor, schema: { type: string, maxLength: 128 }, description: Stored GitHub login; distinct from Agent Session owner. } + + + * responses: + * '200': + * description: Analytics result. + * content: + * application/json: + * schema: { $ref: '#/components/schemas/GetAnalyticsOptionsSuccessResponse' } + * '400': { description: Invalid query, content: { application/json: { schema: { $ref: '#/components/schemas/ApiErrorResponse' } } } } + * '401': { description: Authentication required, content: { application/json: { schema: { $ref: '#/components/schemas/ApiErrorResponse' } } } } + * '403': { description: Administrator role required, content: { application/json: { schema: { $ref: '#/components/schemas/ApiErrorResponse' } } } } + */ +const getHandler = async (req: NextRequest) => { + const query = parseEnvironmentAnalyticsScope(req.nextUrl.searchParams); + const result = await new EnvironmentAnalyticsService().getOptions(query); + return withNoStore(successResponse(result, { status: 200 }, req)); +}; + +export const GET = createApiHandler(getHandler, { auth: 'session', roles: ['admin'] }); diff --git a/src/server/db/migrations/036_add_analytics_deployment_lookup_index.ts b/src/server/db/migrations/036_add_analytics_deployment_lookup_index.ts new file mode 100644 index 00000000..2b58ab5b --- /dev/null +++ b/src/server/db/migrations/036_add_analytics_deployment_lookup_index.ts @@ -0,0 +1,25 @@ +/** + * 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 type { Knex } from 'knex'; + +// PostgreSQL concurrent index operations cannot run inside Knex's migration transaction. +export const config = { transaction: false }; + +export async function up(knex: Knex): Promise { + // Current readiness fetches active deployments by build ID. Keep historical rows out of this lookup index. + await knex.raw('CREATE INDEX CONCURRENTLY deploys_active_build_id_idx ON deploys ("buildId") WHERE active = true'); +} + +export async function down(knex: Knex): Promise { + await knex.raw('DROP INDEX CONCURRENTLY deploys_active_build_id_idx'); +} diff --git a/src/server/lib/v2RoutePolicyManifest.ts b/src/server/lib/v2RoutePolicyManifest.ts index 0478c036..6694eaa5 100644 --- a/src/server/lib/v2RoutePolicyManifest.ts +++ b/src/server/lib/v2RoutePolicyManifest.ts @@ -33,6 +33,13 @@ export type V2RoutePolicyEntry = }; export const V2_ROUTE_POLICY_MANIFEST: readonly V2RoutePolicyEntry[] = [ + { method: 'GET', route: '/api/v2/admin/analytics/agents', policy: 'session', roles: ['admin'] }, + { method: 'GET', route: '/api/v2/admin/analytics/agents/options', policy: 'session', roles: ['admin'] }, + { method: 'GET', route: '/api/v2/admin/analytics/agents/runs', policy: 'session', roles: ['admin'] }, + { method: 'GET', route: '/api/v2/admin/analytics/environments', policy: 'session', roles: ['admin'] }, + { method: 'GET', route: '/api/v2/admin/analytics/environments/records', policy: 'session', roles: ['admin'] }, + { method: 'GET', route: '/api/v2/admin/analytics/inventory', policy: 'session', roles: ['admin'] }, + { method: 'GET', route: '/api/v2/admin/analytics/options', policy: 'session', roles: ['admin'] }, { method: 'GET', route: '/api/v2/ai/admin/agent/capabilities', policy: 'session', roles: ['admin'] }, { method: 'PUT', route: '/api/v2/ai/admin/agent/capabilities', policy: 'session', roles: ['admin'] }, { method: 'GET', route: '/api/v2/ai/admin/agent/creation-policy', policy: 'session', roles: ['admin'] }, diff --git a/src/server/services/analytics/AgentAnalyticsService.test.ts b/src/server/services/analytics/AgentAnalyticsService.test.ts new file mode 100644 index 00000000..13ac649e --- /dev/null +++ b/src/server/services/analytics/AgentAnalyticsService.test.ts @@ -0,0 +1,351 @@ +/** + * 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. + */ + +jest.mock('server/lib/dependencies', () => ({ defaultDb: {} })); + +const mockResolveRange = jest.fn(); +jest.mock('./query', () => ({ + ...jest.requireActual('./query'), + resolveAnalyticsRange: (...args: unknown[]) => mockResolveRange(...args), +})); + +import AgentAnalyticsService, { + parseAgentAnalyticsQuery, + parseAgentAnalyticsRunsQuery, + parseAgentAnalyticsPagination, + serializeAgentAnalyticsMetrics, +} from './AgentAnalyticsService'; +import type { ResolvedAnalyticsRange } from './query'; + +const range: ResolvedAnalyticsRange = { + from: '2026-03-08', + to: '2026-03-10', + timezone: 'America/Los_Angeles', + fromUtc: '2026-03-08T08:00:00.000Z', + toUtc: '2026-03-10T07:00:00.000Z', + dates: ['2026-03-08', '2026-03-09'], + calendarDays: 2, + interval: 'day', + compare: true, + asOf: '2026-03-10T08:00:00.000Z', + previous: { + from: '2026-03-06', + to: '2026-03-08', + fromUtc: '2026-03-06T08:00:00.000Z', + toUtc: '2026-03-08T08:00:00.000Z', + dates: ['2026-03-06', '2026-03-07'], + }, +}; + +function serviceWithRows(results: unknown[][]) { + const raw = jest.fn(async (sql: string) => + sql.startsWith('SET TRANSACTION') ? undefined : { rows: results.shift() } + ); + const transaction = jest.fn(async (callback) => callback({ raw })); + const service = new AgentAnalyticsService({ knex: { transaction } } as any); + return { service, raw }; +} + +describe('AgentAnalyticsService', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockResolveRange.mockResolvedValue(range); + }); + + it('distinguishes unknown usage from an empty cohort and recorded zero', () => { + expect(serializeAgentAnalyticsMetrics()).toMatchObject({ + runs: 0, + tokens: { total: 0, input: 0, output: 0, missingRuns: 0 }, + reportedCost: { usd: 0 }, + }); + expect( + serializeAgentAnalyticsMetrics({ runs: '2', running: '1', failed: '1', pending_usage_runs: '1' }) + ).toMatchObject({ + runs: 2, + tokens: { total: null, input: null, output: null, reportedRuns: 0, missingRuns: 2, pendingRuns: 1 }, + reportedCost: { usd: null, coveredRuns: 0 }, + estimatedCost: { usd: null, coveredRuns: 0 }, + }); + expect( + serializeAgentAnalyticsMetrics({ + runs: 1, + total_tokens: '0', + total_reported_runs: 1, + reported_cost: 0, + reported_cost_runs: 1, + }) + ).toMatchObject({ + tokens: { total: 0, missingRuns: 0 }, + reportedCost: { usd: 0, coveredRuns: 1 }, + estimatedCost: { usd: null, coveredRuns: 0 }, + }); + }); + + it('preserves independent token directions and separate overlapping cost coverage', () => { + expect( + serializeAgentAnalyticsMetrics({ + runs: 3, + sessions: 2, + owners: 1, + total_tokens: 100, + total_reported_runs: 1, + input_tokens: 75, + input_reported_runs: 2, + output_tokens: 25, + output_reported_runs: 1, + reported_cost: '0.015', + reported_cost_runs: 1, + estimated_cost: '0.02', + estimated_cost_runs: 2, + unknown: 1, + }) + ).toMatchObject({ + runs: 3, + sessions: 2, + owners: 1, + outcomes: { unknown: 1 }, + tokens: { + total: 100, + input: 75, + output: 25, + reportedRuns: 1, + inputReportedRuns: 2, + outputReportedRuns: 1, + missingRuns: 2, + }, + reportedCost: { usd: 0.015, coveredRuns: 1 }, + estimatedCost: { usd: 0.02, coveredRuns: 2 }, + }); + }); + + it('fills absent calendar buckets and aligns previous dates using the shared DST range', async () => { + const { service, raw } = serviceWithRows([ + [ + { + period: 'current', + group_type: 'total', + runs: 2, + sessions: 1, + owners: 1, + total_tokens: 12, + total_reported_runs: 1, + }, + { + period: 'current', + group_type: 'date', + bucket: '2026-03-09', + runs: 2, + total_tokens: 12, + total_reported_runs: 1, + }, + { period: 'previous', group_type: 'date', bucket: '2026-03-08', runs: 1 }, + { period: 'current', group_type: 'repository', repository: 'org/repo', runs: 2, ranking_total: 23 }, + ], + [ + { period: 'current', count: 1, unattributed: 0 }, + { period: 'previous', count: 2, unattributed: 1 }, + ], + [{ earliest: new Date('2025-04-01T00:00:00Z') }], + ]); + const result = await service.getSummary({ repository: 'org/repo', compare: true }); + expect(result.series).toEqual([ + expect.objectContaining({ + date: '2026-03-08', + previousDate: '2026-03-06', + current: expect.objectContaining({ runs: 0, tokens: expect.objectContaining({ total: 0 }) }), + previous: expect.objectContaining({ runs: 1, tokens: expect.objectContaining({ total: null }) }), + }), + expect.objectContaining({ + date: '2026-03-09', + previousDate: '2026-03-07', + current: expect.objectContaining({ runs: 2 }), + previous: expect.objectContaining({ runs: 0 }), + }), + ]); + expect(result.sessionsCreated).toEqual({ + current: 1, + previous: 2, + unattributedCurrent: 0, + unattributedPrevious: 1, + attribution: 'first_recorded_run', + }); + expect(result.rankingTotal.repositories).toBe(23); + expect(result.rankingTruncated.repositories).toBe(true); + expect(result.earliestRunAt).toBe('2025-04-01T00:00:00.000Z'); + const [sql, bindings] = raw.mock.calls[1] as unknown as [string, unknown[]]; + expect(sql).toContain('COUNT(DISTINCT session_id)'); + expect(sql).toContain("jsonb_typeof(r.\"usageSummary\"->'totalTokens') = 'number'"); + expect(sql).toContain("'{source,repoFullName}'"); + expect(sql).toContain("'{source,workspaceLayout,primaryRepo}'"); + expect(sql).not.toContain('jsonb_array_elements'); + expect(sql).not.toContain('workspaceRepos'); + expect(sql).toContain('r."createdAt" >= ?::timestamptz AND r."createdAt" < ?::timestamptz'); + expect(sql).not.toContain('r."queuedAt" >='); + expect(bindings).toEqual([ + range.fromUtc, + 'day', + range.timezone, + range.fromUtc, + 2, + range.previous!.fromUtc, + range.toUtc, + 'org/repo', + 20, + ]); + expect(raw.mock.calls[2][0]).toContain('ORDER BY r."createdAt", r.id LIMIT 1'); + expect(raw.mock.calls[3][0]).toContain('min("createdAt")'); + }); + + it('omits previous results when comparison is disabled and preserves empty runs beyond the last page', async () => { + mockResolveRange.mockResolvedValue({ ...range, compare: false, previous: null }); + const { service, raw } = serviceWithRows([[{ total: 1, runs: [] }]]); + const result = await service.listRuns({ repository: 'unattributed', runStatus: 'failed' }, 2, 25); + expect(result.runs).toEqual([]); + expect(result.pagination).toEqual({ page: 2, limit: 25, total: 1, hasMore: false }); + const [sql, bindings] = raw.mock.calls[1] as unknown as [string, unknown[]]; + expect(sql).toContain('repository IS NULL AND status = ?'); + expect(sql).toContain('ORDER BY submitted_at DESC, id DESC LIMIT ? OFFSET ?'); + expect(bindings).toEqual([range.fromUtc, range.toUtc, 'failed', 25, 25]); + }); + + it('returns only run metadata while preserving unavailable usage and cost', async () => { + const { service } = serviceWithRows([ + [ + { + total: 1, + runs: [ + { + run_uuid: 'run', + session_uuid: 'session', + thread_uuid: 'thread', + submitted_at: range.fromUtc, + queued_at: range.fromUtc, + status: 'failed', + provider: 'openai', + model: 'model', + owner_id: 'owner', + agent_label: 'Assistant', + error_code: 'provider_quota_exhausted', + total_tokens: null, + input_tokens: 10, + output_tokens: null, + reported_cost: null, + estimated_cost: '0.01', + secret: 'excluded', + prompt: 'excluded', + }, + ], + }, + ], + ]); + const result = await service.listRuns({}); + expect(result.runs[0]).toMatchObject({ + tokens: { total: null, input: 10, output: null }, + reportedCostUsd: null, + estimatedCostUsd: 0.01, + errorCode: 'provider_quota_exhausted', + }); + expect(result.runs[0]).not.toHaveProperty('secret'); + expect(result.runs[0]).not.toHaveProperty('prompt'); + }); + + it('preserves submission time while returning the latest approval requeue time', async () => { + const requeuedAt = '2026-03-11T08:00:00.000Z'; + const { service, raw } = serviceWithRows([ + [ + { + total: 1, + runs: [ + { + run_uuid: 'resumed-run', + session_uuid: 'session', + thread_uuid: 'thread', + submitted_at: range.fromUtc, + queued_at: requeuedAt, + status: 'completed', + provider: 'openai', + model: 'model', + owner_id: 'owner', + total_tokens: 300, + }, + ], + }, + ], + ]); + const result = await service.listRuns({}, 1, 25); + expect(result.runs[0]).toMatchObject({ submittedAt: range.fromUtc, queuedAt: requeuedAt, tokens: { total: 300 } }); + const sql = raw.mock.calls[1][0]; + expect(sql).toContain('r."createdAt" AS submitted_at'); + expect(sql).toContain('r."queuedAt" AS queued_at'); + expect(sql).toContain('r."createdAt" >= ?::timestamptz AND r."createdAt" < ?::timestamptz'); + expect(sql).toContain('ORDER BY submitted_at DESC, id DESC'); + expect(sql).not.toContain('r."queuedAt" >='); + expect(sql).not.toContain('ORDER BY queued_at'); + }); + + it('returns bounded independent options and truncation counts', async () => { + const raw = jest.fn().mockResolvedValue({ + rows: [ + { + as_of: new Date(range.asOf), + repositories_total: 201, + models_total: 2, + owners_total: 1, + has_unattributed: true, + repositories: ['org/repo'], + models: [{ provider: 'openai', model: 'model' }], + owners: [{ id: 'owner', githubUsername: null }], + }, + ], + }); + const result = await new AgentAnalyticsService({ knex: { raw } } as any).getOptions(); + expect(result.truncated).toEqual({ repositories: true, owners: false, models: false }); + expect(result.owners).toEqual([{ id: 'owner', githubUsername: null }]); + expect(result.hasUnattributed).toBe(true); + expect(raw.mock.calls[0][1]).toEqual([200, 200, 200]); + }); +}); + +describe('Agent analytics input boundaries', () => { + it('uses recorded names and keeps owner identity separate', () => { + expect( + parseAgentAnalyticsQuery( + new URLSearchParams('repository=Org%2FRepo&organization=Org&owner=keycloak-uuid&sessionKind=chat&compare=true') + ) + ).toMatchObject({ + repository: 'org/repo', + organization: 'org', + owner: 'keycloak-uuid', + sessionKind: 'chat', + compare: true, + }); + }); + it.each([ + 'repositoryId=123', + 'repository=bad', + 'organization=org%2Frepo', + 'sessionKind=other', + 'compare=1', + 'from=2026-02-30&to=2026-03-04', + 'from=2025-01-01&to=2026-01-02', + ])('rejects unsupported or invalid scope %s', (input) => { + expect(() => parseAgentAnalyticsQuery(new URLSearchParams(input))).toThrow(); + }); + it.each(['page=0', 'page=2.5', 'page=10001', 'limit=101', 'limit=-1'])('rejects invalid pagination %s', (input) => { + expect(() => parseAgentAnalyticsPagination(new URLSearchParams(input))).toThrow(); + }); + it('validates run outcomes and default bounded pagination', () => { + expect(parseAgentAnalyticsPagination(new URLSearchParams())).toEqual({ page: 1, limit: 25 }); + expect(() => parseAgentAnalyticsRunsQuery(new URLSearchParams('runStatus=success'))).toThrow(); + expect(parseAgentAnalyticsRunsQuery(new URLSearchParams('runStatus=unknown')).runStatus).toBe('unknown'); + }); +}); diff --git a/src/server/services/analytics/AgentAnalyticsService.ts b/src/server/services/analytics/AgentAnalyticsService.ts new file mode 100644 index 00000000..221f6291 --- /dev/null +++ b/src/server/services/analytics/AgentAnalyticsService.ts @@ -0,0 +1,586 @@ +/** + * 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 type { Knex } from 'knex'; +import type Database from 'server/database'; +import { defaultDb } from 'server/lib/dependencies'; +import { BadRequestError } from 'server/lib/appError'; +import { + parseAnalyticsCalendarQuery, + resolveAnalyticsRange, + type AnalyticsCalendarQuery, + type ResolvedAnalyticsRange, +} from './query'; + +export const AGENT_ANALYTICS_STATUSES = [ + 'queued', + 'starting', + 'running', + 'waiting_for_approval', + 'waiting_for_input', + 'transitioned', + 'completed', + 'failed', + 'cancelled', + 'unknown', +] as const; + +export type AgentAnalyticsStatus = (typeof AGENT_ANALYTICS_STATUSES)[number]; + +export interface AgentAnalyticsQuery extends AnalyticsCalendarQuery { + repository?: string; + organization?: string; + owner?: string; + sessionKind?: 'environment' | 'sandbox' | 'chat'; + agentId?: string; + provider?: string; + model?: string; +} + +export interface AgentAnalyticsRunsQuery extends AgentAnalyticsQuery { + runStatus?: AgentAnalyticsStatus; +} + +export interface AgentAnalyticsMetrics { + runs: number; + sessions: number; + owners: number; + outcomes: Record; + tokens: { + total: number | null; + input: number | null; + output: number | null; + reportedRuns: number; + inputReportedRuns: number; + outputReportedRuns: number; + missingRuns: number; + pendingRuns: number; + }; + reportedCost: { usd: number | null; coveredRuns: number }; + estimatedCost: { usd: number | null; coveredRuns: number }; + unattributedRuns: number; +} + +export interface AgentAnalyticsSummary { + range: ResolvedAnalyticsRange; + asOf: string; + attribution: { repository: 'run_plan_primary'; model: 'current_resolved'; repositoryScope: 'recorded_name' }; + caveats: string[]; + totals: AgentAnalyticsMetrics; + previous: AgentAnalyticsMetrics | null; + series: Array<{ + date: string; + previousDate: string | null; + current: AgentAnalyticsMetrics; + previous: AgentAnalyticsMetrics | null; + }>; + repositories: Array; + models: Array; + definitions: Array; + sessionsCreated: { + current: number; + previous: number | null; + unattributedCurrent: number; + unattributedPrevious: number | null; + attribution: 'first_recorded_run'; + }; + earliestRunAt: string | null; + rankingLimit: number; + rankingTotal: { repositories: number; models: number; definitions: number }; + rankingTruncated: { repositories: boolean; models: boolean; definitions: boolean }; +} + +export interface AgentAnalyticsRun { + runId: string; + sessionId: string; + threadId: string; + submittedAt: string; + queuedAt: string; + status: AgentAnalyticsStatus; + repository: string | null; + provider: string; + model: string; + agentId: string | null; + agentLabel: string | null; + ownerId: string; + ownerGithubUsername: string | null; + tokens: { total: number | null; input: number | null; output: number | null }; + reportedCostUsd: number | null; + estimatedCostUsd: number | null; + errorCode: string | null; +} + +export interface AgentAnalyticsRuns { + range: ResolvedAnalyticsRange; + asOf: string; + caveats: string[]; + runs: AgentAnalyticsRun[]; + pagination: { page: number; limit: number; total: number; hasMore: boolean }; +} + +export interface AgentAnalyticsOptions { + asOf: string; + repositoryScope: 'recorded_name'; + repositories: string[]; + hasUnattributed: boolean; + owners: Array<{ id: string; githubUsername: string | null }>; + models: Array<{ provider: string; model: string }>; + limit: number; + totals: { repositories: number; owners: number; models: number }; + truncated: { repositories: boolean; owners: boolean; models: boolean }; +} + +const RANKING_LIMIT = 20; +const CAVEATS = [ + 'Submission cohorts use immutable run creation time; latest queue time can change when an approval resumes a run.', + 'Usage is cumulative recorded usage for runs submitted in the window; resumed execution may occur outside it.', + 'Outcomes and resolved provider/model reflect the retained run as of this snapshot; completion is an execution outcome.', + 'Repository scope uses one recorded primary repository name, without GitHub installation identity; legacy or missing snapshots remain unattributed.', + 'Reported and estimated model costs have independent coverage and can overlap; they are not additive or total infrastructure cost.', + 'Created sessions use their creation window and the first retained run for scope attribution; sessions without a recorded run remain unattributed.', + 'Definition rankings group by recorded Agent ID; displayed labels are representative retained labels.', + 'History is limited to retained records; the earliest retained run is not proof of collection completeness.', +]; + +function filterValue(params: URLSearchParams, name: string, limit = 255): string | undefined { + const value = params.get(name)?.trim(); + if (!value) return undefined; + if (value.length > limit) throw new BadRequestError(`${name} must be at most ${limit} characters.`); + return value; +} + +export function parseAgentAnalyticsQuery(params: URLSearchParams): AgentAnalyticsQuery { + if (params.get('repositoryId')) + throw new BadRequestError('Agent analytics uses recorded repository names; repositoryId is not supported.'); + const repository = filterValue(params, 'repository')?.toLowerCase(); + if (repository && repository !== 'unattributed' && !/^[a-z0-9_.-]+\/[a-z0-9_.-]+$/.test(repository)) { + throw new BadRequestError('repository must be an owner/repository name or unattributed.'); + } + const organization = filterValue(params, 'organization', 100)?.toLowerCase(); + if (organization && !/^[a-z0-9_.-]+$/.test(organization)) + throw new BadRequestError('organization must be a repository owner name.'); + const sessionKind = filterValue(params, 'sessionKind'); + if (sessionKind && !['environment', 'sandbox', 'chat'].includes(sessionKind)) + throw new BadRequestError('sessionKind must be environment, sandbox, or chat.'); + return { + ...parseAnalyticsCalendarQuery(params), + ...(repository ? { repository } : {}), + ...(organization ? { organization } : {}), + ...(sessionKind ? { sessionKind: sessionKind as AgentAnalyticsQuery['sessionKind'] } : {}), + ...Object.fromEntries( + ['owner', 'agentId', 'provider', 'model'].flatMap((key) => { + const value = filterValue(params, key); + return value ? [[key, value]] : []; + }) + ), + }; +} + +export function parseAgentAnalyticsRunsQuery(params: URLSearchParams): AgentAnalyticsRunsQuery { + const query = parseAgentAnalyticsQuery(params); + const runStatus = filterValue(params, 'runStatus'); + if (runStatus && !AGENT_ANALYTICS_STATUSES.includes(runStatus as AgentAnalyticsStatus)) + throw new BadRequestError('runStatus must be a supported run outcome.'); + return { ...query, ...(runStatus ? { runStatus: runStatus as AgentAnalyticsStatus } : {}) }; +} + +export function parseAgentAnalyticsPagination(params: URLSearchParams): { page: number; limit: number } { + const parse = (key: string, fallback: number, maximum: number) => { + const value = params.get(key); + if (value == null) return fallback; + if (!/^\d+$/.test(value) || Number(value) < 1 || Number(value) > maximum) + throw new BadRequestError(`${key} must be an integer between 1 and ${maximum}.`); + return Number(value); + }; + return { page: parse('page', 1, 10000), limit: parse('limit', 25, 100) }; +} + +type SqlRow = Record; + +function number(value: unknown): number { + const result = Number(value ?? 0); + if (!Number.isFinite(result)) throw new Error('Agent analytics numeric result exceeds the supported range.'); + return result; +} + +function nullableNumber(value: unknown): number | null { + return value == null ? null : number(value); +} + +function timestamp(value: unknown): string | null { + if (value == null) return null; + return value instanceof Date ? value.toISOString() : String(value); +} + +export function serializeAgentAnalyticsMetrics(row?: SqlRow): AgentAnalyticsMetrics { + const runs = number(row?.runs); + const reportedRuns = number(row?.total_reported_runs); + return { + runs, + sessions: number(row?.sessions), + owners: number(row?.owners), + outcomes: Object.fromEntries(AGENT_ANALYTICS_STATUSES.map((status) => [status, number(row?.[status])])) as Record< + AgentAnalyticsStatus, + number + >, + tokens: { + total: runs === 0 ? 0 : nullableNumber(row?.total_tokens), + input: runs === 0 ? 0 : nullableNumber(row?.input_tokens), + output: runs === 0 ? 0 : nullableNumber(row?.output_tokens), + reportedRuns, + inputReportedRuns: number(row?.input_reported_runs), + outputReportedRuns: number(row?.output_reported_runs), + missingRuns: runs - reportedRuns, + pendingRuns: number(row?.pending_usage_runs), + }, + reportedCost: { + usd: runs === 0 ? 0 : nullableNumber(row?.reported_cost), + coveredRuns: number(row?.reported_cost_runs), + }, + estimatedCost: { + usd: runs === 0 ? 0 : nullableNumber(row?.estimated_cost), + coveredRuns: number(row?.estimated_cost_runs), + }, + unattributedRuns: number(row?.unattributed_runs), + }; +} + +function jsonString(path: string): string { + return `CASE WHEN jsonb_typeof(${path}) = 'string' THEN NULLIF(BTRIM(${path} #>> '{}'), '') END`; +} + +function jsonNumber(field: string): string { + return `CASE WHEN jsonb_typeof(r."usageSummary"->'${field}') = 'number' THEN (r."usageSummary"->>'${field}')::numeric END`; +} + +const RUN_DIMENSIONS = ` + r.id, r.uuid AS run_uuid, r."sessionId" AS session_id, r."threadId" AS thread_id, + r."createdAt" AS submitted_at, + r."queuedAt" AS queued_at, + CASE WHEN r.status IN (${AGENT_ANALYTICS_STATUSES.filter((status) => status !== 'unknown') + .map((status) => `'${status}'`) + .join(',')}) THEN r.status ELSE 'unknown' END AS status, + LOWER(COALESCE(${jsonString(`r."runPlanSnapshot" #> '{source,repoFullName}'`)}, ${jsonString( + `r."runPlanSnapshot" #> '{source,workspaceLayout,primaryRepo}'` +)})) AS repository, + COALESCE(NULLIF(BTRIM(r."resolvedProvider"), ''), NULLIF(BTRIM(r.provider), ''), 'unknown_provider') AS provider, + COALESCE(NULLIF(BTRIM(r."resolvedModel"), ''), NULLIF(BTRIM(r.model), ''), 'unknown_model') AS model, + ${jsonString(`r."runPlanSnapshot" #> '{agent,id}'`)} AS agent_id, + ${jsonString(`r."runPlanSnapshot" #> '{agent,label}'`)} AS agent_label`; + +const USAGE_COLUMNS = ` + ${jsonNumber('inputTokens')} AS input_tokens, + ${jsonNumber('outputTokens')} AS output_tokens, + COALESCE(${jsonNumber('totalTokens')}, ${jsonNumber('inputTokens')} + ${jsonNumber('outputTokens')}) AS total_tokens, + ${jsonNumber('totalCostUsd')} AS reported_cost, + ${jsonNumber('estimatedCostUsd')} AS estimated_cost`; + +const AGGREGATES = ` + COUNT(*)::int AS runs, COUNT(DISTINCT session_id)::int AS sessions, COUNT(DISTINCT owner_id)::int AS owners, + ${AGENT_ANALYTICS_STATUSES.map((status) => `COUNT(*) FILTER (WHERE status = '${status}')::int AS "${status}"`).join( + ',\n' + )}, + SUM(total_tokens) AS total_tokens, SUM(input_tokens) AS input_tokens, SUM(output_tokens) AS output_tokens, + COUNT(total_tokens)::int AS total_reported_runs, COUNT(input_tokens)::int AS input_reported_runs, COUNT(output_tokens)::int AS output_reported_runs, + COUNT(*) FILTER (WHERE total_tokens IS NULL AND status IN ('queued', 'starting', 'running'))::int AS pending_usage_runs, + SUM(reported_cost) AS reported_cost, COUNT(reported_cost)::int AS reported_cost_runs, + SUM(estimated_cost) AS estimated_cost, COUNT(estimated_cost)::int AS estimated_cost_runs, + COUNT(*) FILTER (WHERE repository IS NULL)::int AS unattributed_runs`; + +function scopeSql(query: AgentAnalyticsQuery): { sql: string; bindings: string[] } { + const conditions: string[] = []; + const bindings: string[] = []; + if (query.repository === 'unattributed') conditions.push('repository IS NULL'); + else if (query.repository) { + conditions.push('repository = ?'); + bindings.push(query.repository); + } + if (query.organization) { + conditions.push("split_part(repository, '/', 1) = ?"); + bindings.push(query.organization); + } + for (const [key, column] of [ + ['owner', 'owner_id'], + ['sessionKind', 'session_kind'], + ['agentId', 'agent_id'], + ['provider', 'provider'], + ['model', 'model'], + ] as const) { + if (query[key]) { + conditions.push(`${column} = ?`); + bindings.push(query[key]!); + } + } + return { sql: conditions.length ? `WHERE ${conditions.join(' AND ')}` : '', bindings }; +} + +function bucketDate(date: string, interval: 'day' | 'week'): string { + if (interval === 'day') return date; + const value = new Date(`${date}T00:00:00Z`); + value.setUTCDate(value.getUTCDate() - ((value.getUTCDay() + 6) % 7)); + return value.toISOString().slice(0, 10); +} + +function priorDate(date: string, days: number): string { + const value = new Date(`${date}T00:00:00Z`); + value.setUTCDate(value.getUTCDate() - days); + return value.toISOString().slice(0, 10); +} + +export default class AgentAnalyticsService { + constructor(private readonly db: Pick = defaultDb) {} + + async getSummary(query: AgentAnalyticsQuery): Promise { + return this.db.knex.transaction(async (trx) => { + await trx.raw('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY'); + const range = await resolveAnalyticsRange(trx, query); + const scope = scopeSql(query); + const from = range.previous?.fromUtc ?? range.fromUtc; + const result = await trx.raw( + ` + WITH facts AS MATERIALIZED ( + SELECT ${RUN_DIMENSIONS}, ${USAGE_COLUMNS}, s."userId" AS owner_id, + s."sessionKind" AS session_kind, + CASE WHEN r."createdAt" >= ?::timestamptz THEN 'current' ELSE 'previous' END AS period, + date_trunc(?, ((r."createdAt" AT TIME ZONE ?)::date + + CASE WHEN r."createdAt" < ?::timestamptz THEN ?::int ELSE 0 END)::timestamp)::date::text AS bucket + FROM agent_runs r JOIN agent_sessions s ON s.id = r."sessionId" + WHERE r."createdAt" >= ?::timestamptz AND r."createdAt" < ?::timestamptz + ), scoped AS MATERIALIZED (SELECT * FROM facts ${scope.sql}), + aggregates AS ( + SELECT period, bucket, repository, provider, model, agent_id, MAX(agent_label) AS agent_label, + CASE WHEN GROUPING(bucket) = 0 THEN 'date' + WHEN GROUPING(repository) = 0 THEN 'repository' + WHEN GROUPING(provider) = 0 THEN 'model' + WHEN GROUPING(agent_id) = 0 THEN 'definition' ELSE 'total' END AS group_type, + ${AGGREGATES} + FROM scoped GROUP BY GROUPING SETS ((period), (period,bucket), (period,repository), (period,provider,model), (period,agent_id)) + ), ranked AS ( + SELECT *, COUNT(*) OVER (PARTITION BY period, group_type)::int AS ranking_total, + row_number() OVER (PARTITION BY period, group_type ORDER BY runs DESC, repository NULLS LAST, provider, model, agent_id NULLS LAST, agent_label NULLS LAST) AS rank + FROM aggregates + ) SELECT * FROM ranked WHERE group_type IN ('total','date') OR (period = 'current' AND rank <= ?) + ORDER BY group_type, period, rank + `, + [ + range.fromUtc, + range.interval, + range.timezone, + range.fromUtc, + range.calendarDays, + from, + range.toUtc, + ...scope.bindings, + RANKING_LIMIT, + ] + ); + const rows = result.rows as SqlRow[]; + const sessionRows = await this.createdSessions(trx, query, range); + const historyResult = await trx.raw('SELECT min("createdAt") AS earliest FROM agent_runs'); + const current = rows.filter((row) => row.period === 'current'); + const dates = [...new Set(range.dates.map((date) => bucketDate(date, range.interval)))]; + const created = sessionRows.find((row) => row.period === 'current'); + const previousCreated = sessionRows.find((row) => row.period === 'previous'); + const rankingTotal = { + repositories: number(current.find((row) => row.group_type === 'repository')?.ranking_total), + models: number(current.find((row) => row.group_type === 'model')?.ranking_total), + definitions: number(current.find((row) => row.group_type === 'definition')?.ranking_total), + }; + return { + range, + asOf: range.asOf, + attribution: { repository: 'run_plan_primary', model: 'current_resolved', repositoryScope: 'recorded_name' }, + caveats: [...CAVEATS], + totals: serializeAgentAnalyticsMetrics(current.find((row) => row.group_type === 'total')), + previous: range.previous + ? serializeAgentAnalyticsMetrics(rows.find((row) => row.period === 'previous' && row.group_type === 'total')) + : null, + series: dates.map((date) => ({ + date, + previousDate: range.previous ? priorDate(date, range.calendarDays) : null, + current: serializeAgentAnalyticsMetrics( + current.find((row) => row.group_type === 'date' && row.bucket === date) + ), + previous: range.previous + ? serializeAgentAnalyticsMetrics( + rows.find((row) => row.period === 'previous' && row.group_type === 'date' && row.bucket === date) + ) + : null, + })), + repositories: current + .filter((row) => row.group_type === 'repository') + .map((row) => ({ + repository: row.repository == null ? null : String(row.repository), + ...serializeAgentAnalyticsMetrics(row), + })), + models: current + .filter((row) => row.group_type === 'model') + .map((row) => ({ + provider: String(row.provider), + model: String(row.model), + ...serializeAgentAnalyticsMetrics(row), + })), + definitions: current + .filter((row) => row.group_type === 'definition') + .map((row) => ({ + agentId: row.agent_id == null ? null : String(row.agent_id), + label: row.agent_label == null ? null : String(row.agent_label), + ...serializeAgentAnalyticsMetrics(row), + })), + sessionsCreated: { + current: number(created?.count), + previous: range.previous ? number(previousCreated?.count) : null, + unattributedCurrent: number(created?.unattributed), + unattributedPrevious: range.previous ? number(previousCreated?.unattributed) : null, + attribution: 'first_recorded_run', + }, + earliestRunAt: timestamp(historyResult.rows[0]?.earliest), + rankingLimit: RANKING_LIMIT, + rankingTotal, + rankingTruncated: { + repositories: rankingTotal.repositories > RANKING_LIMIT, + models: rankingTotal.models > RANKING_LIMIT, + definitions: rankingTotal.definitions > RANKING_LIMIT, + }, + }; + }); + } + + private async createdSessions( + trx: Knex.Transaction, + query: AgentAnalyticsQuery, + range: ResolvedAnalyticsRange + ): Promise { + const scope = scopeSql(query); + const result = await trx.raw( + ` + WITH sessions AS ( + SELECT s."userId" AS owner_id, s."sessionKind" AS session_kind, first_run.*, + CASE WHEN s."createdAt" >= ?::timestamptz THEN 'current' ELSE 'previous' END AS period + FROM agent_sessions s LEFT JOIN LATERAL ( + SELECT ${RUN_DIMENSIONS} FROM agent_runs r WHERE r."sessionId" = s.id ORDER BY r."createdAt", r.id LIMIT 1 + ) first_run ON true + WHERE s."createdAt" >= ?::timestamptz AND s."createdAt" < ?::timestamptz + ) SELECT period, COUNT(*)::int AS count, COUNT(*) FILTER (WHERE repository IS NULL)::int AS unattributed + FROM sessions ${scope.sql} GROUP BY period + `, + [range.fromUtc, range.previous?.fromUtc ?? range.fromUtc, range.toUtc, ...scope.bindings] + ); + return result.rows; + } + + async listRuns(query: AgentAnalyticsRunsQuery, page = 1, limit = 25): Promise { + if (!Number.isInteger(page) || page < 1 || page > 10000 || !Number.isInteger(limit) || limit < 1 || limit > 100) + throw new BadRequestError('Invalid run pagination.'); + return this.db.knex.transaction(async (trx) => { + await trx.raw('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY'); + const range = await resolveAnalyticsRange(trx, query); + const scope = scopeSql(query); + const statusCondition = query.runStatus ? `${scope.sql ? ' AND' : ' WHERE'} status = ?` : ''; + const statusBindings = query.runStatus ? [query.runStatus] : []; + const facts = `WITH facts AS MATERIALIZED ( + SELECT ${RUN_DIMENSIONS}, ${USAGE_COLUMNS}, s."userId" AS owner_id, s."ownerGithubUsername" AS owner_github_username, + s."sessionKind" AS session_kind, s.uuid AS session_uuid, t.uuid AS thread_uuid, + ${jsonString(`r.error->'code'`)} AS error_code + FROM agent_runs r JOIN agent_sessions s ON s.id = r."sessionId" JOIN agent_threads t ON t.id = r."threadId" + WHERE r."createdAt" >= ?::timestamptz AND r."createdAt" < ?::timestamptz + ), scoped AS MATERIALIZED (SELECT * FROM facts ${scope.sql}${statusCondition})`; + const bindings = [range.fromUtc, range.toUtc, ...scope.bindings, ...statusBindings]; + const result = await trx.raw( + `${facts} + SELECT (SELECT COUNT(*)::int FROM scoped) AS total, COALESCE(( + SELECT jsonb_agg(page_rows ORDER BY submitted_at DESC, id DESC) FROM ( + SELECT * FROM scoped ORDER BY submitted_at DESC, id DESC LIMIT ? OFFSET ? + ) page_rows + ), '[]'::jsonb) AS runs`, + [...bindings, limit, (page - 1) * limit] + ); + const row = result.rows[0]; + const total = number(row.total); + return { + range, + asOf: range.asOf, + caveats: [...CAVEATS], + runs: (row.runs as SqlRow[]).map((run) => ({ + runId: String(run.run_uuid), + sessionId: String(run.session_uuid), + threadId: String(run.thread_uuid), + submittedAt: timestamp(run.submitted_at)!, + queuedAt: timestamp(run.queued_at)!, + status: run.status as AgentAnalyticsStatus, + repository: run.repository == null ? null : String(run.repository), + provider: String(run.provider), + model: String(run.model), + agentId: run.agent_id == null ? null : String(run.agent_id), + agentLabel: run.agent_label == null ? null : String(run.agent_label), + ownerId: String(run.owner_id), + ownerGithubUsername: run.owner_github_username == null ? null : String(run.owner_github_username), + tokens: { + total: nullableNumber(run.total_tokens), + input: nullableNumber(run.input_tokens), + output: nullableNumber(run.output_tokens), + }, + reportedCostUsd: nullableNumber(run.reported_cost), + estimatedCostUsd: nullableNumber(run.estimated_cost), + errorCode: run.error_code == null ? null : String(run.error_code), + })), + pagination: { page, limit, total, hasMore: page * limit < total }, + }; + }); + } + + async getOptions(): Promise { + const limit = 200; + const result = await this.db.knex.raw( + ` + WITH facts AS MATERIALIZED (SELECT ${RUN_DIMENSIONS} FROM agent_runs r), + repositories AS (SELECT DISTINCT repository FROM facts WHERE repository IS NOT NULL), + models AS (SELECT DISTINCT provider, model FROM facts), + owners AS ( + SELECT DISTINCT ON ("userId") "userId" AS id, NULLIF(BTRIM("ownerGithubUsername"), '') AS "githubUsername" + FROM agent_sessions ORDER BY "userId", "updatedAt" DESC, id DESC + ) SELECT CURRENT_TIMESTAMP AS as_of, + (SELECT COUNT(*)::int FROM repositories) AS repositories_total, + (SELECT COUNT(*)::int FROM models) AS models_total, + (SELECT COUNT(*)::int FROM owners) AS owners_total, + (EXISTS (SELECT 1 FROM facts WHERE repository IS NULL) OR EXISTS ( + SELECT 1 FROM agent_sessions s WHERE NOT EXISTS (SELECT 1 FROM agent_runs r WHERE r."sessionId" = s.id) + )) AS has_unattributed, + COALESCE((SELECT jsonb_agg(repository ORDER BY repository) FROM (SELECT repository FROM repositories ORDER BY repository LIMIT ?) r), '[]'::jsonb) AS repositories, + COALESCE((SELECT jsonb_agg(m ORDER BY provider, model) FROM (SELECT * FROM models ORDER BY provider, model LIMIT ?) m), '[]'::jsonb) AS models, + COALESCE((SELECT jsonb_agg(o ORDER BY "githubUsername" NULLS LAST, id) FROM (SELECT * FROM owners ORDER BY "githubUsername" NULLS LAST, id LIMIT ?) o), '[]'::jsonb) AS owners + `, + [limit, limit, limit] + ); + const row = result.rows[0]; + const totals = { + repositories: number(row.repositories_total), + models: number(row.models_total), + owners: number(row.owners_total), + }; + return { + asOf: timestamp(row.as_of)!, + repositoryScope: 'recorded_name', + repositories: row.repositories, + owners: row.owners, + models: row.models, + hasUnattributed: Boolean(row.has_unattributed), + limit, + totals, + truncated: { + repositories: totals.repositories > limit, + owners: totals.owners > limit, + models: totals.models > limit, + }, + }; + } +} diff --git a/src/server/services/analytics/EnvironmentAnalyticsService.test.ts b/src/server/services/analytics/EnvironmentAnalyticsService.test.ts new file mode 100644 index 00000000..7a47a41e --- /dev/null +++ b/src/server/services/analytics/EnvironmentAnalyticsService.test.ts @@ -0,0 +1,262 @@ +jest.mock('server/lib/dependencies', () => ({ defaultDb: {} })); +jest.mock('./query', () => ({ ...jest.requireActual('./query'), resolveAnalyticsRange: jest.fn() })); + +import knexFactory, { type Knex } from 'knex'; +import EnvironmentAnalyticsService, { + analyticsEnvironmentRecord, + parseEnvironmentAnalyticsQuery, + parseEnvironmentAnalyticsScope, + parseEnvironmentAnalyticsRecordsQuery, +} from './EnvironmentAnalyticsService'; +import { resolveAnalyticsRange, type ResolvedAnalyticsRange } from './query'; + +const scope = parseEnvironmentAnalyticsScope(new URLSearchParams()); +const range: ResolvedAnalyticsRange = { + from: '2026-09-30', + to: '2026-10-02', + fromUtc: '2026-09-30T00:00:00.000Z', + toUtc: '2026-10-02T00:00:00.000Z', + dates: ['2026-09-30', '2026-10-01'], + timezone: 'UTC', + interval: 'day', + compare: false, + calendarDays: 2, + previous: null, + asOf: '2026-10-02T12:00:00.000Z', +}; +function row(id: number, overrides: Record = {}) { + return { + id, + uuid: `env-${id}`, + namespace: `env-${id}`, + status: 'deployed', + isStatic: false, + triggerType: 'github_pr', + createdAt: '2026-09-30T10:00:00Z', + updatedAt: '2026-10-01T10:00:00Z', + deletedAt: null, + expiresAt: null, + deployEnabled: true, + pullRequestId: 1, + prDeployOnUpdate: true, + prAuthor: 'Alice', + pullRequestNumber: 12, + prTitle: 'Change', + repositoryId: 4, + fullName: 'org/repo', + githubInstallationId: 8, + author: 'Alice', + repositoryAmbiguous: false, + deploys: [{ active: true, status: 'ready', publicUrl: '', deployable: { type: 'docker' } }], + ...overrides, + }; +} +function serviceWithExecutor( + execute: (query: { sql: string; bindings: readonly unknown[]; method: string }) => unknown +) { + const knex = knexFactory({ client: 'pg' }); + const queries: Array<{ sql: string; bindings: readonly unknown[]; method: string }> = []; + jest.spyOn(knex.client, 'runner').mockImplementation( + (builder: any) => + ({ + run: async () => { + const query = builder.toSQL(); + queries.push(query); + return execute(query); + }, + } as any) + ); + const transaction = async (work: (transaction: Knex) => Promise) => work(knex); + return { service: new EnvironmentAnalyticsService({ knex: { transaction } } as any), queries }; +} + +describe('EnvironmentAnalyticsService contracts', () => { + beforeEach(() => { + jest.clearAllMocks(); + (resolveAnalyticsRange as jest.Mock).mockResolvedValue(range); + }); + + it.each([ + 'repositoryId=0', + 'repositoryId=1.5', + 'repositoryId=4&unattributed=true', + 'environmentType=sandbox', + 'environmentAuthor=', + 'rankBy=attempts', + ])('rejects invalid scope %s', (query) => { + expect(() => parseEnvironmentAnalyticsQuery(new URLSearchParams(query))).toThrow(); + }); + + it.each(['cohort=history', 'phase=failed', 'cohort=current&phase=unknown', 'cohort=current&limit=101', 'page=0'])( + 'rejects invalid drilldown %s', + (query) => { + expect(() => parseEnvironmentAnalyticsRecordsQuery(new URLSearchParams(query))).toThrow(); + } + ); + + it('current drilldown keeps independent scope and ignores historical dates', () => { + const query = parseEnvironmentAnalyticsRecordsQuery( + new URLSearchParams('cohort=current&phase=failed&from=invalid&environmentType=static') + ); + expect(query).toMatchObject({ cohort: 'current', phase: 'failed', page: 1, limit: 25, environmentType: 'static' }); + expect(query.from).toBeUndefined(); + }); + + it('uses service-aware readiness and the correct PR/API pause gate', () => { + expect(analyticsEnvironmentRecord(row(1) as any).phase).toBe('ready'); + expect( + analyticsEnvironmentRecord( + row(2, { deploys: [{ active: true, status: 'built', publicUrl: '', deployable: { type: 'docker' } }] }) as any + ).phase + ).toBe('deployed_not_ready'); + expect( + analyticsEnvironmentRecord( + row(3, { + deploys: [{ active: true, status: 'built', publicUrl: '', deployable: { type: 'configuration' } }], + }) as any + ).phase + ).toBe('ready'); + expect( + analyticsEnvironmentRecord( + row(4, { + deploys: [ + { active: true, status: 'pending', publicUrl: 'https://external', deployable: { type: 'externalHTTP' } }, + ], + }) as any + ).phase + ).toBe('ready'); + expect( + analyticsEnvironmentRecord(row(5, { status: 'pending', prDeployOnUpdate: false, deployEnabled: true }) as any) + .phase + ).toBe('paused'); + expect( + analyticsEnvironmentRecord( + row(6, { status: 'pending', pullRequestId: null, prDeployOnUpdate: true, deployEnabled: false }) as any + ).phase + ).toBe('paused'); + expect(analyticsEnvironmentRecord(row(7, { status: 'config_error' }) as any).phase).toBe('failed'); + expect(analyticsEnvironmentRecord(row(8, { status: 'tearing_down' }) as any).phase).toBe('tearing_down'); + }); + + it('retains history metadata without claiming a live UUID resource', () => { + const record = analyticsEnvironmentRecord( + row(9, { + deletedAt: '2026-10-01T12:00:00Z', + status: 'torn_down', + repositoryId: null, + repositoryAmbiguous: true, + }) as any + ); + expect(record).toMatchObject({ + id: 9, + phase: 'torn_down', + repositoryId: null, + repositoryAmbiguous: true, + deletedAt: '2026-10-01T12:00:00.000Z', + resourceAvailable: false, + }); + expect(analyticsEnvironmentRecord(row(10, { uuid: null }) as any).resourceAvailable).toBe(false); + }); + + it('processes all inventory batches while bounding exception output', async () => { + const rows = Array.from({ length: 1005 }, (_, index) => + row(index + 1, { + status: index % 2 ? 'error' : 'deployed', + repositoryId: index === 1004 ? null : 4, + repositoryAmbiguous: index === 1004, + }) + ); + const { service, queries } = serviceWithExecutor((query) => { + if (query.sql.startsWith('SET TRANSACTION')) return { rows: [] }; + if (query.sql.includes('CURRENT_TIMESTAMP')) return [{ asOf: range.asOf }]; + const lastId = Number(query.bindings.at(-2)); + return rows.filter((record) => record.id > lastId).slice(0, 1000); + }); + const result = await service.getInventory(scope); + expect(result.totals).toMatchObject({ current: 1005, failed: 502, ready: 503, unattributed: 1, ambiguous: 1 }); + expect(result.exceptions).toHaveLength(20); + expect(result.truncated).toBe(true); + expect(queries.filter((query) => query.sql.includes(' AS deploys'))).toHaveLength(2); + expect(queries[0].sql).toBe('SET TRANSACTION READ ONLY'); + }); + + it('pages current records without a full readiness scan when no phase filter is requested', async () => { + const { service, queries } = serviceWithExecutor((query) => { + if (query.sql.startsWith('SET TRANSACTION')) return { rows: [] }; + if (query.sql.includes('CURRENT_TIMESTAMP')) return [{ asOf: range.asOf }]; + if (query.sql.includes('count("e"."id")')) return { total: '1005' }; + return [row(26), row(27)]; + }); + const result = await service.getRecords( + parseEnvironmentAnalyticsRecordsQuery(new URLSearchParams('cohort=current&page=2&limit=25')) + ); + expect(result.pagination).toEqual({ page: 2, limit: 25, total: 1005, hasMore: true }); + expect(result.records.map((record) => record.id)).toEqual([26, 27]); + expect(result.range).toBeNull(); + const projections = queries.filter((query) => query.sql.includes(' AS deploys')); + expect(projections).toHaveLength(1); + expect(projections[0].sql).toContain('order by "e"."id" asc limit ? offset ?'); + expect(projections[0].bindings.slice(-2)).toEqual([25, 25]); + expect(projections[0].sql).not.toContain('"e"."id" >'); + }); + + it('deduplicates PR coverage with EXISTS and preserves independent source-repository aggregates', async () => { + const { service, queries } = serviceWithExecutor((query) => { + if (query.sql.startsWith('SET TRANSACTION')) return { rows: [] }; + if (query.sql.includes(' AS deploys')) + return [row(1), row(2), row(3, { repositoryId: null, repositoryAmbiguous: true })]; + if (query.method === 'first') return { earliest: '2026-09-01T00:00:00Z' }; + if (query.sql.includes('group by "date"')) + return [{ date: '2026-09-30', count: query.sql.includes('"pull_requests" as "p"') ? '1' : '3' }]; + if (query.sql.includes('AS covered')) + return [{ repositoryId: 4, fullName: 'org/repo', githubInstallationId: 8, count: '1', covered: '1' }]; + return [ + { repositoryId: 4, fullName: 'org/repo', githubInstallationId: 8, count: '2', ambiguous: '0' }, + { repositoryId: null, fullName: null, githubInstallationId: null, count: '1', ambiguous: '1' }, + ]; + }); + const result = await service.getEnvironments({ + ...scope, + from: range.from, + to: range.to, + compare: false, + rankBy: 'first_seen', + }); + expect(result.totals).toMatchObject({ + firstSeenEnvironments: 3, + observedPullRequests: 1, + pullRequestsWithEnvironments: 1, + activeRepositories: 1, + unattributedEnvironments: 1, + ambiguousRepositoryEnvironments: 1, + }); + expect(result.repositories[0]).toMatchObject({ + repositoryId: 4, + firstSeenEnvironments: 2, + observedPullRequests: 1, + pullRequestCoverage: 1, + }); + expect(result.buckets.map((bucket) => bucket.firstSeenEnvironments)).toEqual([3, 0]); + const coverage = queries.find((query) => query.sql.includes('AS covered'))!; + expect(coverage.sql).toContain('WHERE EXISTS ('); + expect(coverage.sql).toContain('coverage."pullRequestId" = p.id'); + expect(coverage.sql).not.toContain('"coverage"."createdAt"'); + expect(coverage.sql).not.toContain('join "builds"'); + const envs = queries.find((query) => query.sql.includes('AS ambiguous'))!; + expect(envs.sql).toContain('WHEN rc.matches = 1'); + expect(envs.sql).not.toContain('join "deploys"'); + }); + + it('returns null PR measures for static-only scope instead of a false zero', async () => { + const { service } = serviceWithExecutor((query) => { + if (query.sql.startsWith('SET TRANSACTION')) return { rows: [] }; + if (query.sql.includes(' AS deploys')) return []; + if (query.method === 'first') return { earliest: null }; + return []; + }); + const result = await service.getEnvironments({ ...scope, environmentType: 'static', rankBy: 'first_seen' }); + expect(result.totals.observedPullRequests).toBeNull(); + expect(result.buckets.every((bucket) => bucket.observedPullRequests === null)).toBe(true); + expect(result.previousTotals).toBeNull(); + }); +}); diff --git a/src/server/services/analytics/EnvironmentAnalyticsService.ts b/src/server/services/analytics/EnvironmentAnalyticsService.ts new file mode 100644 index 00000000..8fb05a78 --- /dev/null +++ b/src/server/services/analytics/EnvironmentAnalyticsService.ts @@ -0,0 +1,726 @@ +/** + * 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 type { Knex } from 'knex'; +import type Database from 'server/database'; +import type Build from 'server/models/Build'; +import { defaultDb } from 'server/lib/dependencies'; +import { BadRequestError } from 'server/lib/appError'; +import { getEnvironmentPhase, type EnvironmentPhase, type ReadinessDeploy } from 'server/lib/environments/readiness'; +import { BuildKind, BuildStatus } from 'shared/constants'; +import { + analyticsBucketDates, + analyticsBucketExpression, + parseAnalyticsCalendarQuery, + resolveAnalyticsRange, + type AnalyticsCalendarQuery, + type ResolvedAnalyticsRange, +} from './query'; + +export type EnvironmentAnalyticsScope = { + repositoryId: number | null; + organization: string | null; + environmentType: 'all' | 'ephemeral' | 'static'; + environmentAuthor: string | null; + unattributed: boolean; +}; +export type EnvironmentAnalyticsQuery = AnalyticsCalendarQuery & + EnvironmentAnalyticsScope & { + rankBy: 'first_seen' | 'observed_prs' | 'pr_coverage'; + }; +export type EnvironmentAnalyticsRecordsQuery = EnvironmentAnalyticsQuery & { + cohort: 'first_seen' | 'current'; + phase: EnvironmentPhase | null; + page: number; + limit: number; +}; +export type AnalyticsRepositoryIdentity = { + repositoryId: number | null; + fullName: string | null; + githubInstallationId: number | null; +}; +export type EnvironmentAnalyticsTotals = { + firstSeenEnvironments: number; + observedPullRequests: number | null; + pullRequestsWithEnvironments: number | null; + activeRepositories: number; + unattributedEnvironments: number; + ambiguousRepositoryEnvironments: number; +}; +export type EnvironmentAnalyticsRepository = AnalyticsRepositoryIdentity & { + firstSeenEnvironments: number; + observedPullRequests: number | null; + pullRequestsWithEnvironments: number | null; + pullRequestCoverage: number | null; + currentEnvironments: number; + readyEnvironments: number; +}; +export type EnvironmentAnalytics = { + range: ResolvedAnalyticsRange; + scope: EnvironmentAnalyticsScope; + retention: { + earliestRetainedEnvironmentAt: string | null; + earliestRetainedPullRequestAt: string | null; + collectionStartedAt: null; + retentionStartAt: null; + }; + totals: EnvironmentAnalyticsTotals; + previousTotals: EnvironmentAnalyticsTotals | null; + buckets: Array<{ + date: string; + firstSeenEnvironments: number; + observedPullRequests: number | null; + previousFirstSeenEnvironments: number | null; + previousObservedPullRequests: number | null; + }>; + repositories: EnvironmentAnalyticsRepository[]; + rankingTotal: number; + rankingTruncated: boolean; + rankBy: EnvironmentAnalyticsQuery['rankBy']; + caveats: string[]; +}; +export type InventoryTotals = { + current: number; + static: number; + ephemeral: number; + ready: number; + deployedNotReady: number; + inProgress: number; + paused: number; + failed: number; + tearingDown: number; + unattributed: number; + ambiguous: number; +}; +export type InventoryRepository = AnalyticsRepositoryIdentity & InventoryTotals; +export type EnvironmentAnalyticsRecord = AnalyticsRepositoryIdentity & { + id: number; + uuid: string | null; + namespace: string | null; + status: string | null; + phase: EnvironmentPhase; + isStatic: boolean; + trigger: string; + author: string | null; + createdAt: string | null; + updatedAt: string | null; + expiresAt: string | null; + deletedAt: string | null; + resourceAvailable: boolean; + repositoryAmbiguous: boolean; + pullRequest: { number: number | null; title: string | null; author: string | null } | null; +}; +export type InventoryAnalytics = { + asOf: string; + scope: EnvironmentAnalyticsScope; + totals: InventoryTotals; + repositories: InventoryRepository[]; + exceptions: EnvironmentAnalyticsRecord[]; + truncated: boolean; + repositoriesTruncated: boolean; + repositoryTotal: number; + caveats: string[]; +}; +export type AnalyticsOptions = { + repositories: Array; + organizations: string[]; + environmentAuthors: string[]; + truncated: { repositories: boolean; organizations: boolean; environmentAuthors: boolean }; + caveats: string[]; +}; +export type EnvironmentAnalyticsRecords = { + asOf: string; + scope: EnvironmentAnalyticsScope; + range: ResolvedAnalyticsRange | null; + cohort: 'first_seen' | 'current'; + records: EnvironmentAnalyticsRecord[]; + pagination: { page: number; limit: number; total: number; hasMore: boolean }; +}; + +type Projection = { + id: number; + uuid: string | null; + namespace: string | null; + status: string | null; + isStatic: boolean | null; + triggerType: string | null; + createdAt: Date | string | null; + updatedAt: Date | string | null; + deletedAt: Date | string | null; + expiresAt: Date | string | null; + deployEnabled: boolean | null; + pullRequestId: number | null; + prDeployOnUpdate: boolean | null; + prAuthor: string | null; + pullRequestNumber: number | null; + prTitle: string | null; + repositoryId: number | null; + fullName: string | null; + githubInstallationId: number | null; + author: string | null; + repositoryAmbiguous: boolean; + deploys: ReadinessDeploy[]; +}; +type CountRow = AnalyticsRepositoryIdentity & { + count: number | string; + covered?: number | string; + ambiguous?: number | string; +}; +const BATCH_SIZE = 1000; +const RANKING_LIMIT = 100; +const EXCEPTION_LIMIT = 20; +const CURRENT_PHASES: EnvironmentPhase[] = [ + 'ready', + 'deployed_not_ready', + 'in_progress', + 'paused', + 'failed', + 'tearing_down', +]; +const CAVEATS = [ + 'Historical counts cover retained first-seen records, not deployment attempts. Collection and retention start are unknown.', + 'API-created environments have no installation binding; duplicate active repository matches are unattributed and counted as ambiguous.', +]; +const timestamp = (value: Date | string | null) => (value == null ? null : new Date(value).toISOString()); +const count = (value: unknown) => Number(value) || 0; +const identityKey = (value: number | null) => (value == null ? 'unattributed' : String(value)); +const identity = (row: AnalyticsRepositoryIdentity): AnalyticsRepositoryIdentity => ({ + repositoryId: row.repositoryId == null ? null : Number(row.repositoryId), + fullName: row.fullName ?? null, + githubInstallationId: row.githubInstallationId == null ? null : Number(row.githubInstallationId), +}); +const emptyInventory = (): InventoryTotals => ({ + current: 0, + static: 0, + ephemeral: 0, + ready: 0, + deployedNotReady: 0, + inProgress: 0, + paused: 0, + failed: 0, + tearingDown: 0, + unattributed: 0, + ambiguous: 0, +}); + +function optionalText(params: URLSearchParams, name: string, maximum = 128): string | null { + const value = params.get(name); + if (value == null) return null; + if (!value.trim() || value.length > maximum) + throw new BadRequestError(`${name} must be a nonempty bounded value.`, 'invalid_query'); + return value.trim(); +} + +export function parseEnvironmentAnalyticsScope(params: URLSearchParams): EnvironmentAnalyticsScope { + const repository = params.get('repositoryId'); + const repositoryId = repository == null ? null : Number(repository); + if (repository != null && (!/^[1-9]\d*$/.test(repository) || !Number.isSafeInteger(repositoryId))) { + throw new BadRequestError('repositoryId must be a positive repository row ID.', 'invalid_query'); + } + const unattributed = params.get('unattributed'); + if (unattributed !== null && unattributed !== 'true' && unattributed !== 'false') + throw new BadRequestError('unattributed must be true or false.', 'invalid_query'); + if (unattributed === 'true' && repositoryId != null) + throw new BadRequestError('unattributed and repositoryId are mutually exclusive.', 'invalid_query'); + const environmentType = params.get('environmentType') ?? 'all'; + if (!['all', 'ephemeral', 'static'].includes(environmentType)) + throw new BadRequestError('environmentType must be all, ephemeral or static.', 'invalid_query'); + return { + repositoryId, + organization: optionalText(params, 'organization'), + environmentAuthor: optionalText(params, 'environmentAuthor'), + environmentType: environmentType as EnvironmentAnalyticsScope['environmentType'], + unattributed: unattributed === 'true', + }; +} + +export function parseEnvironmentAnalyticsQuery(params: URLSearchParams): EnvironmentAnalyticsQuery { + const rankBy = params.get('rankBy') ?? 'first_seen'; + if (!['first_seen', 'observed_prs', 'pr_coverage'].includes(rankBy)) + throw new BadRequestError('rankBy must be first_seen, observed_prs or pr_coverage.', 'invalid_query'); + return { + ...parseAnalyticsCalendarQuery(params), + ...parseEnvironmentAnalyticsScope(params), + rankBy: rankBy as EnvironmentAnalyticsQuery['rankBy'], + }; +} + +export function parseEnvironmentAnalyticsRecordsQuery(params: URLSearchParams): EnvironmentAnalyticsRecordsQuery { + const cohort = params.get('cohort') ?? 'first_seen'; + if (cohort !== 'first_seen' && cohort !== 'current') + throw new BadRequestError('cohort must be first_seen or current.', 'invalid_query'); + const phase = params.get('phase') as EnvironmentPhase | null; + if (phase !== null && (cohort !== 'current' || !CURRENT_PHASES.includes(phase))) + throw new BadRequestError( + 'phase is a current-inventory filter and must be a supported current phase.', + 'invalid_query' + ); + const page = Number(params.get('page') ?? 1), + limit = Number(params.get('limit') ?? 25); + if ( + !Number.isSafeInteger(page) || + page < 1 || + page > 1_000_000 || + !Number.isSafeInteger(limit) || + limit < 1 || + limit > 100 + ) + throw new BadRequestError('page must be positive and limit must be between 1 and 100.', 'invalid_query'); + const query = + cohort === 'current' + ? { ...parseEnvironmentAnalyticsScope(params), rankBy: 'first_seen' as const } + : parseEnvironmentAnalyticsQuery(params); + return { ...query, cohort, phase, page, limit }; +} + +function environmentBase(knex: Knex, scope: EnvironmentAnalyticsScope): Knex.QueryBuilder { + const repositories = knex('repositories') + .select('githubRepositoryId') + .count('* as matches') + .min('id as repositoryId') + .whereNull('deletedAt') + .groupBy('githubRepositoryId'); + const base = knex.raw( + ` + SELECT b.id, b.uuid, b.namespace, b.status, b."isStatic", b."triggerType", b."createdAt", b."updatedAt", + b."deletedAt", b."expiresAt", b."deployEnabled", b."pullRequestId", p."deployOnUpdate" AS "prDeployOnUpdate", + p."githubLogin" AS "prAuthor", p."pullRequestNumber", p.title AS "prTitle", + COALESCE(b."createdByGithubLogin", p."githubLogin") AS author, + CASE WHEN p.id IS NOT NULL THEN p."repositoryId" WHEN rc.matches = 1 THEN rc."repositoryId" END AS "repositoryId", + (p.id IS NULL AND COALESCE(rc.matches, 0) > 1) AS "repositoryAmbiguous" + FROM builds b LEFT JOIN pull_requests p ON p.id = b."pullRequestId" + LEFT JOIN repository_candidates rc ON p.id IS NULL AND rc."githubRepositoryId" = b."githubRepositoryId" + WHERE b.kind = ? + `, + [BuildKind.ENVIRONMENT] + ); + const query = knex + .with('repository_candidates', repositories) + .with('environment_base', base) + .from('environment_base as e') + .leftJoin('repositories as r', 'r.id', 'e.repositoryId'); + if (scope.repositoryId != null) query.where('e.repositoryId', scope.repositoryId); + if (scope.unattributed) query.whereNull('e.repositoryId'); + if (scope.organization) query.whereRaw(`LOWER(split_part(r."fullName", '/', 1)) = LOWER(?)`, [scope.organization]); + if (scope.environmentType !== 'all') + query.whereRaw('COALESCE(e."isStatic", false) = ?', [scope.environmentType === 'static']); + if (scope.environmentAuthor) query.whereRaw('LOWER(e.author) = LOWER(?)', [scope.environmentAuthor]); + return query; +} + +function pullRequestBase(knex: Knex, scope: EnvironmentAnalyticsScope): Knex.QueryBuilder { + const query = knex('pull_requests as p') + .leftJoin('repositories as r', 'r.id', 'p.repositoryId') + .where('p.githubPullRequestId', '>', 0) + .where('p.pullRequestNumber', '>', 0); + if (scope.repositoryId != null) query.where('p.repositoryId', scope.repositoryId); + if (scope.unattributed) query.whereNull('r.id'); + if (scope.organization) query.whereRaw(`LOWER(split_part(r."fullName", '/', 1)) = LOWER(?)`, [scope.organization]); + if (scope.environmentAuthor) query.whereRaw('LOWER(p."githubLogin") = LOWER(?)', [scope.environmentAuthor]); + return query; +} + +function projection(knex: Knex, scope: EnvironmentAnalyticsScope): Knex.QueryBuilder { + return environmentBase(knex, scope) + .select('e.*', 'r.fullName', 'r.githubInstallationId') + .select( + knex.raw(` + COALESCE((SELECT jsonb_agg(jsonb_build_object('active', d.active, 'status', d.status, + 'publicUrl', d."publicUrl", 'deployable', jsonb_build_object('type', a.type))) + FROM deploys d JOIN deployables a ON a.id = d."deployableId" + WHERE d."buildId" = e.id AND d.active = true AND NULLIF(btrim(a.name), '') IS NOT NULL), '[]'::jsonb) AS deploys + `) + ); +} + +function currentEnvironmentQuery(query: Knex.QueryBuilder): Knex.QueryBuilder { + return query.whereNull('e.deletedAt').whereRaw('(e.status IS NULL OR e.status <> ?)', [BuildStatus.TORN_DOWN]); +} + +export function analyticsEnvironmentRecord(row: Projection): EnvironmentAnalyticsRecord { + const build = { + status: row.status, + deployEnabled: row.pullRequestId == null ? row.deployEnabled === true : row.prDeployOnUpdate === true, + deploys: row.deploys ?? [], + } as unknown as Build; + return { + ...identity(row), + id: Number(row.id), + uuid: row.uuid, + namespace: row.namespace, + status: row.status, + phase: getEnvironmentPhase(build), + isStatic: row.isStatic === true, + trigger: row.triggerType ?? 'github_pr', + author: row.author, + createdAt: timestamp(row.createdAt), + updatedAt: timestamp(row.updatedAt), + expiresAt: timestamp(row.expiresAt), + deletedAt: timestamp(row.deletedAt), + resourceAvailable: Boolean(row.uuid) && row.deletedAt == null && row.status !== BuildStatus.TORN_DOWN, + repositoryAmbiguous: row.repositoryAmbiguous === true, + pullRequest: + row.pullRequestId == null ? null : { number: row.pullRequestNumber, title: row.prTitle, author: row.prAuthor }, + }; +} + +function addInventory(totals: InventoryTotals, record: EnvironmentAnalyticsRecord): void { + totals.current++; + totals[record.isStatic ? 'static' : 'ephemeral']++; + if (record.repositoryId == null) totals.unattributed++; + if (record.repositoryAmbiguous) totals.ambiguous++; + const field = ( + { + ready: 'ready', + deployed_not_ready: 'deployedNotReady', + in_progress: 'inProgress', + paused: 'paused', + failed: 'failed', + tearing_down: 'tearingDown', + } as const + )[record.phase]; + if (field) totals[field]++; +} + +export default class EnvironmentAnalyticsService { + constructor(private readonly db: Pick = defaultDb) {} + + private async transaction(work: (knex: Knex) => Promise): Promise { + return this.db.knex.transaction( + async (trx) => { + await trx.raw('SET TRANSACTION READ ONLY'); + return work(trx); + }, + { isolationLevel: 'repeatable read' } + ); + } + + private async visitCurrent( + knex: Knex, + scope: EnvironmentAnalyticsScope, + visit: (record: EnvironmentAnalyticsRecord) => void + ): Promise { + let lastId = 0; + for (;;) { + const rows = (await currentEnvironmentQuery(projection(knex, scope)) + .where('e.id', '>', lastId) + .orderBy('e.id') + .limit(BATCH_SIZE)) as Projection[]; + for (const row of rows) visit(analyticsEnvironmentRecord(row)); + if (rows.length < BATCH_SIZE) return; + lastId = Number(rows[rows.length - 1].id); + } + } + + private async inventory( + knex: Knex, + scope: EnvironmentAnalyticsScope + ): Promise<{ + totals: InventoryTotals; + repositories: Map; + exceptions: EnvironmentAnalyticsRecord[]; + exceptionTotal: number; + }> { + const totals = emptyInventory(), + repositories = new Map(); + const exceptions: EnvironmentAnalyticsRecord[] = []; + let exceptionTotal = 0; + await this.visitCurrent(knex, scope, (record) => { + addInventory(totals, record); + const key = identityKey(record.repositoryId); + const repository = repositories.get(key) ?? { ...identity(record), ...emptyInventory() }; + addInventory(repository, record); + repositories.set(key, repository); + if (record.phase !== 'ready') { + exceptionTotal++; + exceptions.push(record); + exceptions.sort((a, b) => (b.updatedAt ?? '').localeCompare(a.updatedAt ?? '') || b.id - a.id); + if (exceptions.length > EXCEPTION_LIMIT) exceptions.pop(); + } + }); + return { totals, repositories, exceptions, exceptionTotal }; + } + + private async period(knex: Knex, query: EnvironmentAnalyticsQuery, range: ResolvedAnalyticsRange, prior: boolean) { + const period = prior ? range.previous! : range; + const environments = environmentBase(knex, query) + .where('e.createdAt', '>=', period.fromUtc) + .where('e.createdAt', '<', period.toUtc); + const envRows = (await environments + .clone() + .select('e.repositoryId', 'r.fullName', 'r.githubInstallationId') + .count('e.id as count') + .select(knex.raw('count(*) FILTER (WHERE e."repositoryAmbiguous") AS ambiguous')) + .groupBy('e.repositoryId', 'r.fullName', 'r.githubInstallationId')) as CountRow[]; + const envBuckets = (await environments + .clone() + .select( + analyticsBucketExpression(knex, range, 'e.createdAt', prior ? 'previous' : 'current').wrap('', ' AS date') + ) + .count('e.id as count') + .groupBy('date')) as { date: string; count: number | string }[]; + let prRows: CountRow[] = [], + prBuckets: { date: string; count: number | string }[] = []; + if (query.environmentType !== 'static') { + const prs = pullRequestBase(knex, query) + .where('p.createdAt', '>=', period.fromUtc) + .where('p.createdAt', '<', period.toUtc); + const coverage = knex('builds as coverage') + .select(knex.raw('1')) + .whereRaw('coverage."pullRequestId" = p.id') + .where('coverage.kind', BuildKind.ENVIRONMENT); + if (query.environmentType === 'ephemeral') coverage.whereRaw('COALESCE(coverage."isStatic", false) = false'); + prRows = (await prs + .clone() + .select('p.repositoryId', 'r.fullName', 'r.githubInstallationId') + .count('p.id as count') + .select(knex.raw('count(*) FILTER (WHERE EXISTS (?)) AS covered', [coverage])) + .groupBy('p.repositoryId', 'r.fullName', 'r.githubInstallationId')) as CountRow[]; + prBuckets = (await prs + .clone() + .select( + analyticsBucketExpression(knex, range, 'p.createdAt', prior ? 'previous' : 'current').wrap('', ' AS date') + ) + .count('p.id as count') + .groupBy('date')) as { date: string; count: number | string }[]; + } + const applicable = query.environmentType !== 'static'; + const totals: EnvironmentAnalyticsTotals = { + firstSeenEnvironments: envRows.reduce((sum, row) => sum + count(row.count), 0), + observedPullRequests: applicable ? prRows.reduce((sum, row) => sum + count(row.count), 0) : null, + pullRequestsWithEnvironments: applicable ? prRows.reduce((sum, row) => sum + count(row.covered), 0) : null, + activeRepositories: envRows.filter((row) => row.repositoryId != null && count(row.count) > 0).length, + unattributedEnvironments: envRows + .filter((row) => row.repositoryId == null) + .reduce((sum, row) => sum + count(row.count), 0), + ambiguousRepositoryEnvironments: envRows.reduce((sum, row) => sum + count(row.ambiguous), 0), + }; + return { totals, envRows, prRows, envBuckets, prBuckets }; + } + + async getEnvironments(query: EnvironmentAnalyticsQuery): Promise { + return this.transaction(async (knex) => { + const range = await resolveAnalyticsRange(knex, query); + const current = await this.period(knex, query, range, false); + const previous = range.previous ? await this.period(knex, query, range, true) : null; + const inventory = await this.inventory(knex, query); + const applicable = query.environmentType !== 'static'; + const repositories = new Map(); + const getRepository = (row: AnalyticsRepositoryIdentity) => { + const key = identityKey(row.repositoryId); + const existing = repositories.get(key) ?? { + ...identity(row), + firstSeenEnvironments: 0, + observedPullRequests: applicable ? 0 : null, + pullRequestsWithEnvironments: applicable ? 0 : null, + pullRequestCoverage: null, + currentEnvironments: 0, + readyEnvironments: 0, + }; + repositories.set(key, existing); + return existing; + }; + for (const row of current.envRows) getRepository(row).firstSeenEnvironments += count(row.count); + for (const row of current.prRows) { + const repo = getRepository(row); + repo.observedPullRequests = count(row.count); + repo.pullRequestsWithEnvironments = count(row.covered); + } + for (const row of inventory.repositories.values()) { + const repo = getRepository(row); + repo.currentEnvironments = row.current; + repo.readyEnvironments = row.ready; + } + const ranking = [...repositories.values()].map((row) => ({ + ...row, + pullRequestCoverage: row.observedPullRequests + ? row.pullRequestsWithEnvironments! / row.observedPullRequests + : null, + })); + const rankValue = (row: EnvironmentAnalyticsRepository) => + query.rankBy === 'observed_prs' + ? row.observedPullRequests ?? -1 + : query.rankBy === 'pr_coverage' + ? row.pullRequestCoverage ?? -1 + : row.firstSeenEnvironments; + ranking.sort( + (a, b) => + rankValue(b) - rankValue(a) || + b.firstSeenEnvironments - a.firstSeenEnvironments || + (a.fullName ?? '\uffff').localeCompare(b.fullName ?? '\uffff') || + (a.repositoryId ?? Number.MAX_SAFE_INTEGER) - (b.repositoryId ?? Number.MAX_SAFE_INTEGER) + ); + const envBuckets = new Map(current.envBuckets.map((row) => [row.date, count(row.count)])); + const prBuckets = new Map(current.prBuckets.map((row) => [row.date, count(row.count)])); + const previousEnvs = new Map(previous?.envBuckets.map((row) => [row.date, count(row.count)]) ?? []); + const previousPrs = new Map(previous?.prBuckets.map((row) => [row.date, count(row.count)]) ?? []); + const envFirst = await environmentBase(knex, query).min('e.createdAt as earliest').first(); + const prFirst = applicable ? await pullRequestBase(knex, query).min('p.createdAt as earliest').first() : null; + return { + range, + scope: this.scope(query), + totals: current.totals, + previousTotals: previous?.totals ?? null, + retention: { + earliestRetainedEnvironmentAt: timestamp(envFirst?.earliest ?? null), + earliestRetainedPullRequestAt: timestamp(prFirst?.earliest ?? null), + collectionStartedAt: null, + retentionStartAt: null, + }, + buckets: analyticsBucketDates(range).map((date) => ({ + date, + firstSeenEnvironments: envBuckets.get(date) ?? 0, + observedPullRequests: applicable ? prBuckets.get(date) ?? 0 : null, + previousFirstSeenEnvironments: previous ? previousEnvs.get(date) ?? 0 : null, + previousObservedPullRequests: previous && applicable ? previousPrs.get(date) ?? 0 : null, + })), + repositories: ranking.slice(0, RANKING_LIMIT), + rankingTotal: ranking.length, + rankingTruncated: ranking.length > RANKING_LIMIT, + rankBy: query.rankBy, + caveats: [ + ...CAVEATS, + 'PR coverage uses the same first-observed PR cohort; linked retained environments may have been created outside that window. Static-only PR metrics are inapplicable.', + ], + }; + }); + } + + private scope(query: EnvironmentAnalyticsScope): EnvironmentAnalyticsScope { + return { + repositoryId: query.repositoryId, + organization: query.organization, + environmentType: query.environmentType, + environmentAuthor: query.environmentAuthor, + unattributed: query.unattributed, + }; + } + + async getInventory(scope: EnvironmentAnalyticsScope): Promise { + return this.transaction(async (knex) => { + const [asOf] = await knex.select(knex.raw('CURRENT_TIMESTAMP AS "asOf"')); + const result = await this.inventory(knex, scope); + const repositories = [...result.repositories.values()].sort( + (a, b) => b.current - a.current || (a.fullName ?? '\uffff').localeCompare(b.fullName ?? '\uffff') + ); + return { + asOf: timestamp(asOf.asOf)!, + scope: this.scope(scope), + totals: result.totals, + repositories: repositories.slice(0, RANKING_LIMIT), + exceptions: result.exceptions, + truncated: result.exceptionTotal > EXCEPTION_LIMIT, + repositoriesTruncated: repositories.length > RANKING_LIMIT, + repositoryTotal: repositories.length, + caveats: [ + ...CAVEATS, + 'Current inventory ignores historical date controls. Readiness reflects recorded state, not a live Kubernetes probe.', + ], + }; + }); + } + + async getOptions(scope: EnvironmentAnalyticsScope): Promise { + return this.transaction(async (knex) => { + const repoQuery = knex('repositories') + .select('id as repositoryId', 'fullName', 'githubRepositoryId', 'githubInstallationId', 'deletedAt') + .orderBy('fullName') + .orderBy('id'); + if (scope.organization) + repoQuery.whereRaw(`LOWER(split_part("fullName", '/', 1)) = LOWER(?)`, [scope.organization]); + const rows = await repoQuery.limit(501); + const orgQuery = knex('repositories') + .select<{ organization: string }[]>(knex.raw(`split_part("fullName", '/', 1) AS organization`)) + .whereNotNull('fullName') + .groupBy('organization') + .orderBy('organization') + .limit(501); + const orgs = await orgQuery; + const authorScope = { ...scope, environmentAuthor: null }; + const authors = (await environmentBase(knex, authorScope) + .select(knex.raw('min(e.author) AS author')) + .whereNotNull('e.author') + .whereRaw(`btrim(e.author) <> ''`) + .groupByRaw('lower(e.author)') + .orderBy('author') + .limit(1001)) as { author: string }[]; + return { + repositories: rows.slice(0, 500).map((row) => ({ + ...identity(row), + githubRepositoryId: Number(row.githubRepositoryId), + deletedAt: timestamp(row.deletedAt), + })), + organizations: orgs.slice(0, 500).map((row) => row.organization), + environmentAuthors: authors.slice(0, 1000).map((row) => row.author), + truncated: { + repositories: rows.length > 500, + organizations: orgs.length > 500, + environmentAuthors: authors.length > 1000, + }, + caveats: [...CAVEATS], + }; + }); + } + + async getRecords(query: EnvironmentAnalyticsRecordsQuery): Promise { + return this.transaction(async (knex) => { + let range: ResolvedAnalyticsRange | null = null; + let asOf: string; + let total = 0; + const records: EnvironmentAnalyticsRecord[] = []; + const offset = (query.page - 1) * query.limit; + if (query.cohort === 'current') { + const [row] = await knex.select(knex.raw('CURRENT_TIMESTAMP AS "asOf"')); + asOf = timestamp(row.asOf)!; + if (query.phase) { + await this.visitCurrent(knex, query, (record) => { + if (record.phase !== query.phase) return; + if (total >= offset && records.length < query.limit) records.push(record); + total++; + }); + } else { + const totalRow = await currentEnvironmentQuery(environmentBase(knex, query)).count('e.id as total').first(); + total = count(totalRow?.total); + const rows = (await currentEnvironmentQuery(projection(knex, query)) + .orderBy('e.id') + .offset(offset) + .limit(query.limit)) as Projection[]; + records.push(...rows.map(analyticsEnvironmentRecord)); + } + } else { + range = await resolveAnalyticsRange(knex, query); + asOf = range.asOf; + const base = environmentBase(knex, query) + .where('e.createdAt', '>=', range.fromUtc) + .where('e.createdAt', '<', range.toUtc); + const totalRow = await base.clone().count('e.id as total').first(); + total = count(totalRow?.total); + const rows = (await projection(knex, query) + .where('e.createdAt', '>=', range.fromUtc) + .where('e.createdAt', '<', range.toUtc) + .orderBy('e.createdAt', 'desc') + .orderBy('e.id', 'desc') + .offset(offset) + .limit(query.limit)) as Projection[]; + records.push(...rows.map(analyticsEnvironmentRecord)); + } + return { + asOf, + scope: this.scope(query), + range, + cohort: query.cohort, + records, + pagination: { page: query.page, limit: query.limit, total, hasMore: offset + records.length < total }, + }; + }); + } +} diff --git a/src/server/services/analytics/query.test.ts b/src/server/services/analytics/query.test.ts new file mode 100644 index 00000000..97b84c72 --- /dev/null +++ b/src/server/services/analytics/query.test.ts @@ -0,0 +1,165 @@ +import knexFactory from 'knex'; +import { + analyticsBucketDates, + analyticsBucketExpression, + parseAnalyticsCalendarQuery, + resolveAnalyticsRange, + type ResolvedAnalyticsRange, +} from './query'; + +describe('analytics calendar contract', () => { + it.each([ + 'from=2026-02-30&to=2026-03-02', + 'from=2026-1-01&to=2026-01-03', + 'from=2026-01-01T00:00:00Z&to=2026-01-03', + 'from=&to=2026-01-03', + 'from=2026-01-03&to=2026-01-03', + 'from=2026-01-04&to=2026-01-03', + 'from=2025-01-01&to=2026-01-02', + 'timezone=Not%2FAZone', + 'timezone=%2B01%3A00', + 'compare=1', + 'interval=month', + 'from=0000-12-31&to=0001-01-01', + 'from=9999-12-31&to=10000-01-01', + ])('rejects invalid calendar input %s', (query) => { + expect(() => parseAnalyticsCalendarQuery(new URLSearchParams(query))).toThrow(); + }); + + it('defaults to UTC/day with comparison and accepts exactly 365 calendar days', () => { + expect(parseAnalyticsCalendarQuery(new URLSearchParams())).toEqual({ + from: undefined, + to: undefined, + timezone: 'UTC', + interval: 'day', + compare: true, + }); + expect( + parseAnalyticsCalendarQuery(new URLSearchParams('from=2025-01-01&to=2026-01-01&compare=false')).compare + ).toBe(false); + }); + + it('preserves SQL-resolved DST boundaries and equal calendar-day prior alignment', async () => { + const raw = jest.fn().mockResolvedValue({ + rows: [ + { + from: '2026-03-08', + to: '2026-03-10', + fromUtc: '2026-03-08T08:00:00Z', + toUtc: '2026-03-10T07:00:00Z', + previousFrom: '2026-03-06', + previousFromUtc: '2026-03-06T08:00:00Z', + asOf: '2026-03-10T08:30:00Z', + }, + ], + }); + const result = await resolveAnalyticsRange({ raw } as any, { + from: '2026-03-08', + to: '2026-03-10', + timezone: 'America/Los_Angeles', + }); + expect(result.calendarDays).toBe(2); + expect(result.dates).toEqual(['2026-03-08', '2026-03-09']); + expect(result.previous?.dates).toEqual(['2026-03-06', '2026-03-07']); + expect((Date.parse(result.toUtc) - Date.parse(result.fromUtc)) / 3_600_000).toBe(47); + expect((Date.parse(result.previous!.toUtc) - Date.parse(result.previous!.fromUtc)) / 3_600_000).toBe(48); + expect(raw).toHaveBeenCalledWith(expect.stringContaining('AT TIME ZONE timezone'), [ + 'America/Los_Angeles', + '2026-03-10', + 'America/Los_Angeles', + '2026-03-08', + ]); + }); + + it('uses complete calendar days returned by SQL for default dates and omits explicit disabled comparison', async () => { + const raw = jest.fn().mockResolvedValue({ + rows: [ + { + from: '2026-09-02', + to: '2026-10-02', + fromUtc: '2026-09-02T00:00:00Z', + toUtc: '2026-10-02T00:00:00Z', + previousFrom: '2026-08-03', + previousFromUtc: '2026-08-03T00:00:00Z', + asOf: '2026-10-02T18:00:00Z', + }, + ], + }); + const result = await resolveAnalyticsRange({ raw } as any, { compare: false }); + expect(result.dates).toHaveLength(30); + expect(result.dates.at(-1)).toBe('2026-10-01'); + expect(result.previous).toBeNull(); + expect(raw.mock.calls[0][1]).toEqual(['UTC', null, 'UTC', null]); + }); + + it('shifts prior local dates before ISO-week truncation so partial weeks compare matching blocks', () => { + const knex = knexFactory({ client: 'pg' }); + const range = { + interval: 'week', + timezone: 'America/Los_Angeles', + calendarDays: 5, + dates: ['2026-03-07', '2026-03-08', '2026-03-09', '2026-03-10', '2026-03-11'], + } as ResolvedAnalyticsRange; + expect(analyticsBucketDates(range)).toEqual(['2026-03-02', '2026-03-09']); + expect(analyticsBucketExpression(knex, range, 'runs.queuedAt', 'previous').toSQL().bindings).toEqual([ + 'week', + 'America/Los_Angeles', + 5, + ]); + expect(analyticsBucketExpression(knex, range, 'runs.queuedAt').toSQL().sql).toContain( + '"runs"."queuedAt" AT TIME ZONE' + ); + }); + + it('rejects comparison dates outside the supported AD calendar domain', async () => { + const raw = jest.fn().mockResolvedValue({ + rows: [ + { + from: '0001-01-01 AD', + to: '0001-01-02 AD', + fromUtc: '0001-01-01T00:00:00Z', + toUtc: '0001-01-02T00:00:00Z', + previousFrom: '0001-12-31 BC', + previousFromUtc: '0000-12-31T00:00:00Z', + asOf: '2026-10-03T00:00:00Z', + }, + ], + }); + await expect(resolveAnalyticsRange({ raw } as any, { from: '0001-01-01', to: '0001-01-02' })).rejects.toMatchObject( + { + httpStatus: 400, + code: 'invalid_query', + } + ); + const range = await resolveAnalyticsRange({ raw } as any, { + from: '0001-01-01', + to: '0001-01-02', + compare: false, + }); + expect(range).toMatchObject({ + from: '0001-01-01', + to: '0001-01-02', + dates: ['0001-01-01'], + previous: null, + }); + }); + + it('preserves the maximum four-digit AD dates and their comparison', async () => { + const raw = jest.fn().mockResolvedValue({ + rows: [ + { + from: '9999-12-30 AD', + to: '9999-12-31 AD', + fromUtc: '9999-12-30T00:00:00Z', + toUtc: '9999-12-31T00:00:00Z', + previousFrom: '9999-12-29 AD', + previousFromUtc: '9999-12-29T00:00:00Z', + asOf: '2026-10-03T00:00:00Z', + }, + ], + }); + const range = await resolveAnalyticsRange({ raw } as any, { from: '9999-12-30', to: '9999-12-31' }); + expect(range.dates).toEqual(['9999-12-30']); + expect(range.previous).toMatchObject({ from: '9999-12-29', to: '9999-12-30', dates: ['9999-12-29'] }); + }); +}); diff --git a/src/server/services/analytics/query.ts b/src/server/services/analytics/query.ts new file mode 100644 index 00000000..97d1b602 --- /dev/null +++ b/src/server/services/analytics/query.ts @@ -0,0 +1,205 @@ +/** + * 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 type { Knex } from 'knex'; +import { BadRequestError } from 'server/lib/appError'; + +export type AnalyticsInterval = 'day' | 'week'; +export type AnalyticsCalendarQuery = { + from?: string; + to?: string; + timezone?: string; + compare?: boolean; + interval?: AnalyticsInterval; +}; +export type AnalyticsPeriod = { + from: string; + to: string; + fromUtc: string; + toUtc: string; + dates: string[]; +}; +export type ResolvedAnalyticsRange = AnalyticsPeriod & { + timezone: string; + interval: AnalyticsInterval; + compare: boolean; + calendarDays: number; + previous: AnalyticsPeriod | null; + asOf: string; +}; + +const DAY = 86_400_000; +const MAX_DAYS = 365; +const dateTimestamp = (date: string) => Date.parse(`${date}T00:00:00Z`); +const isoDate = (timestamp: number) => new Date(timestamp).toISOString().slice(0, 10); +const isoInstant = (value: Date | string) => new Date(value).toISOString(); + +function validateDate(value: string | undefined, name: string): void { + if (value === undefined) return; + if (!/^\d{4}-\d{2}-\d{2}$/.test(value) || value.startsWith('0000') || !Number.isFinite(dateTimestamp(value))) { + throw new BadRequestError(`${name} must be a calendar date in YYYY-MM-DD format.`, 'invalid_query'); + } + if (isoDate(dateTimestamp(value)) !== value) { + throw new BadRequestError(`${name} must be a real calendar date.`, 'invalid_query'); + } +} + +function validateLength(from: string, to: string): number { + const count = (dateTimestamp(to) - dateTimestamp(from)) / DAY; + if (count < 1 || count > MAX_DAYS) { + throw new BadRequestError(`The date range must contain between 1 and ${MAX_DAYS} calendar days.`, 'invalid_query'); + } + return count; +} + +function resolvedDate(value: string, name: string): string { + const date = value.replace(/ AD$/, ''); + validateDate(date, name); + return date; +} + +export function analyticsTimezone(value = 'UTC'): string { + if (!value || value.length > 100 || !/^[A-Za-z_]+(?:\/[A-Za-z0-9_+-]+)*$/.test(value)) { + throw new BadRequestError('timezone must be a supported IANA timezone.', 'invalid_query'); + } + try { + return new Intl.DateTimeFormat('en-US', { timeZone: value }).resolvedOptions().timeZone; + } catch { + throw new BadRequestError('timezone must be a supported IANA timezone.', 'invalid_query'); + } +} + +export function parseAnalyticsCalendarQuery(params: URLSearchParams): AnalyticsCalendarQuery { + const from = params.get('from') ?? undefined; + const to = params.get('to') ?? undefined; + validateDate(from, 'from'); + validateDate(to, 'to'); + if (from !== undefined && to !== undefined) validateLength(from, to); + const interval = params.get('interval') ?? 'day'; + if (interval !== 'day' && interval !== 'week') { + throw new BadRequestError('interval must be day or week.', 'invalid_query'); + } + const compare = params.get('compare'); + if (compare !== null && compare !== 'true' && compare !== 'false') { + throw new BadRequestError('compare must be true or false.', 'invalid_query'); + } + return { + from, + to, + timezone: analyticsTimezone(params.get('timezone') ?? 'UTC'), + interval, + compare: compare !== 'false', + }; +} + +export async function resolveAnalyticsRange( + knex: Pick, + query: AnalyticsCalendarQuery +): Promise { + validateDate(query.from, 'from'); + validateDate(query.to, 'to'); + if (query.from !== undefined && query.to !== undefined) validateLength(query.from, query.to); + const timezone = analyticsTimezone(query.timezone); + const interval = query.interval ?? 'day'; + if ( + (interval !== 'day' && interval !== 'week') || + (query.compare !== undefined && typeof query.compare !== 'boolean') + ) { + throw new BadRequestError('Invalid interval or comparison option.', 'invalid_query'); + } + const result = await knex.raw( + ` + WITH requested AS ( + SELECT CURRENT_TIMESTAMP AS "asOf", ?::text AS timezone, + COALESCE(?::date, (CURRENT_TIMESTAMP AT TIME ZONE ?)::date) AS "toDate", + ?::date AS "fromDate" + ), period AS ( + SELECT "asOf", timezone, COALESCE("fromDate", "toDate" - 30) AS "fromDate", "toDate" + FROM requested + ) + SELECT "asOf", to_char("fromDate", 'YYYY-MM-DD AD') AS "from", + to_char("toDate", 'YYYY-MM-DD AD') AS "to", + "fromDate"::timestamp AT TIME ZONE timezone AS "fromUtc", + "toDate"::timestamp AT TIME ZONE timezone AS "toUtc", + to_char("fromDate" - ("toDate" - "fromDate"), 'YYYY-MM-DD AD') AS "previousFrom", + ("fromDate" - ("toDate" - "fromDate"))::timestamp AT TIME ZONE timezone AS "previousFromUtc" + FROM period + `, + [timezone, query.to ?? null, timezone, query.from ?? null] + ); + const row = result.rows[0] as { + from: string; + to: string; + fromUtc: Date | string; + toUtc: Date | string; + previousFrom: string; + previousFromUtc: Date | string; + asOf: Date | string; + }; + const from = resolvedDate(row.from, 'from'); + const to = resolvedDate(row.to, 'to'); + const calendarDays = validateLength(from, to); + const dates = Array.from({ length: calendarDays }, (_, index) => isoDate(dateTimestamp(from) + index * DAY)); + const compare = query.compare !== false; + const previousFrom = compare ? resolvedDate(row.previousFrom, 'comparison start date') : null; + return { + from, + to, + fromUtc: isoInstant(row.fromUtc), + toUtc: isoInstant(row.toUtc), + timezone, + interval, + compare, + calendarDays, + dates, + asOf: isoInstant(row.asOf), + previous: compare + ? { + from: previousFrom!, + to: from, + fromUtc: isoInstant(row.previousFromUtc), + toUtc: isoInstant(row.fromUtc), + dates: Array.from({ length: calendarDays }, (_, index) => + isoDate(dateTimestamp(previousFrom!) + index * DAY) + ), + } + : null, + }; +} + +export function analyticsBucketDates(range: ResolvedAnalyticsRange): string[] { + if (range.interval === 'day') return range.dates; + return [ + ...new Set( + range.dates.map((date) => { + const timestamp = dateTimestamp(date); + const daysFromMonday = (new Date(timestamp).getUTCDay() + 6) % 7; + return isoDate(timestamp - daysFromMonday * DAY); + }) + ), + ]; +} + +export function analyticsBucketExpression( + knex: Pick, + range: ResolvedAnalyticsRange, + column: string, + period: 'current' | 'previous' = 'current' +): Knex.Raw { + // Shift prior local dates before truncation so partial ISO weeks compare the same calendar-day blocks. + return knex.raw(`to_char(date_trunc(?, (?? AT TIME ZONE ?) + (? * interval '1 day')), 'YYYY-MM-DD')`, [ + range.interval, + column, + range.timezone, + period === 'previous' ? range.calendarDays : 0, + ]); +} diff --git a/src/shared/agentAnalyticsOpenApi.ts b/src/shared/agentAnalyticsOpenApi.ts new file mode 100644 index 00000000..24c6158b --- /dev/null +++ b/src/shared/agentAnalyticsOpenApi.ts @@ -0,0 +1,153 @@ +/** + * 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. + */ + +const count = { type: 'integer', minimum: 0 }; +const nullableNumber = { type: 'number', nullable: true }; +const nullableString = { type: 'string', nullable: true }; +const ref = (name: string) => ({ $ref: `#/components/schemas/${name}` }); +const object = (properties: Record) => ({ + type: 'object', + properties, + required: Object.keys(properties), + additionalProperties: false, +}); +const array = (items: unknown) => ({ type: 'array', items }); +const success = (name: string) => ({ + allOf: [ref('SuccessApiResponse'), { type: 'object', properties: { data: ref(name) }, required: ['data'] }], +}); +const statuses = [ + 'queued', + 'starting', + 'running', + 'waiting_for_approval', + 'waiting_for_input', + 'transitioned', + 'completed', + 'failed', + 'cancelled', + 'unknown', +]; +const totals = object({ repositories: count, owners: count, models: count }); +const truncated = object({ + repositories: { type: 'boolean' }, + owners: { type: 'boolean' }, + models: { type: 'boolean' }, +}); +const metricProperties = { + runs: count, + sessions: count, + owners: count, + outcomes: object(Object.fromEntries(statuses.map((status) => [status, count]))), + tokens: object({ + total: nullableNumber, + input: nullableNumber, + output: nullableNumber, + reportedRuns: count, + inputReportedRuns: count, + outputReportedRuns: count, + missingRuns: count, + pendingRuns: count, + }), + reportedCost: object({ usd: nullableNumber, coveredRuns: count }), + estimatedCost: object({ usd: nullableNumber, coveredRuns: count }), + unattributedRuns: count, +}; + +export const agentAnalyticsSchemas = { + AgentAnalyticsMetrics: object(metricProperties), + AgentAnalyticsSummary: object({ + range: ref('AnalyticsRange'), + asOf: { type: 'string', format: 'date-time' }, + attribution: object({ + repository: { type: 'string', enum: ['run_plan_primary'] }, + model: { type: 'string', enum: ['current_resolved'] }, + repositoryScope: { type: 'string', enum: ['recorded_name'] }, + }), + caveats: array({ type: 'string' }), + totals: ref('AgentAnalyticsMetrics'), + previous: { nullable: true, allOf: [ref('AgentAnalyticsMetrics')] }, + series: array( + object({ + date: { type: 'string', format: 'date' }, + previousDate: { type: 'string', format: 'date', nullable: true }, + current: ref('AgentAnalyticsMetrics'), + previous: { nullable: true, allOf: [ref('AgentAnalyticsMetrics')] }, + }) + ), + repositories: array(object({ repository: nullableString, ...metricProperties })), + models: array(object({ provider: { type: 'string' }, model: { type: 'string' }, ...metricProperties })), + definitions: array(object({ agentId: nullableString, label: nullableString, ...metricProperties })), + sessionsCreated: object({ + current: count, + previous: { ...count, nullable: true }, + unattributedCurrent: count, + unattributedPrevious: { ...count, nullable: true }, + attribution: { type: 'string', enum: ['first_recorded_run'] }, + }), + earliestRunAt: { type: 'string', format: 'date-time', nullable: true }, + rankingLimit: count, + rankingTotal: object({ repositories: count, models: count, definitions: count }), + rankingTruncated: object({ + repositories: { type: 'boolean' }, + models: { type: 'boolean' }, + definitions: { type: 'boolean' }, + }), + }), + AgentAnalyticsRun: object({ + runId: { type: 'string', format: 'uuid' }, + sessionId: { type: 'string', format: 'uuid' }, + threadId: { type: 'string', format: 'uuid' }, + submittedAt: { + type: 'string', + format: 'date-time', + description: 'Immutable run creation time used for submission cohorts and pagination.', + }, + queuedAt: { + type: 'string', + format: 'date-time', + description: 'Latest queue time; can change when an approval resumes the run.', + }, + status: { type: 'string', enum: statuses }, + repository: nullableString, + provider: { type: 'string' }, + model: { type: 'string' }, + agentId: nullableString, + agentLabel: nullableString, + ownerId: { type: 'string' }, + ownerGithubUsername: nullableString, + tokens: object({ total: nullableNumber, input: nullableNumber, output: nullableNumber }), + reportedCostUsd: nullableNumber, + estimatedCostUsd: nullableNumber, + errorCode: nullableString, + }), + AgentAnalyticsRuns: object({ + range: ref('AnalyticsRange'), + asOf: { type: 'string', format: 'date-time' }, + caveats: array({ type: 'string' }), + runs: array(ref('AgentAnalyticsRun')), + pagination: object({ page: count, limit: count, total: count, hasMore: { type: 'boolean' } }), + }), + AgentAnalyticsOptions: object({ + asOf: { type: 'string', format: 'date-time' }, + repositoryScope: { type: 'string', enum: ['recorded_name'] }, + repositories: array({ type: 'string' }), + hasUnattributed: { type: 'boolean' }, + owners: array(object({ id: { type: 'string' }, githubUsername: nullableString })), + models: array(object({ provider: { type: 'string' }, model: { type: 'string' } })), + limit: count, + totals, + truncated, + }), + GetAgentAnalyticsSuccessResponse: success('AgentAnalyticsSummary'), + GetAgentAnalyticsRunsSuccessResponse: success('AgentAnalyticsRuns'), + GetAgentAnalyticsOptionsSuccessResponse: success('AgentAnalyticsOptions'), +}; diff --git a/src/shared/environmentAnalyticsOpenApi.test.ts b/src/shared/environmentAnalyticsOpenApi.test.ts new file mode 100644 index 00000000..f0a563a9 --- /dev/null +++ b/src/shared/environmentAnalyticsOpenApi.test.ts @@ -0,0 +1,74 @@ +import swaggerJSDoc from 'swagger-jsdoc'; +import { openApiSpecificationForV2Api } from './openApiSpec'; + +const spec = swaggerJSDoc(openApiSpecificationForV2Api) as any; +const schemas = spec.components.schemas; + +describe('Admin environment analytics OpenAPI contract', () => { + it.each([ + ['environments', 'getEnvironmentAnalytics', 'GetEnvironmentAnalyticsSuccessResponse'], + ['inventory', 'getInventoryAnalytics', 'GetInventoryAnalyticsSuccessResponse'], + ['options', 'getAnalyticsOptions', 'GetAnalyticsOptionsSuccessResponse'], + ['environments/records', 'getEnvironmentAnalyticsRecords', 'GetEnvironmentAnalyticsRecordsSuccessResponse'], + ])('documents %s with a concrete generated response and session authentication', (path, operationId, response) => { + const operation = spec.paths[`/api/v2/admin/analytics/${path}`].get; + expect(operation.operationId).toBe(operationId); + expect(operation.responses['200'].content['application/json'].schema).toEqual({ + $ref: `#/components/schemas/${response}`, + }); + const dataSchema = schemas[response].allOf[1].properties.data.$ref.split('/').at(-1); + expect(schemas[dataSchema]).toBeDefined(); + expect(operation.security ?? spec.security).toEqual([{ BearerAuth: [] }]); + expect(operation.responses['403'].description).toContain('Administrator'); + }); + + it('preserves current-only phases and nullable history availability metadata', () => { + const operation = spec.paths['/api/v2/admin/analytics/environments/records'].get; + expect(operation.parameters.find((param: any) => param.name === 'phase').description).toContain('current'); + expect(schemas.EnvironmentAnalyticsRecord.properties.phase.enum).toEqual([ + 'ready', + 'deployed_not_ready', + 'in_progress', + 'paused', + 'failed', + 'tearing_down', + 'torn_down', + ]); + expect(schemas.EnvironmentAnalyticsRecord.required).toEqual( + expect.arrayContaining(['resourceAvailable', 'deletedAt', 'repositoryAmbiguous', 'pullRequest']) + ); + expect(schemas.EnvironmentAnalyticsRecord.properties.pullRequest.nullable).toBe(true); + expect(schemas.EnvironmentAnalyticsRecords.properties.range.nullable).toBe(true); + expect(schemas.EnvironmentAnalyticsPagination.properties.limit.maximum).toBe(100); + }); + + it('models calendar bounds, optional comparison and inapplicable PR values honestly', () => { + const operation = spec.paths['/api/v2/admin/analytics/environments'].get; + expect(operation.parameters.find((param: any) => param.name === 'compare').schema.default).toBe(true); + expect(operation.parameters.find((param: any) => param.name === 'rankBy').schema.enum).toEqual([ + 'first_seen', + 'observed_prs', + 'pr_coverage', + ]); + expect(schemas.AnalyticsRange.properties.calendarDays.maximum).toBe(365); + expect(schemas.AnalyticsRange.properties.previous.nullable).toBe(true); + expect(schemas.EnvironmentAnalyticsTotals.properties.observedPullRequests.nullable).toBe(true); + expect(schemas.EnvironmentAnalyticsBucket.properties.previousFirstSeenEnvironments.nullable).toBe(true); + expect(schemas.EnvironmentAnalyticsRetention.properties.collectionStartedAt.description).toContain('Unknown'); + }); + + it('retains installation-aware options and bounded output metadata', () => { + expect(schemas.AnalyticsRepositoryOption.required).toEqual( + expect.arrayContaining(['repositoryId', 'githubInstallationId', 'githubRepositoryId', 'deletedAt']) + ); + expect(schemas.AnalyticsOptions.properties.truncated.required).toEqual([ + 'repositories', + 'organizations', + 'environmentAuthors', + ]); + expect(schemas.EnvironmentAnalytics.required).toEqual(expect.arrayContaining(['rankingTotal', 'rankingTruncated'])); + expect(schemas.InventoryAnalytics.required).toEqual( + expect.arrayContaining(['repositoryTotal', 'repositoriesTruncated', 'truncated']) + ); + }); +}); diff --git a/src/shared/environmentAnalyticsOpenApi.ts b/src/shared/environmentAnalyticsOpenApi.ts new file mode 100644 index 00000000..723f7a4e --- /dev/null +++ b/src/shared/environmentAnalyticsOpenApi.ts @@ -0,0 +1,187 @@ +/** + * 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. + */ + +const integer = { type: 'integer', minimum: 0 }; +const nullableInteger = { ...integer, nullable: true }; +const text = { type: 'string' }; +const nullableText = { ...text, nullable: true }; +const date = { type: 'string', format: 'date' }; +const instant = { type: 'string', format: 'date-time' }; +const nullableInstant = { ...instant, nullable: true }; +const boolean = { type: 'boolean' }; +const ref = (name: string) => ({ $ref: `#/components/schemas/${name}` }); +const nullableRef = (name: string) => ({ allOf: [ref(name)], nullable: true }); +const array = (items: object) => ({ type: 'array', items }); +const object = (properties: Record) => ({ + type: 'object', + properties, + required: Object.keys(properties), + additionalProperties: false, +}); +const success = (name: string) => ({ + allOf: [ref('SuccessApiResponse'), { type: 'object', properties: { data: ref(name) }, required: ['data'] }], +}); + +const periodProperties = { from: date, to: date, fromUtc: instant, toUtc: instant, dates: array(date) }; +const repositoryProperties = { + repositoryId: nullableInteger, + fullName: nullableText, + githubInstallationId: nullableInteger, +}; +const inventoryProperties = { + current: integer, + static: integer, + ephemeral: integer, + ready: integer, + deployedNotReady: integer, + inProgress: integer, + paused: integer, + failed: integer, + tearingDown: integer, + unattributed: integer, + ambiguous: integer, +}; +const phase = { + type: 'string', + enum: ['ready', 'deployed_not_ready', 'in_progress', 'paused', 'failed', 'tearing_down', 'torn_down'], +}; + +export const environmentAnalyticsSchemas = { + AnalyticsPeriod: object(periodProperties), + AnalyticsRange: object({ + ...periodProperties, + timezone: text, + interval: { type: 'string', enum: ['day', 'week'] }, + compare: boolean, + calendarDays: { type: 'integer', minimum: 1, maximum: 365 }, + previous: nullableRef('AnalyticsPeriod'), + asOf: instant, + }), + EnvironmentAnalyticsScope: object({ + repositoryId: nullableInteger, + organization: nullableText, + environmentType: { type: 'string', enum: ['all', 'ephemeral', 'static'] }, + environmentAuthor: nullableText, + unattributed: boolean, + }), + EnvironmentAnalyticsTotals: object({ + firstSeenEnvironments: integer, + observedPullRequests: nullableInteger, + pullRequestsWithEnvironments: nullableInteger, + activeRepositories: integer, + unattributedEnvironments: integer, + ambiguousRepositoryEnvironments: integer, + }), + EnvironmentAnalyticsRepository: object({ + ...repositoryProperties, + firstSeenEnvironments: integer, + observedPullRequests: nullableInteger, + pullRequestsWithEnvironments: nullableInteger, + pullRequestCoverage: { type: 'number', minimum: 0, maximum: 1, nullable: true }, + currentEnvironments: integer, + readyEnvironments: integer, + }), + EnvironmentAnalyticsBucket: object({ + date, + firstSeenEnvironments: integer, + observedPullRequests: nullableInteger, + previousFirstSeenEnvironments: nullableInteger, + previousObservedPullRequests: nullableInteger, + }), + EnvironmentAnalyticsRetention: object({ + earliestRetainedEnvironmentAt: nullableInstant, + earliestRetainedPullRequestAt: nullableInstant, + collectionStartedAt: { + ...nullableInstant, + description: 'Unknown; earliest retained record is not collection start.', + }, + retentionStartAt: { + ...nullableInstant, + description: 'Unknown; no retention completeness boundary is established.', + }, + }), + EnvironmentAnalytics: object({ + range: ref('AnalyticsRange'), + scope: ref('EnvironmentAnalyticsScope'), + retention: ref('EnvironmentAnalyticsRetention'), + totals: ref('EnvironmentAnalyticsTotals'), + previousTotals: nullableRef('EnvironmentAnalyticsTotals'), + buckets: array(ref('EnvironmentAnalyticsBucket')), + repositories: array(ref('EnvironmentAnalyticsRepository')), + rankingTotal: integer, + rankingTruncated: boolean, + rankBy: { type: 'string', enum: ['first_seen', 'observed_prs', 'pr_coverage'] }, + caveats: array(text), + }), + InventoryAnalyticsTotals: object(inventoryProperties), + InventoryAnalyticsRepository: object({ ...repositoryProperties, ...inventoryProperties }), + EnvironmentAnalyticsPullRequest: object({ number: nullableInteger, title: nullableText, author: nullableText }), + EnvironmentAnalyticsRecord: object({ + ...repositoryProperties, + id: integer, + uuid: nullableText, + namespace: nullableText, + status: nullableText, + phase, + isStatic: boolean, + trigger: text, + author: nullableText, + createdAt: nullableInstant, + updatedAt: nullableInstant, + expiresAt: nullableInstant, + deletedAt: nullableInstant, + resourceAvailable: boolean, + repositoryAmbiguous: boolean, + pullRequest: nullableRef('EnvironmentAnalyticsPullRequest'), + }), + InventoryAnalytics: object({ + asOf: instant, + scope: ref('EnvironmentAnalyticsScope'), + totals: ref('InventoryAnalyticsTotals'), + repositories: array(ref('InventoryAnalyticsRepository')), + exceptions: array(ref('EnvironmentAnalyticsRecord')), + truncated: boolean, + repositoriesTruncated: boolean, + repositoryTotal: integer, + caveats: array(text), + }), + AnalyticsRepositoryOption: object({ + ...repositoryProperties, + githubRepositoryId: integer, + deletedAt: nullableInstant, + }), + AnalyticsOptions: object({ + repositories: array(ref('AnalyticsRepositoryOption')), + organizations: array(text), + environmentAuthors: array(text), + truncated: object({ repositories: boolean, organizations: boolean, environmentAuthors: boolean }), + caveats: array(text), + }), + EnvironmentAnalyticsPagination: object({ + page: { type: 'integer', minimum: 1 }, + limit: { type: 'integer', minimum: 1, maximum: 100 }, + total: integer, + hasMore: boolean, + }), + EnvironmentAnalyticsRecords: object({ + asOf: instant, + scope: ref('EnvironmentAnalyticsScope'), + range: nullableRef('AnalyticsRange'), + cohort: { type: 'string', enum: ['first_seen', 'current'] }, + records: array(ref('EnvironmentAnalyticsRecord')), + pagination: ref('EnvironmentAnalyticsPagination'), + }), + GetAnalyticsOptionsSuccessResponse: success('AnalyticsOptions'), + GetEnvironmentAnalyticsSuccessResponse: success('EnvironmentAnalytics'), + GetInventoryAnalyticsSuccessResponse: success('InventoryAnalytics'), + GetEnvironmentAnalyticsRecordsSuccessResponse: success('EnvironmentAnalyticsRecords'), +}; diff --git a/src/shared/openApiSpec.ts b/src/shared/openApiSpec.ts index 24e648f1..502fcf2d 100644 --- a/src/shared/openApiSpec.ts +++ b/src/shared/openApiSpec.ts @@ -1,4 +1,6 @@ import { OAS3Options } from 'swagger-jsdoc'; +import { environmentAnalyticsSchemas } from './environmentAnalyticsOpenApi'; +import { agentAnalyticsSchemas } from './agentAnalyticsOpenApi'; import { AGENT_FEEDBACK_SCOPES, MAX_AGENT_FEEDBACK_TEXT_LENGTH, @@ -169,6 +171,8 @@ export const openApiSpecificationForV2Api: OAS3Options = { }, }, schemas: { + ...environmentAnalyticsSchemas, + ...agentAnalyticsSchemas, SitesBrowserMintRequest: { type: 'object', additionalProperties: false, From f7e09a27f9fbe6ca2a1b3208cb57e4659bdf5a0d Mon Sep 17 00:00:00 2001 From: vmelikyan Date: Sat, 3 Oct 2026 08:56:08 -0700 Subject: [PATCH 2/9] feat(analytics): add lifetime and managed component metrics Add recorded API lifetimes and estimated lifetimes for torn-down PR environments. Show unique components by repository and ready components across current environments. Keep missing measurements unavailable. Exclude incomplete identities from unique component counts. --- .../analytics/lifetimes/records/route.ts | 62 +++ .../admin/analytics/lifetimes/route.test.ts | 122 +++++ .../api/v2/admin/analytics/lifetimes/route.ts | 56 +++ .../v2/admin/analytics/services/route.test.ts | 80 +++ .../api/v2/admin/analytics/services/route.ts | 55 +++ src/server/lib/v2RoutePolicyManifest.ts | 3 + .../EnvironmentAnalyticsService.test.ts | 171 +++++++ .../analytics/EnvironmentAnalyticsService.ts | 307 +++++++++++- ...nvironmentLifetimeAnalyticsService.test.ts | 285 +++++++++++ .../EnvironmentLifetimeAnalyticsService.ts | 457 ++++++++++++++++++ .../environmentAnalyticsOpenApi.test.ts | 23 + src/shared/environmentAnalyticsOpenApi.ts | 62 +++ .../environmentLifetimeAnalyticsOpenApi.ts | 126 +++++ src/shared/openApiSpec.ts | 2 + 14 files changed, 1807 insertions(+), 4 deletions(-) create mode 100644 src/app/api/v2/admin/analytics/lifetimes/records/route.ts create mode 100644 src/app/api/v2/admin/analytics/lifetimes/route.test.ts create mode 100644 src/app/api/v2/admin/analytics/lifetimes/route.ts create mode 100644 src/app/api/v2/admin/analytics/services/route.test.ts create mode 100644 src/app/api/v2/admin/analytics/services/route.ts create mode 100644 src/server/services/analytics/EnvironmentLifetimeAnalyticsService.test.ts create mode 100644 src/server/services/analytics/EnvironmentLifetimeAnalyticsService.ts create mode 100644 src/shared/environmentLifetimeAnalyticsOpenApi.ts diff --git a/src/app/api/v2/admin/analytics/lifetimes/records/route.ts b/src/app/api/v2/admin/analytics/lifetimes/records/route.ts new file mode 100644 index 00000000..a9a4db08 --- /dev/null +++ b/src/app/api/v2/admin/analytics/lifetimes/records/route.ts @@ -0,0 +1,62 @@ +/** + * 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 { successResponse, withNoStore } from 'server/lib/response'; +import EnvironmentLifetimeAnalyticsService, { + parseEnvironmentLifetimeRecordsQuery, +} from 'server/services/analytics/EnvironmentLifetimeAnalyticsService'; + +export const runtime = 'nodejs'; + +/** + * @openapi + * /api/v2/admin/analytics/lifetimes/records: + * get: + * summary: Get paginated lifetime or current-age environment records + * description: Administrator-only retained-record analytics. API uses createdAt to deletedAt; torn-down PR estimates use createdAt to updatedAt and may include inactive gaps or later edits. Current age is independent of the retirement window. + * tags: [Admin Analytics] + * operationId: getEnvironmentLifetimeAnalyticsRecords + * parameters: + * - { in: query, name: repositoryId, schema: { type: integer, minimum: 1 }, description: Stable installation-aware repository row ID. } + * - { in: query, name: unattributed, schema: { type: boolean, default: false }, description: Mutually exclusive with repositoryId. } + * - { in: query, name: organization, schema: { type: string, maxLength: 128 } } + * - { in: query, name: environmentType, schema: { type: string, enum: [all, ephemeral, static], default: all } } + * - { in: query, name: environmentAuthor, schema: { type: string, maxLength: 128 } } + * - { in: query, name: from, schema: { type: string, format: date }, description: Inclusive retirement calendar date. Defaults to 30 complete days before to. } + * - { in: query, name: to, schema: { type: string, format: date }, description: Exclusive retirement calendar date. Defaults to today in timezone. } + * - { in: query, name: timezone, schema: { type: string, default: UTC }, description: IANA timezone for boundaries and buckets. } + * - { in: query, name: compare, schema: { type: boolean, default: true } } + * - { in: query, name: interval, schema: { type: string, enum: [day, week], default: day } } + * - { in: query, name: cohort, schema: { type: string, enum: [completed, current], default: completed }, description: Current age ignores all calendar controls and returns range null. } + * - { in: query, name: group, schema: { type: string, enum: [all, api, pr, other], default: all }, description: Other is current age only. Completed all combines records without combining summary statistics. } + * - { in: query, name: bin, schema: { type: string, enum: [under_1h, 1_to_6h, 6_to_24h, 1_to_3d, 3_to_7d, 7_to_30d, 30d_plus, missing, invalid] }, description: Half-open duration hours or sample coverage bucket. Unplaced end dates are excluded from completed window records. } + * - { in: query, name: page, schema: { type: integer, minimum: 1, maximum: 10000, default: 1 } } + * - { in: query, name: limit, schema: { type: integer, minimum: 1, maximum: 100, default: 25 } } + * responses: + * '200': + * description: Lifetime analytics result. + * content: + * application/json: + * schema: { $ref: '#/components/schemas/GetEnvironmentLifetimeAnalyticsRecordsSuccessResponse' } + * '400': { description: Invalid query, content: { application/json: { schema: { $ref: '#/components/schemas/ApiErrorResponse' } } } } + * '401': { description: Authentication required, content: { application/json: { schema: { $ref: '#/components/schemas/ApiErrorResponse' } } } } + * '403': { description: Administrator role required, content: { application/json: { schema: { $ref: '#/components/schemas/ApiErrorResponse' } } } } + */ +const getHandler = async (req: NextRequest) => { + const query = parseEnvironmentLifetimeRecordsQuery(req.nextUrl.searchParams); + const result = await new EnvironmentLifetimeAnalyticsService().getRecords(query); + return withNoStore(successResponse(result, { status: 200 }, req)); +}; + +export const GET = createApiHandler(getHandler, { auth: 'session', roles: ['admin'] }); diff --git a/src/app/api/v2/admin/analytics/lifetimes/route.test.ts b/src/app/api/v2/admin/analytics/lifetimes/route.test.ts new file mode 100644 index 00000000..613d6fa1 --- /dev/null +++ b/src/app/api/v2/admin/analytics/lifetimes/route.test.ts @@ -0,0 +1,122 @@ +import { NextRequest } from 'next/server'; + +const mockIdentity = jest.fn(), + mockGetUser = jest.fn(); +const mockSummary = jest.fn(), + mockRecords = jest.fn(); +jest.mock('server/lib/dependencies', () => ({ defaultDb: {} })); +jest.mock('server/lib/get-user', () => ({ + getRequestUserIdentity: (...args: unknown[]) => mockIdentity(...args), + getUser: (...args: unknown[]) => mockGetUser(...args), +})); +jest.mock('server/services/analytics/EnvironmentLifetimeAnalyticsService', () => ({ + ...jest.requireActual('server/services/analytics/EnvironmentLifetimeAnalyticsService'), + __esModule: true, + default: jest.fn().mockImplementation(() => ({ getSummary: mockSummary, getRecords: mockRecords })), +})); + +import { GET as summary } from './route'; +import { GET as records } from './records/route'; +const handlers = [ + ['lifetimes', summary], + ['lifetime records', records], +] as const; +function request(query = ''): NextRequest { + return { + headers: new Headers([['x-request-id', 'lifetime-test']]), + nextUrl: new URL(`http://localhost/api/v2/admin/analytics/lifetimes${query}`), + } as unknown as NextRequest; +} +function expectNoAccess() { + expect(mockSummary).not.toHaveBeenCalled(); + expect(mockRecords).not.toHaveBeenCalled(); +} + +describe('lifetime analytics administrator boundaries', () => { + const originalAuth = process.env.ENABLE_AUTH; + beforeEach(() => { + jest.clearAllMocks(); + process.env.ENABLE_AUTH = 'true'; + mockIdentity.mockReturnValue({ userId: 'admin', roles: ['admin'] }); + mockGetUser.mockReturnValue({ sub: 'admin', realm_access: { roles: ['admin'] } }); + mockSummary.mockResolvedValue({}); + mockRecords.mockResolvedValue({}); + }); + afterEach(() => { + if (originalAuth === undefined) delete process.env.ENABLE_AUTH; + else process.env.ENABLE_AUTH = originalAuth; + }); + it.each(handlers)('rejects unauthenticated %s before queries', async (_name, handler) => { + mockIdentity.mockReturnValue(null); + expect((await handler(request())).status).toBe(401); + expectNoAccess(); + }); + it.each(handlers)('rejects ordinary users for %s before queries', async (_name, handler) => { + mockIdentity.mockReturnValue({ userId: 'user', roles: ['user'] }); + mockGetUser.mockReturnValue({ sub: 'user', realm_access: { roles: ['user'] } }); + expect((await handler(request())).status).toBe(403); + expectNoAccess(); + }); + it.each(handlers)('rejects API-key credentials for %s', async (_name, handler) => { + const req = request(); + req.headers.set('authorization', 'Bearer lfc_pat_fake'); + expect((await handler(req)).status).toBe(403); + expectNoAccess(); + }); + it.each(handlers)('returns private no-store for administrator %s', async (_name, handler) => { + const response = await handler(request()); + expect(response.status).toBe(200); + expect(response.headers.get('Cache-Control')).toBe('private, no-store'); + }); + it('passes retirement range, timezone and environment scope', async () => { + const response = await summary( + request('?from=2026-03-08&to=2026-03-10&timezone=America%2FLos_Angeles&repositoryId=4&environmentAuthor=Alice') + ); + expect(response.status).toBe(200); + expect(mockSummary).toHaveBeenCalledWith( + expect.objectContaining({ + from: '2026-03-08', + to: '2026-03-10', + timezone: 'America/Los_Angeles', + repositoryId: 4, + environmentAuthor: 'Alice', + compare: true, + }) + ); + }); + it('current age ignores calendar controls while preserving group, bin and scope', async () => { + const response = await records( + request( + '?cohort=current&group=other&bin=invalid&from=invalid&to=invalid&timezone=invalid&environmentType=static&unattributed=true' + ) + ); + expect(response.status).toBe(200); + expect(mockRecords).toHaveBeenCalledWith( + expect.objectContaining({ + cohort: 'current', + group: 'other', + bin: 'invalid', + environmentType: 'static', + unattributed: true, + }) + ); + expect(mockRecords.mock.calls[0][0].from).toBeUndefined(); + }); + it.each([ + '?group=other', + '?bin=episode', + '?page=10001', + '?limit=101', + '?cohort=episode', + '?repositoryId=1&unattributed=true', + '?from=2026-02-30&to=2026-03-02', + ])('rejects invalid lifetime record query %s', async (query) => { + expect((await records(request(query))).status).toBe(400); + expectNoAccess(); + }); + it('isolates summary failures from record requests', async () => { + mockSummary.mockRejectedValueOnce(new Error('Unavailable')); + expect((await summary(request())).status).toBe(500); + expect((await records(request())).status).toBe(200); + }); +}); diff --git a/src/app/api/v2/admin/analytics/lifetimes/route.ts b/src/app/api/v2/admin/analytics/lifetimes/route.ts new file mode 100644 index 00000000..81160819 --- /dev/null +++ b/src/app/api/v2/admin/analytics/lifetimes/route.ts @@ -0,0 +1,56 @@ +/** + * 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 { successResponse, withNoStore } from 'server/lib/response'; +import { parseEnvironmentAnalyticsQuery } from 'server/services/analytics/EnvironmentAnalyticsService'; +import EnvironmentLifetimeAnalyticsService from 'server/services/analytics/EnvironmentLifetimeAnalyticsService'; + +export const runtime = 'nodejs'; + +/** + * @openapi + * /api/v2/admin/analytics/lifetimes: + * get: + * summary: Get recorded API lifetimes, PR estimates and current environment ages + * description: Administrator-only retained-record analytics. API uses createdAt to deletedAt; torn-down PR estimates use createdAt to updatedAt and may include inactive gaps or later edits. Current age is independent of the retirement window. + * tags: [Admin Analytics] + * operationId: getEnvironmentLifetimeAnalytics + * parameters: + * - { in: query, name: repositoryId, schema: { type: integer, minimum: 1 }, description: Stable installation-aware repository row ID. } + * - { in: query, name: unattributed, schema: { type: boolean, default: false }, description: Mutually exclusive with repositoryId. } + * - { in: query, name: organization, schema: { type: string, maxLength: 128 } } + * - { in: query, name: environmentType, schema: { type: string, enum: [all, ephemeral, static], default: all } } + * - { in: query, name: environmentAuthor, schema: { type: string, maxLength: 128 } } + * - { in: query, name: from, schema: { type: string, format: date }, description: Inclusive retirement calendar date. Defaults to 30 complete days before to. } + * - { in: query, name: to, schema: { type: string, format: date }, description: Exclusive retirement calendar date. Defaults to today in timezone. } + * - { in: query, name: timezone, schema: { type: string, default: UTC }, description: IANA timezone for boundaries and buckets. } + * - { in: query, name: compare, schema: { type: boolean, default: true } } + * - { in: query, name: interval, schema: { type: string, enum: [day, week], default: day } } + * responses: + * '200': + * description: Lifetime analytics result. + * content: + * application/json: + * schema: { $ref: '#/components/schemas/GetEnvironmentLifetimeAnalyticsSuccessResponse' } + * '400': { description: Invalid query, content: { application/json: { schema: { $ref: '#/components/schemas/ApiErrorResponse' } } } } + * '401': { description: Authentication required, content: { application/json: { schema: { $ref: '#/components/schemas/ApiErrorResponse' } } } } + * '403': { description: Administrator role required, content: { application/json: { schema: { $ref: '#/components/schemas/ApiErrorResponse' } } } } + */ +const getHandler = async (req: NextRequest) => { + const query = parseEnvironmentAnalyticsQuery(req.nextUrl.searchParams); + const result = await new EnvironmentLifetimeAnalyticsService().getSummary(query); + return withNoStore(successResponse(result, { status: 200 }, req)); +}; + +export const GET = createApiHandler(getHandler, { auth: 'session', roles: ['admin'] }); diff --git a/src/app/api/v2/admin/analytics/services/route.test.ts b/src/app/api/v2/admin/analytics/services/route.test.ts new file mode 100644 index 00000000..709fb997 --- /dev/null +++ b/src/app/api/v2/admin/analytics/services/route.test.ts @@ -0,0 +1,80 @@ +import { NextRequest } from 'next/server'; + +const mockIdentity = jest.fn(), + mockGetUser = jest.fn(), + mockServices = jest.fn(); +jest.mock('server/lib/dependencies', () => ({ defaultDb: {} })); +jest.mock('server/lib/get-user', () => ({ + getRequestUserIdentity: (...args: unknown[]) => mockIdentity(...args), + getUser: (...args: unknown[]) => mockGetUser(...args), +})); +jest.mock('server/services/analytics/EnvironmentAnalyticsService', () => ({ + ...jest.requireActual('server/services/analytics/EnvironmentAnalyticsService'), + __esModule: true, + default: jest.fn().mockImplementation(() => ({ getServices: mockServices })), +})); +import { GET } from './route'; + +const request = (query = '') => + ({ + headers: new Headers([['x-request-id', 'services-test']]), + nextUrl: new URL(`http://localhost/api/v2/admin/analytics/services${query}`), + } as unknown as NextRequest); + +describe('managed Service analytics administrator boundary', () => { + const originalAuth = process.env.ENABLE_AUTH; + beforeEach(() => { + jest.clearAllMocks(); + process.env.ENABLE_AUTH = 'true'; + mockIdentity.mockReturnValue({ userId: 'admin', roles: ['admin'] }); + mockGetUser.mockReturnValue({ sub: 'admin', realm_access: { roles: ['admin'] } }); + mockServices.mockResolvedValue({}); + }); + afterEach(() => { + if (originalAuth === undefined) delete process.env.ENABLE_AUTH; + else process.env.ENABLE_AUTH = originalAuth; + }); + it('rejects anonymous access before querying Services', async () => { + mockIdentity.mockReturnValue(null); + expect((await GET(request())).status).toBe(401); + expect(mockServices).not.toHaveBeenCalled(); + }); + it('rejects ordinary users before querying Services', async () => { + mockIdentity.mockReturnValue({ userId: 'user', roles: ['user'] }); + mockGetUser.mockReturnValue({ sub: 'user', realm_access: { roles: ['user'] } }); + expect((await GET(request())).status).toBe(403); + expect(mockServices).not.toHaveBeenCalled(); + }); + it('rejects API key credentials', async () => { + const req = request(); + req.headers.set('authorization', 'Bearer lfc_pat_fake'); + expect((await GET(req)).status).toBe(403); + expect(mockServices).not.toHaveBeenCalled(); + }); + it('keeps current-only scope, typed pagination, and uncached responses', async () => { + const result = await GET(request('?from=invalid&repositoryId=4&type=helm&environmentType=static&page=2&limit=100')); + expect(result.status).toBe(200); + expect(mockServices).toHaveBeenCalledWith({ + repositoryId: 4, + organization: null, + environmentType: 'static', + environmentAuthor: null, + unattributed: false, + type: 'helm', + page: 2, + limit: 100, + }); + expect(result.headers.get('Cache-Control')).toBe('private, no-store'); + }); + it.each(['?type=codefresh', '?type=externalHTTP', '?limit=101', '?repositoryId=4&unattributed=true'])( + 'rejects invalid scope %s before querying', + async (query) => { + expect((await GET(request(query))).status).toBe(400); + expect(mockServices).not.toHaveBeenCalled(); + } + ); + it('returns a provider error without a successful empty result', async () => { + mockServices.mockRejectedValueOnce(new Error('Query unavailable')); + expect((await GET(request())).status).toBe(500); + }); +}); diff --git a/src/app/api/v2/admin/analytics/services/route.ts b/src/app/api/v2/admin/analytics/services/route.ts new file mode 100644 index 00000000..ddd0c457 --- /dev/null +++ b/src/app/api/v2/admin/analytics/services/route.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 { NextRequest } from 'next/server'; +import { createApiHandler } from 'server/lib/createApiHandler'; +import { successResponse, withNoStore } from 'server/lib/response'; +import EnvironmentAnalyticsService, { + parseManagedServiceRecordsQuery, +} from 'server/services/analytics/EnvironmentAnalyticsService'; + +export const runtime = 'nodejs'; + +/** + * @openapi + * /api/v2/admin/analytics/services: + * get: + * summary: Get current managed Services + * description: Administrator-only paginated configured Services and current instance counts. Historical date controls are ignored. External and build-only units are excluded. + * tags: [Admin Analytics] + * operationId: getManagedServiceRecords + * parameters: + * - { in: query, name: repositoryId, schema: { type: integer, minimum: 1 }, description: Owning environment repository row ID, with installation identity. } + * - { in: query, name: unattributed, schema: { type: boolean, default: false }, description: Mutually exclusive with repositoryId. Unresolved identities remain separate by environment. } + * - { in: query, name: organization, schema: { type: string, maxLength: 128 } } + * - { in: query, name: environmentType, schema: { type: string, enum: [all, ephemeral, static], default: all } } + * - { in: query, name: environmentAuthor, schema: { type: string, maxLength: 128 } } + * - { in: query, name: type, schema: { type: string, enum: [docker, github, helm, aurora-restore] } } + * - { in: query, name: page, schema: { type: integer, minimum: 1, maximum: 1000000, default: 1 } } + * - { in: query, name: limit, schema: { type: integer, minimum: 1, maximum: 100, default: 25 } } + * responses: + * '200': + * description: Current managed Service records. + * content: + * application/json: + * schema: { $ref: '#/components/schemas/GetManagedServiceRecordsSuccessResponse' } + * '400': { description: Invalid query, content: { application/json: { schema: { $ref: '#/components/schemas/ApiErrorResponse' } } } } + * '401': { description: Authentication required, content: { application/json: { schema: { $ref: '#/components/schemas/ApiErrorResponse' } } } } + * '403': { description: Administrator role required, content: { application/json: { schema: { $ref: '#/components/schemas/ApiErrorResponse' } } } } + */ +const getHandler = async (req: NextRequest) => { + const query = parseManagedServiceRecordsQuery(req.nextUrl.searchParams); + const result = await new EnvironmentAnalyticsService().getServices(query); + return withNoStore(successResponse(result, { status: 200 }, req)); +}; + +export const GET = createApiHandler(getHandler, { auth: 'session', roles: ['admin'] }); diff --git a/src/server/lib/v2RoutePolicyManifest.ts b/src/server/lib/v2RoutePolicyManifest.ts index 6694eaa5..f23a33e6 100644 --- a/src/server/lib/v2RoutePolicyManifest.ts +++ b/src/server/lib/v2RoutePolicyManifest.ts @@ -39,7 +39,10 @@ export const V2_ROUTE_POLICY_MANIFEST: readonly V2RoutePolicyEntry[] = [ { method: 'GET', route: '/api/v2/admin/analytics/environments', policy: 'session', roles: ['admin'] }, { method: 'GET', route: '/api/v2/admin/analytics/environments/records', policy: 'session', roles: ['admin'] }, { method: 'GET', route: '/api/v2/admin/analytics/inventory', policy: 'session', roles: ['admin'] }, + { method: 'GET', route: '/api/v2/admin/analytics/lifetimes', policy: 'session', roles: ['admin'] }, + { method: 'GET', route: '/api/v2/admin/analytics/lifetimes/records', policy: 'session', roles: ['admin'] }, { method: 'GET', route: '/api/v2/admin/analytics/options', policy: 'session', roles: ['admin'] }, + { method: 'GET', route: '/api/v2/admin/analytics/services', policy: 'session', roles: ['admin'] }, { method: 'GET', route: '/api/v2/ai/admin/agent/capabilities', policy: 'session', roles: ['admin'] }, { method: 'PUT', route: '/api/v2/ai/admin/agent/capabilities', policy: 'session', roles: ['admin'] }, { method: 'GET', route: '/api/v2/ai/admin/agent/creation-policy', policy: 'session', roles: ['admin'] }, diff --git a/src/server/services/analytics/EnvironmentAnalyticsService.test.ts b/src/server/services/analytics/EnvironmentAnalyticsService.test.ts index 7a47a41e..bc95b4c1 100644 --- a/src/server/services/analytics/EnvironmentAnalyticsService.test.ts +++ b/src/server/services/analytics/EnvironmentAnalyticsService.test.ts @@ -7,6 +7,7 @@ import EnvironmentAnalyticsService, { parseEnvironmentAnalyticsQuery, parseEnvironmentAnalyticsScope, parseEnvironmentAnalyticsRecordsQuery, + parseManagedServiceRecordsQuery, } from './EnvironmentAnalyticsService'; import { resolveAnalyticsRange, type ResolvedAnalyticsRange } from './query'; @@ -94,6 +95,21 @@ describe('EnvironmentAnalyticsService contracts', () => { } ); + it.each(['type=configuration', 'type=codefresh', 'type=externalHTTP', 'limit=101', 'page=0', 'page=1.5'])( + 'rejects invalid managed Service query %s', + (query) => expect(() => parseManagedServiceRecordsQuery(new URLSearchParams(query))).toThrow() + ); + + it('keeps Services current-only with the existing bounded owning-environment scope', () => { + expect(parseManagedServiceRecordsQuery(new URLSearchParams('from=invalid&type=helm&repositoryId=4'))).toEqual({ + ...scope, + repositoryId: 4, + type: 'helm', + page: 1, + limit: 25, + }); + }); + it('current drilldown keeps independent scope and ignores historical dates', () => { const query = parseEnvironmentAnalyticsRecordsQuery( new URLSearchParams('cohort=current&phase=failed&from=invalid&environmentType=static') @@ -259,4 +275,159 @@ describe('EnvironmentAnalyticsService contracts', () => { expect(result.buckets.every((bucket) => bucket.observedPullRequests === null)).toBe(true); expect(result.previousTotals).toBeNull(); }); + + it('adds independent managed Service coverage and per-environment instances without changing readiness', async () => { + const { service, queries } = serviceWithExecutor((query) => { + if (query.sql.startsWith('SET TRANSACTION')) return { rows: [] }; + if (query.sql.includes('CURRENT_TIMESTAMP')) return [{ asOf: range.asOf }]; + if (query.sql.includes(' AS deploys')) return [row(1), row(2, { repositoryId: null })]; + if (query.sql.includes('AS "environmentsWithServices"')) + return [ + { + distinctServices: '1', + instances: '3', + readyInstances: '2', + environmentsWithServices: '2', + unresolvedIdentityInstances: '1', + externalInstances: '1', + buildOnlyInstances: '2', + unknownTypeInstances: '0', + }, + ]; + if (query.sql.includes('group by "s"."environmentId"')) + return [ + { environmentId: 1, type: 'docker', instances: '2' }, + { environmentId: 2, type: 'docker', instances: '1' }, + ]; + return [{ type: 'docker', distinctServices: '1', instances: '3', readyInstances: '2' }]; + }); + const result = await service.getInventory(scope); + expect(result.totals).toMatchObject({ current: 2, ready: 2 }); + expect(result.services).toEqual({ + distinctServices: 1, + instances: 3, + readyInstances: 2, + environmentsWithServices: 2, + unresolvedIdentityInstances: 1, + byType: [ + { type: 'docker', distinctServices: 1, instances: 3, readyInstances: 2 }, + { type: 'github', distinctServices: 0, instances: 0, readyInstances: 0 }, + { type: 'helm', distinctServices: 0, instances: 0, readyInstances: 0 }, + { type: 'aurora-restore', distinctServices: 0, instances: 0, readyInstances: 0 }, + ], + excluded: { externalInstances: 1, buildOnlyInstances: 2, unknownTypeInstances: 0 }, + }); + expect(queries.some((query) => query.sql.includes('group by "s"."environmentId"'))).toBe(false); + }); + + it('SQL-pages stable configured Service groups and converts independent nullable identities', async () => { + const { service, queries } = serviceWithExecutor((query) => { + if (query.sql.startsWith('SET TRANSACTION')) return { rows: [] }; + if (query.sql.includes('CURRENT_TIMESTAMP')) return [{ asOf: range.asOf }]; + if (query.method === 'first') return { total: '30' }; + return [ + { + repositoryId: null, + fullName: null, + githubInstallationId: null, + key: '["environment",12,"github:unrecorded","db","helm"]', + name: 'db', + type: 'helm', + sourceGithubRepositoryId: null, + serviceId: null, + identityResolved: false, + instances: '1', + readyInstances: '0', + environments: '1', + }, + ]; + }); + const result = await service.getServices(parseManagedServiceRecordsQuery(new URLSearchParams('type=helm&page=2'))); + expect(result.pagination).toEqual({ + page: 2, + limit: 25, + total: 30, + hasMore: true, + maxPage: 1_000_000, + truncated: false, + }); + expect(result.records[0]).toMatchObject({ + repositoryId: null, + identityResolved: false, + instances: 1, + readyInstances: 0, + sourceGithubRepositoryId: null, + }); + const pageQuery = queries.find((query) => query.sql.includes('order by "instances"'))!; + expect(pageQuery.sql).toContain('order by "instances" desc, "key" asc limit ? offset ?'); + expect(pageQuery.bindings.slice(-2)).toEqual([25, 25]); + expect(pageQuery.bindings).toContain('helm'); + expect(pageQuery.sql).toContain('COALESCE(s."repositoryId", s."environmentId")'); + expect(pageQuery.sql).not.toContain('"e"."createdAt" >='); + }); + + it.each([ + [1_000_000, 1_000_001, false, true], + [1_000_000, 1_000_000, false, false], + [999_999, 1_000_001, true, true], + ])('reports Services paging bounds at page %i with total %i', async (page, total, hasMore, truncated) => { + const { service, queries } = serviceWithExecutor((query) => { + if (query.sql.startsWith('SET TRANSACTION')) return { rows: [] }; + if (query.sql.includes('CURRENT_TIMESTAMP')) return [{ asOf: range.asOf }]; + if (query.method === 'first') return { total: String(total) }; + return [ + { + repositoryId: 4, + fullName: 'org/repo', + githubInstallationId: 8, + key: '["repository",4,"github:5","api","docker"]', + name: 'api', + type: 'docker', + sourceGithubRepositoryId: 5, + serviceId: null, + identityResolved: true, + instances: '1', + readyInstances: '1', + environments: '1', + }, + ]; + }); + const result = await service.getServices( + parseManagedServiceRecordsQuery(new URLSearchParams(`page=${page}&limit=1`)) + ); + expect(result.pagination).toEqual({ page, limit: 1, total, hasMore, maxPage: 1_000_000, truncated }); + const pageQuery = queries.find((query) => query.sql.includes('order by "instances"'))!; + expect(pageQuery.bindings.slice(-2)).toEqual([1, page - 1]); + }); + + it('loads service counts only for the current environment page and leaves historical counts unknown', async () => { + const { service, queries } = serviceWithExecutor((query) => { + if (query.sql.startsWith('SET TRANSACTION')) return { rows: [] }; + if (query.sql.includes('CURRENT_TIMESTAMP')) return [{ asOf: range.asOf }]; + if (query.method === 'first') return { total: '1' }; + if (query.sql.includes('service_deployables')) return [{ environmentId: 26, type: 'helm', instances: '2' }]; + return [row(26)]; + }); + const current = await service.getRecords( + parseEnvironmentAnalyticsRecordsQuery(new URLSearchParams('cohort=current')) + ); + expect(current.records[0]).toMatchObject({ serviceInstances: 2, serviceTypes: [{ type: 'helm', instances: 2 }] }); + const serviceQuery = queries.find((query) => query.sql.includes('service_deployables'))!; + expect(serviceQuery.bindings).toContain(26); + expect(serviceQuery.sql).toContain('a."buildUUID" = e.uuid'); + expect(serviceQuery.sql).toContain('a."deletedAt" IS NULL'); + expect(serviceQuery.sql).toContain('d."deletedAt" IS NULL'); + expect(serviceQuery.sql).toContain('PARTITION BY a."buildId", a.name ORDER BY a.id DESC'); + expect(serviceQuery.sql).toContain('PARTITION BY d."buildId", d."deployableId" ORDER BY d.id DESC'); + expect(serviceQuery.bindings).toEqual( + expect.arrayContaining(['torn_down', 'docker', 'github', 'helm', 'aurora-restore']) + ); + expect(serviceQuery.bindings).not.toEqual(expect.arrayContaining(['configuration', 'codefresh', 'externalHTTP'])); + queries.length = 0; + const historical = await service.getRecords( + parseEnvironmentAnalyticsRecordsQuery(new URLSearchParams('from=2026-09-30&to=2026-10-02')) + ); + expect(historical.records[0]).toMatchObject({ serviceInstances: null, serviceTypes: null }); + expect(queries.some((query) => query.sql.includes('service_deployables'))).toBe(false); + }); }); diff --git a/src/server/services/analytics/EnvironmentAnalyticsService.ts b/src/server/services/analytics/EnvironmentAnalyticsService.ts index 8fb05a78..01e1899c 100644 --- a/src/server/services/analytics/EnvironmentAnalyticsService.ts +++ b/src/server/services/analytics/EnvironmentAnalyticsService.ts @@ -16,7 +16,7 @@ import type Build from 'server/models/Build'; import { defaultDb } from 'server/lib/dependencies'; import { BadRequestError } from 'server/lib/appError'; import { getEnvironmentPhase, type EnvironmentPhase, type ReadinessDeploy } from 'server/lib/environments/readiness'; -import { BuildKind, BuildStatus } from 'shared/constants'; +import { BuildKind, BuildStatus, DeployStatus, DeployTypes } from 'shared/constants'; import { analyticsBucketDates, analyticsBucketExpression, @@ -102,6 +102,54 @@ export type InventoryTotals = { ambiguous: number; }; export type InventoryRepository = AnalyticsRepositoryIdentity & InventoryTotals; +export type ManagedServiceType = + | DeployTypes.DOCKER + | DeployTypes.GITHUB + | DeployTypes.HELM + | DeployTypes.AURORA_RESTORE; +export type ManagedServiceTypeCounts = { + type: ManagedServiceType; + distinctServices: number; + instances: number; + readyInstances: number; +}; +export type ManagedServiceInventory = { + distinctServices: number; + instances: number; + readyInstances: number; + environmentsWithServices: number; + unresolvedIdentityInstances: number; + byType: ManagedServiceTypeCounts[]; + excluded: { externalInstances: number; buildOnlyInstances: number; unknownTypeInstances: number }; +}; +export type ManagedServiceRecord = AnalyticsRepositoryIdentity & { + key: string; + name: string; + type: ManagedServiceType; + sourceGithubRepositoryId: number | null; + serviceId: number | null; + identityResolved: boolean; + instances: number; + readyInstances: number; + environments: number; +}; +export type ManagedServiceRecordsQuery = EnvironmentAnalyticsScope & { + type: ManagedServiceType | null; + page: number; + limit: number; +}; +export type ManagedServiceRecords = { + asOf: string; + scope: EnvironmentAnalyticsScope; + type: ManagedServiceType | null; + records: ManagedServiceRecord[]; + pagination: { page: number; limit: number; total: number; hasMore: boolean; maxPage: number; truncated: boolean }; + caveats: string[]; +}; +type EnvironmentServiceCounts = { + instances: number; + byType: Array<{ type: ManagedServiceType; instances: number }>; +}; export type EnvironmentAnalyticsRecord = AnalyticsRepositoryIdentity & { id: number; uuid: string | null; @@ -117,12 +165,15 @@ export type EnvironmentAnalyticsRecord = AnalyticsRepositoryIdentity & { deletedAt: string | null; resourceAvailable: boolean; repositoryAmbiguous: boolean; + serviceInstances: number | null; + serviceTypes: EnvironmentServiceCounts['byType'] | null; pullRequest: { number: number | null; title: string | null; author: string | null } | null; }; export type InventoryAnalytics = { asOf: string; scope: EnvironmentAnalyticsScope; totals: InventoryTotals; + services: ManagedServiceInventory; repositories: InventoryRepository[]; exceptions: EnvironmentAnalyticsRecord[]; truncated: boolean; @@ -178,6 +229,19 @@ type CountRow = AnalyticsRepositoryIdentity & { const BATCH_SIZE = 1000; const RANKING_LIMIT = 100; const EXCEPTION_LIMIT = 20; +const MANAGED_SERVICE_MAX_PAGE = 1_000_000; +const MANAGED_SERVICE_TYPES: ManagedServiceType[] = [ + DeployTypes.DOCKER, + DeployTypes.GITHUB, + DeployTypes.HELM, + DeployTypes.AURORA_RESTORE, +]; +const SERVICE_CAVEATS = [ + 'Service counts are current selected runtime units, including queued and failed instances, not deployment events or live resources.', + 'Unique configured Services are grouped by owning repository, source or database template, name, and type; shared apps in different owning repositories remain separate.', + 'Instances with unresolved owning repository identity are excluded from unique Service counts and remain separate in drilldown.', + 'External connections, configuration units, and Codefresh build-only units are excluded from managed Service counts.', +]; const CURRENT_PHASES: EnvironmentPhase[] = [ 'ready', 'deployed_not_ready', @@ -211,6 +275,15 @@ const emptyInventory = (): InventoryTotals => ({ unattributed: 0, ambiguous: 0, }); +const emptyServiceInventory = (): ManagedServiceInventory => ({ + distinctServices: 0, + instances: 0, + readyInstances: 0, + environmentsWithServices: 0, + unresolvedIdentityInstances: 0, + byType: MANAGED_SERVICE_TYPES.map((type) => ({ type, distinctServices: 0, instances: 0, readyInstances: 0 })), + excluded: { externalInstances: 0, buildOnlyInstances: 0, unknownTypeInstances: 0 }, +}); function optionalText(params: URLSearchParams, name: string, maximum = 128): string | null { const value = params.get(name); @@ -282,7 +355,25 @@ export function parseEnvironmentAnalyticsRecordsQuery(params: URLSearchParams): return { ...query, cohort, phase, page, limit }; } -function environmentBase(knex: Knex, scope: EnvironmentAnalyticsScope): Knex.QueryBuilder { +export function parseManagedServiceRecordsQuery(params: URLSearchParams): ManagedServiceRecordsQuery { + const type = params.get('type') as ManagedServiceType | null; + if (type !== null && !MANAGED_SERVICE_TYPES.includes(type)) + throw new BadRequestError('type must be a managed runtime Service type.', 'invalid_query'); + const page = Number(params.get('page') ?? 1), + limit = Number(params.get('limit') ?? 25); + if ( + !Number.isSafeInteger(page) || + page < 1 || + page > MANAGED_SERVICE_MAX_PAGE || + !Number.isSafeInteger(limit) || + limit < 1 || + limit > 100 + ) + throw new BadRequestError('page must be positive and limit must be between 1 and 100.', 'invalid_query'); + return { ...parseEnvironmentAnalyticsScope(params), type, page, limit }; +} + +export function environmentBase(knex: Knex, scope: EnvironmentAnalyticsScope): Knex.QueryBuilder { const repositories = knex('repositories') .select('githubRepositoryId') .count('* as matches') @@ -292,7 +383,7 @@ function environmentBase(knex: Knex, scope: EnvironmentAnalyticsScope): Knex.Que const base = knex.raw( ` SELECT b.id, b.uuid, b.namespace, b.status, b."isStatic", b."triggerType", b."createdAt", b."updatedAt", - b."deletedAt", b."expiresAt", b."deployEnabled", b."pullRequestId", p."deployOnUpdate" AS "prDeployOnUpdate", + b."deletedAt", b."expiresAt", b."deployEnabled", b."pullRequestId", (p.id IS NOT NULL) AS "hasPullRequest", p."deployOnUpdate" AS "prDeployOnUpdate", p."githubLogin" AS "prAuthor", p."pullRequestNumber", p.title AS "prTitle", COALESCE(b."createdByGithubLogin", p."githubLogin") AS author, CASE WHEN p.id IS NOT NULL THEN p."repositoryId" WHEN rc.matches = 1 THEN rc."repositoryId" END AS "repositoryId", @@ -342,10 +433,57 @@ function projection(knex: Knex, scope: EnvironmentAnalyticsScope): Knex.QueryBui ); } -function currentEnvironmentQuery(query: Knex.QueryBuilder): Knex.QueryBuilder { +export function currentEnvironmentQuery(query: Knex.QueryBuilder): Knex.QueryBuilder { return query.whereNull('e.deletedAt').whereRaw('(e.status IS NULL OR e.status <> ?)', [BuildStatus.TORN_DOWN]); } +function serviceInstances(knex: Knex, scope: EnvironmentAnalyticsScope, environmentIds?: number[]): Knex.QueryBuilder { + const environments = currentEnvironmentQuery(environmentBase(knex, scope)).select( + 'e.id as environmentId', + 'e.uuid', + 'e.repositoryId', + 'r.fullName', + 'r.githubInstallationId' + ); + if (environmentIds) environments.whereIn('e.id', environmentIds); + const deployables = knex.raw(` + SELECT a.id, a.name, a.type, a.active, a."serviceId", e.*, + COALESCE(NULLIF(a."resolvedFromRepositoryId", 0)::bigint, + CASE WHEN btrim(a."repositoryId") ~ '^[0-9]{1,10}$' THEN NULLIF(btrim(a."repositoryId")::bigint, 0) END) + AS "sourceGithubRepositoryId", + row_number() OVER (PARTITION BY a."buildId", a.name ORDER BY a.id DESC) AS ordinal + FROM deployables a JOIN service_environments e ON a."buildId" = e."environmentId" AND a."buildUUID" = e.uuid + WHERE a."deletedAt" IS NULL AND NULLIF(btrim(a.name), '') IS NOT NULL + `); + const deploys = knex.raw(` + SELECT d."deployableId", d.status, + row_number() OVER (PARTITION BY d."buildId", d."deployableId" ORDER BY d.id DESC) AS ordinal + FROM deploys d JOIN service_deployables a ON a.id = d."deployableId" AND a."environmentId" = d."buildId" + WHERE a.ordinal = 1 AND a.active = true AND d.active = true AND d."deletedAt" IS NULL + `); + return knex + .with('service_environments', environments) + .with('service_deployables', deployables) + .with('service_deploys', deploys) + .from('service_deployables as s') + .join('service_deploys as d', 'd.deployableId', 's.id') + .where('s.ordinal', 1) + .where('d.ordinal', 1) + .where('s.active', true) + .whereRaw('(d.status IS NULL OR d.status <> ?)', [DeployStatus.TORN_DOWN]); +} + +function managedInstances(knex: Knex, scope: EnvironmentAnalyticsScope, environmentIds?: number[]): Knex.QueryBuilder { + return serviceInstances(knex, scope, environmentIds).whereIn('s.type', MANAGED_SERVICE_TYPES); +} + +const serviceKeySql = `jsonb_build_array( + CASE WHEN s."repositoryId" IS NULL THEN 'environment' ELSE 'repository' END, + COALESCE(s."repositoryId", s."environmentId"), + CASE WHEN s."serviceId" IS NOT NULL THEN 'db:' || s."serviceId"::text + ELSE 'github:' || COALESCE(s."sourceGithubRepositoryId"::text, 'unrecorded') END, + s.name, s.type)::text`; + export function analyticsEnvironmentRecord(row: Projection): EnvironmentAnalyticsRecord { const build = { status: row.status, @@ -368,6 +506,8 @@ export function analyticsEnvironmentRecord(row: Projection): EnvironmentAnalytic deletedAt: timestamp(row.deletedAt), resourceAvailable: Boolean(row.uuid) && row.deletedAt == null && row.status !== BuildStatus.TORN_DOWN, repositoryAmbiguous: row.repositoryAmbiguous === true, + serviceInstances: null, + serviceTypes: null, pullRequest: row.pullRequestId == null ? null : { number: row.pullRequestNumber, title: row.prTitle, author: row.prAuthor }, }; @@ -450,6 +590,101 @@ export default class EnvironmentAnalyticsService { return { totals, repositories, exceptions, exceptionTotal }; } + private async addServiceCounts( + knex: Knex, + scope: EnvironmentAnalyticsScope, + rows: EnvironmentAnalyticsRecord[] + ): Promise { + if (!rows.length) return; + const counts = (await managedInstances( + knex, + scope, + rows.map((row) => Number(row.id)) + ) + .select('s.environmentId', 's.type') + .count('* as instances') + .groupBy('s.environmentId', 's.type')) as Array<{ + environmentId: number; + type: ManagedServiceType; + instances: string; + }>; + const byEnvironment = new Map(); + for (const row of counts) { + const current = byEnvironment.get(Number(row.environmentId)) ?? { instances: 0, byType: [] }; + current.instances += count(row.instances); + current.byType.push({ type: row.type, instances: count(row.instances) }); + byEnvironment.set(Number(row.environmentId), current); + } + for (const row of rows) { + const counts = byEnvironment.get(Number(row.id)) ?? { instances: 0, byType: [] }; + counts.byType.sort((a, b) => MANAGED_SERVICE_TYPES.indexOf(a.type) - MANAGED_SERVICE_TYPES.indexOf(b.type)); + row.serviceInstances = counts.instances; + row.serviceTypes = counts.byType; + } + } + + private async serviceInventory(knex: Knex, scope: EnvironmentAnalyticsScope): Promise { + const result = emptyServiceInventory(); + const [totals] = await serviceInstances(knex, scope).select( + knex.raw( + ` + count(*) FILTER (WHERE s.type = ANY(?::text[])) AS instances, + count(DISTINCT ${serviceKeySql}) FILTER (WHERE s.type = ANY(?::text[]) AND s."repositoryId" IS NOT NULL) AS "distinctServices", + count(*) FILTER (WHERE s.type = ANY(?::text[]) AND d.status = ?) AS "readyInstances", + count(DISTINCT s."environmentId") FILTER (WHERE s.type = ANY(?::text[])) AS "environmentsWithServices", + count(*) FILTER (WHERE s.type = ANY(?::text[]) AND s."repositoryId" IS NULL) AS "unresolvedIdentityInstances", + count(*) FILTER (WHERE s.type = ?) AS "externalInstances", + count(*) FILTER (WHERE s.type = ANY(?::text[])) AS "buildOnlyInstances", + count(*) FILTER (WHERE s.type IS NULL OR s.type <> ALL(?::text[])) AS "unknownTypeInstances" + `, + [ + MANAGED_SERVICE_TYPES, + MANAGED_SERVICE_TYPES, + MANAGED_SERVICE_TYPES, + DeployStatus.READY, + MANAGED_SERVICE_TYPES, + MANAGED_SERVICE_TYPES, + DeployTypes.EXTERNAL_HTTP, + [DeployTypes.CONFIGURATION, DeployTypes.CODEFRESH], + [...MANAGED_SERVICE_TYPES, DeployTypes.EXTERNAL_HTTP, DeployTypes.CONFIGURATION, DeployTypes.CODEFRESH], + ] + ) + ); + const types = (await managedInstances(knex, scope) + .select('s.type') + .count('* as instances') + .select( + knex.raw( + `count(DISTINCT ${serviceKeySql}) FILTER (WHERE s."repositoryId" IS NOT NULL) AS "distinctServices", + count(*) FILTER (WHERE d.status = ?) AS "readyInstances"`, + [DeployStatus.READY] + ) + ) + .groupBy('s.type')) as Array; + for (const field of [ + 'distinctServices', + 'instances', + 'readyInstances', + 'environmentsWithServices', + 'unresolvedIdentityInstances', + ] as const) + result[field] = count(totals?.[field]); + for (const field of ['externalInstances', 'buildOnlyInstances', 'unknownTypeInstances'] as const) + result.excluded[field] = count(totals?.[field]); + result.byType = result.byType.map((empty) => { + const row = types.find((type) => type.type === empty.type); + return row + ? { + type: empty.type, + distinctServices: count(row.distinctServices), + instances: count(row.instances), + readyInstances: count(row.readyInstances), + } + : empty; + }); + return result; + } + private async period(knex: Knex, query: EnvironmentAnalyticsQuery, range: ResolvedAnalyticsRange, prior: boolean) { const period = prior ? range.previous! : range; const environments = environmentBase(knex, query) @@ -609,6 +844,8 @@ export default class EnvironmentAnalyticsService { return this.transaction(async (knex) => { const [asOf] = await knex.select(knex.raw('CURRENT_TIMESTAMP AS "asOf"')); const result = await this.inventory(knex, scope); + await this.addServiceCounts(knex, scope, result.exceptions); + const services = await this.serviceInventory(knex, scope); const repositories = [...result.repositories.values()].sort( (a, b) => b.current - a.current || (a.fullName ?? '\uffff').localeCompare(b.fullName ?? '\uffff') ); @@ -616,6 +853,7 @@ export default class EnvironmentAnalyticsService { asOf: timestamp(asOf.asOf)!, scope: this.scope(scope), totals: result.totals, + services, repositories: repositories.slice(0, RANKING_LIMIT), exceptions: result.exceptions, truncated: result.exceptionTotal > EXCEPTION_LIMIT, @@ -624,6 +862,66 @@ export default class EnvironmentAnalyticsService { caveats: [ ...CAVEATS, 'Current inventory ignores historical date controls. Readiness reflects recorded state, not a live Kubernetes probe.', + ...SERVICE_CAVEATS, + ], + }; + }); + } + + async getServices(query: ManagedServiceRecordsQuery): Promise { + return this.transaction(async (knex) => { + const [asOf] = await knex.select(knex.raw('CURRENT_TIMESTAMP AS "asOf"')); + const grouped = managedInstances(knex, query) + .select('s.repositoryId', 's.fullName', 's.githubInstallationId', 's.name', 's.type') + .select( + knex.raw( + `${serviceKeySql} AS key, + CASE WHEN count(DISTINCT s."sourceGithubRepositoryId") = 1 THEN min(s."sourceGithubRepositoryId") END AS "sourceGithubRepositoryId", + min(s."serviceId") AS "serviceId", (s."repositoryId" IS NOT NULL) AS "identityResolved", + count(*) AS instances, count(*) FILTER (WHERE d.status = ?) AS "readyInstances", + count(DISTINCT s."environmentId") AS environments`, + [DeployStatus.READY] + ) + ) + .groupBy('s.repositoryId', 's.fullName', 's.githubInstallationId', 's.name', 's.type') + .groupByRaw(serviceKeySql); + if (query.type) grouped.where('s.type', query.type); + const totalRow = await knex.from(grouped.clone().as('services')).count('* as total').first(); + const offset = (query.page - 1) * query.limit; + const rows = (await grouped + .orderBy('instances', 'desc') + .orderBy('key') + .offset(offset) + .limit(query.limit)) as ManagedServiceRecord[]; + const records = rows.map((row) => ({ + ...identity(row), + key: row.key, + name: row.name, + type: row.type, + sourceGithubRepositoryId: row.sourceGithubRepositoryId == null ? null : Number(row.sourceGithubRepositoryId), + serviceId: row.serviceId == null ? null : Number(row.serviceId), + identityResolved: row.identityResolved === true, + instances: count(row.instances), + readyInstances: count(row.readyInstances), + environments: count(row.environments), + })); + const total = count(totalRow?.total); + return { + asOf: timestamp(asOf.asOf)!, + scope: this.scope(query), + type: query.type, + records, + pagination: { + page: query.page, + limit: query.limit, + total, + hasMore: query.page < MANAGED_SERVICE_MAX_PAGE && offset + records.length < total, + maxPage: MANAGED_SERVICE_MAX_PAGE, + truncated: total > MANAGED_SERVICE_MAX_PAGE * query.limit, + }, + caveats: [ + 'Current Services ignore historical date controls. Ready counts reflect recorded READY state.', + ...SERVICE_CAVEATS, ], }; }); @@ -713,6 +1011,7 @@ export default class EnvironmentAnalyticsService { .limit(query.limit)) as Projection[]; records.push(...rows.map(analyticsEnvironmentRecord)); } + if (query.cohort === 'current') await this.addServiceCounts(knex, query, records); return { asOf, scope: this.scope(query), diff --git a/src/server/services/analytics/EnvironmentLifetimeAnalyticsService.test.ts b/src/server/services/analytics/EnvironmentLifetimeAnalyticsService.test.ts new file mode 100644 index 00000000..c95b9f64 --- /dev/null +++ b/src/server/services/analytics/EnvironmentLifetimeAnalyticsService.test.ts @@ -0,0 +1,285 @@ +/** + * 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. + */ + +jest.mock('server/lib/dependencies', () => ({ defaultDb: {} })); +const mockResolveRange = jest.fn(); +jest.mock('./query', () => ({ + ...jest.requireActual('./query'), + resolveAnalyticsRange: (...args: unknown[]) => mockResolveRange(...args), +})); + +import knexFactory from 'knex'; +import EnvironmentLifetimeAnalyticsService, { + ENVIRONMENT_LIFETIME_BINS, + parseEnvironmentLifetimeRecordsQuery, + serializeEnvironmentLifetimeStats, +} from './EnvironmentLifetimeAnalyticsService'; +import { parseEnvironmentAnalyticsQuery } from './EnvironmentAnalyticsService'; +import type { ResolvedAnalyticsRange } from './query'; + +const range: ResolvedAnalyticsRange = { + from: '2026-03-08', + to: '2026-03-10', + timezone: 'America/Los_Angeles', + fromUtc: '2026-03-08T08:00:00.000Z', + toUtc: '2026-03-10T07:00:00.000Z', + dates: ['2026-03-08', '2026-03-09'], + calendarDays: 2, + interval: 'day', + compare: true, + asOf: '2026-03-10T08:00:00.000Z', + previous: { + from: '2026-03-06', + to: '2026-03-08', + fromUtc: '2026-03-06T08:00:00.000Z', + toUtc: '2026-03-08T08:00:00.000Z', + dates: ['2026-03-06', '2026-03-07'], + }, +}; +const compiler = knexFactory({ client: 'pg' }); +function serviceWithRows(results: unknown[][]) { + const raw = jest.fn((sql: string, bindings?: unknown[]) => { + if (sql.startsWith('SET TRANSACTION')) return Promise.resolve(); + if (sql.includes('lifetime_source AS')) return Promise.resolve({ rows: results.shift() }); + if (sql === 'SELECT CURRENT_TIMESTAMP AS "asOf"') return Promise.resolve({ rows: [{ asOf: range.asOf }] }); + return compiler.raw(sql, bindings as any); + }); + const trx = Object.assign((table: string) => compiler(table), { raw, with: compiler.with.bind(compiler) }); + const transaction = jest.fn(async (callback) => callback(trx)); + return { service: new EnvironmentLifetimeAnalyticsService({ knex: { transaction } } as any), raw }; +} +const params = (query = '') => new URLSearchParams(query); + +describe('EnvironmentLifetimeAnalyticsService', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockResolveRange.mockResolvedValue(range); + }); + afterAll(async () => { + await compiler.destroy(); + }); + + it('keeps empty and missing duration statistics unavailable, and a recorded zero measurable', () => { + expect(serializeEnvironmentLifetimeStats('api', 'completed')).toMatchObject({ + eligible: 0, + samples: 0, + meanHours: null, + medianHours: null, + p90Hours: null, + method: 'created_to_deleted', + quality: 'recorded', + }); + expect(serializeEnvironmentLifetimeStats('pr', 'completed', { eligible: 2, missing: 2 })).toMatchObject({ + eligible: 2, + samples: 0, + meanHours: null, + method: 'created_to_last_update', + quality: 'estimated', + }); + expect( + serializeEnvironmentLifetimeStats('all', 'current', { + eligible: '1', + samples: '1', + mean_hours: '0', + median_hours: 0, + p90_hours: 0, + under_1h: 1, + }) + ).toMatchObject({ meanHours: 0, medianHours: 0, p90Hours: 0, quality: 'record_age' }); + }); + it('does not emit nonfinite or unsafe numeric JSON', () => { + expect( + serializeEnvironmentLifetimeStats('api', 'completed', { + samples: 1, + mean_hours: 'Infinity', + median_hours: 'NaN', + p90_hours: -1, + }) + ).toMatchObject({ meanHours: null, medianHours: null, p90Hours: null }); + expect(() => serializeEnvironmentLifetimeStats('api', 'completed', { eligible: '9007199254740992' })).toThrow(); + }); + it('defines contiguous half-open bins with an uncapped final bucket', () => { + expect(ENVIRONMENT_LIFETIME_BINS[0].fromHours).toBe(0); + ENVIRONMENT_LIFETIME_BINS.slice(1).forEach((bin, index) => { + expect(bin.fromHours).toBe(ENVIRONMENT_LIFETIME_BINS[index].toHours); + }); + expect(ENVIRONMENT_LIFETIME_BINS.at(-1)?.toHours).toBeNull(); + }); + it('fills sparse dates, separates methods and preserves previous DST alignment', async () => { + const { service, raw } = serviceWithRows([ + [ + { period: 'current', group_name: 'api', bucket: null, eligible: 2, samples: 1, missing: 1, mean_hours: 1 }, + { period: 'current', group_name: 'pr', bucket: null, eligible: 1, samples: 1, mean_hours: 10 }, + { + period: 'current', + group_name: 'api', + bucket: '2026-03-09', + eligible: 2, + samples: 1, + missing: 1, + mean_hours: 1, + }, + { period: 'previous', group_name: 'api', bucket: '2026-03-08', eligible: 1, samples: 1, mean_hours: 6 }, + ], + [{ group_name: 'all', eligible: 3, samples: 2, invalid: 1, mean_hours: 20 }], + [ + { group_name: 'api', missing_end: 2, invalid_end: 1 }, + { group_name: 'other', total: 4 }, + ], + ]); + const result = await service.getSummary( + parseEnvironmentAnalyticsQuery(params('repositoryId=4&organization=Org&environmentAuthor=Alice')) + ); + expect(result.completed.api).toMatchObject({ eligible: 2, samples: 1, missing: 1, meanHours: 1 }); + expect(result.completed.pr).toMatchObject({ meanHours: 10, quality: 'estimated' }); + expect(result.buckets[0]).toMatchObject({ + date: '2026-03-08', + previousDate: '2026-03-06', + current: { api: { samples: 0, meanHours: null } }, + previous: { api: { samples: 1, meanHours: 6 } }, + }); + expect(result.currentAge.all).toMatchObject({ eligible: 3, samples: 2, invalid: 1, quality: 'record_age' }); + expect(result.unplacedRetirements).toEqual({ + api: { missingEnd: 2, invalidEnd: 1 }, + pr: { missingEnd: 0, invalidEnd: 0 }, + other: 4, + }); + const completedCall = raw.mock.calls.find(([sql]) => sql.includes('period_facts AS'))!; + expect(completedCall[0]).toContain('rc.matches = 1'); + expect(completedCall[0]).toContain('WHEN "hasPullRequest" THEN'); + expect(completedCall[0]).toContain('percentile_cont(0.9)'); + expect(completedCall[0]).toContain('NOT isfinite(start_at)'); + expect(completedCall[0]).toContain('end_at < start_at'); + expect(completedCall[1]).toEqual(expect.arrayContaining([4, 'Org', 'Alice', range.previous!.fromUtc, range.toUtc])); + const currentCall = raw.mock.calls.find(([sql]) => sql.includes("COALESCE(group_name, 'all')"))!; + expect(currentCall[0]).toContain('"e"."deletedAt" is null'); + expect(currentCall[1]).not.toContain(range.fromUtc); + expect(currentCall[1]).not.toContain(range.toUtc); + expect(raw.mock.calls[0][0]).toBe('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY'); + }); + it('keeps weekly prior display dates within the supported AD domain', async () => { + mockResolveRange.mockResolvedValue({ + ...range, + from: '0001-01-02', + to: '0001-01-03', + dates: ['0001-01-02'], + calendarDays: 1, + interval: 'week', + previous: { ...range.previous, from: '0001-01-01', to: '0001-01-02', dates: ['0001-01-01'] }, + }); + const { service } = serviceWithRows([[], [], []]); + const result = await service.getSummary( + parseEnvironmentAnalyticsQuery(params('from=0001-01-02&to=0001-01-03&interval=week')) + ); + expect(result.buckets[0].date).toBe('0001-01-01'); + expect(result.buckets[0].previousDate).toBeNull(); + expect(result.buckets[0].previous).not.toBeNull(); + }); + it('omits previous values when comparison is disabled', async () => { + mockResolveRange.mockResolvedValue({ ...range, previous: null, compare: false }); + const { service } = serviceWithRows([[], [], []]); + const result = await service.getSummary(parseEnvironmentAnalyticsQuery(params('compare=false'))); + expect(result.previousCompleted).toBeNull(); + expect(result.buckets.every((row) => row.previous === null && row.previousDate === null)).toBe(true); + }); + it('current records ignore malformed calendar controls and return first-record age', async () => { + const { service, raw } = serviceWithRows([ + [ + { + total: '1', + records: [ + { + id: 1, + group: 'other', + durationHours: 0, + startedAt: range.asOf, + measuredUntilAt: range.asOf, + repositoryId: null, + githubInstallationId: null, + sampleState: 'valid', + }, + ], + }, + ], + ]); + const query = parseEnvironmentLifetimeRecordsQuery( + params('cohort=current&group=other&from=invalid&timezone=invalid&interval=invalid&compare=invalid') + ); + const result = await service.getRecords(query); + expect(mockResolveRange).not.toHaveBeenCalled(); + expect(result.range).toBeNull(); + expect(result.records[0]).toMatchObject({ method: 'first_record_age', quality: 'record_age', durationHours: 0 }); + expect(raw.mock.calls.some(([sql]) => sql.includes('period_facts'))).toBe(false); + }); + it('records use exactly the retirement window and half-open histogram boundaries', async () => { + const { service, raw } = serviceWithRows([[{ total: 2, records: [] }]]); + const result = await service.getRecords( + parseEnvironmentLifetimeRecordsQuery(params('cohort=completed&group=pr&bin=1_to_6h&page=2&limit=1')) + ); + const call = raw.mock.calls.find(([sql]) => sql.includes('selected AS MATERIALIZED'))!; + expect(call[0]).toContain('end_at >= ?::timestamptz AND end_at < ?::timestamptz'); + expect(call[0]).toContain('duration_hours >= ? AND duration_hours < ?'); + expect(call[1]).toEqual(expect.arrayContaining([range.fromUtc, range.toUtc, 'pr', 1, 6])); + expect(result.pagination).toMatchObject({ + page: 2, + limit: 1, + total: 2, + hasMore: false, + maxPage: 10000, + truncated: false, + }); + }); + it('stops at the supported page bound while reporting uncapped totals', async () => { + const { service } = serviceWithRows([[{ total: 1000001, records: [] }]]); + const result = await service.getRecords(parseEnvironmentLifetimeRecordsQuery(params('page=10000&limit=100'))); + expect(result.pagination).toEqual({ + page: 10000, + limit: 100, + total: 1000001, + hasMore: false, + maxPage: 10000, + truncated: true, + }); + }); + it('accepts current coverage bins and completed all without merging summary methods', () => { + expect(parseEnvironmentLifetimeRecordsQuery(params('cohort=current&bin=invalid&group=all'))).toMatchObject({ + cohort: 'current', + bin: 'invalid', + group: 'all', + }); + expect(parseEnvironmentLifetimeRecordsQuery(params())).toMatchObject({ + cohort: 'completed', + group: 'all', + compare: true, + page: 1, + limit: 25, + }); + }); + it.each([ + 'cohort=episode', + 'group=other', + 'group=github', + 'bin=unknown', + 'page=0', + 'page=10001', + 'page=1.5', + 'page=1e2', + 'limit=101', + 'limit=0', + 'repositoryId=1&unattributed=true', + 'environmentType=workspace', + 'from=2026-02-30&to=2026-03-02', + 'from=2025-01-01&to=2026-01-02', + ])('rejects invalid inputs %s', (query) => { + expect(() => parseEnvironmentLifetimeRecordsQuery(params(query))).toThrow(); + }); +}); diff --git a/src/server/services/analytics/EnvironmentLifetimeAnalyticsService.ts b/src/server/services/analytics/EnvironmentLifetimeAnalyticsService.ts new file mode 100644 index 00000000..579a514e --- /dev/null +++ b/src/server/services/analytics/EnvironmentLifetimeAnalyticsService.ts @@ -0,0 +1,457 @@ +/** + * 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 type { Knex } from 'knex'; +import type Database from 'server/database'; +import { defaultDb } from 'server/lib/dependencies'; +import { BadRequestError } from 'server/lib/appError'; +import { BuildStatus } from 'shared/constants'; +import { + environmentBase, + currentEnvironmentQuery, + parseEnvironmentAnalyticsQuery, + parseEnvironmentAnalyticsScope, + type AnalyticsRepositoryIdentity, + type EnvironmentAnalyticsQuery, + type EnvironmentAnalyticsScope, +} from './EnvironmentAnalyticsService'; +import { analyticsBucketDates, resolveAnalyticsRange, type ResolvedAnalyticsRange } from './query'; + +export const ENVIRONMENT_LIFETIME_BINS = [ + { id: 'under_1h', label: 'Under 1 hour', fromHours: 0, toHours: 1 }, + { id: '1_to_6h', label: '1–6 hours', fromHours: 1, toHours: 6 }, + { id: '6_to_24h', label: '6–24 hours', fromHours: 6, toHours: 24 }, + { id: '1_to_3d', label: '1–3 days', fromHours: 24, toHours: 72 }, + { id: '3_to_7d', label: '3–7 days', fromHours: 72, toHours: 168 }, + { id: '7_to_30d', label: '7–30 days', fromHours: 168, toHours: 720 }, + { id: '30d_plus', label: '30 days or more', fromHours: 720, toHours: null }, +] as const; + +export type EnvironmentLifetimeGroup = 'api' | 'pr' | 'other'; +export type EnvironmentLifetimeMethod = 'created_to_deleted' | 'created_to_last_update' | 'first_record_age'; +export type EnvironmentLifetimeQuality = 'recorded' | 'estimated' | 'record_age'; +export type EnvironmentLifetimeSampleState = 'valid' | 'missing' | 'invalid'; +export type EnvironmentLifetimeBin = (typeof ENVIRONMENT_LIFETIME_BINS)[number]['id'] | 'missing' | 'invalid'; +export type EnvironmentLifetimeStats = { + method: EnvironmentLifetimeMethod; + quality: EnvironmentLifetimeQuality; + eligible: number; + samples: number; + missing: number; + invalid: number; + meanHours: number | null; + medianHours: number | null; + p90Hours: number | null; + distribution: Array<{ id: string; label: string; fromHours: number; toHours: number | null; count: number }>; +}; +export type EnvironmentLifetimeCompleted = { api: EnvironmentLifetimeStats; pr: EnvironmentLifetimeStats }; +export type EnvironmentLifetimeAnalytics = { + range: ResolvedAnalyticsRange; + asOf: string; + scope: EnvironmentAnalyticsScope; + completed: EnvironmentLifetimeCompleted; + previousCompleted: EnvironmentLifetimeCompleted | null; + buckets: Array<{ + date: string; + previousDate: string | null; + current: EnvironmentLifetimeCompleted; + previous: EnvironmentLifetimeCompleted | null; + }>; + currentAge: Record<'all' | EnvironmentLifetimeGroup, EnvironmentLifetimeStats>; + unplacedRetirements: { + api: { missingEnd: number; invalidEnd: number }; + pr: { missingEnd: number; invalidEnd: number }; + other: number; + }; + caveats: string[]; +}; +export type EnvironmentLifetimeRecordsQuery = EnvironmentAnalyticsScope & { + from?: string; + to?: string; + timezone?: string; + compare?: boolean; + interval?: 'day' | 'week'; + rankBy: EnvironmentAnalyticsQuery['rankBy']; + cohort: 'completed' | 'current'; + group: 'all' | EnvironmentLifetimeGroup; + bin: EnvironmentLifetimeBin | null; + page: number; + limit: number; +}; +export type EnvironmentLifetimeRecord = AnalyticsRepositoryIdentity & { + id: number; + uuid: string | null; + status: string | null; + isStatic: boolean; + author: string | null; + repositoryAmbiguous: boolean; + resourceAvailable: boolean; + pullRequest: { number: number | null; title: string | null; author: string | null } | null; + group: EnvironmentLifetimeGroup; + method: EnvironmentLifetimeMethod; + quality: EnvironmentLifetimeQuality; + sampleState: EnvironmentLifetimeSampleState; + startedAt: string | null; + measuredUntilAt: string | null; + durationHours: number | null; +}; +export type EnvironmentLifetimeRecords = { + asOf: string; + scope: EnvironmentAnalyticsScope; + range: ResolvedAnalyticsRange | null; + cohort: 'completed' | 'current'; + group: 'all' | EnvironmentLifetimeGroup; + bin: EnvironmentLifetimeBin | null; + records: EnvironmentLifetimeRecord[]; + pagination: { page: number; limit: number; total: number; hasMore: boolean; maxPage: number; truncated: boolean }; + caveats: string[]; +}; + +const MAX_PAGE = 10_000; +const CAVEATS = [ + 'API lifetime is the recorded time from first creation to identity release, not ready time or live resource uptime.', + 'PR lifetime is an estimate from first creation to the latest update of a currently torn-down record. Later edits and reused records can include inactive gaps.', + 'Current age is time since the first retained record. It includes queued, paused and failed time and can include earlier PR episodes.', + 'Retirement dates select completed samples. Missing or nonfinite end dates cannot be placed in a window; unplaced counts cover the entire selected scope.', + 'Counts cover retained environment records, not deployment attempts. Collection start and history completeness are unknown.', +]; +type LifetimeAggregate = Record & { + group_name?: string; + period?: string; + bucket?: string | null; +}; + +const count = (value: unknown): number => { + const result = value == null ? 0 : Number(value); + if (!Number.isSafeInteger(result) || result < 0) throw new Error('Invalid lifetime analytics count.'); + return result; +}; +const nullableNumber = (value: unknown): number | null => { + if (value == null) return null; + const result = Number(value); + return Number.isFinite(result) && result >= 0 ? result : null; +}; +const timestamp = (value: unknown): string | null => { + if (value == null) return null; + const result = new Date(value as string | Date); + return Number.isFinite(result.getTime()) ? result.toISOString() : null; +}; +const previousBucketDate = (date: string, range: ResolvedAnalyticsRange): string | null => { + if (!range.previous) return null; + const aligned = new Date(Date.parse(`${date}T00:00:00Z`) - range.calendarDays * 86_400_000).toISOString(); + return /^\d{4}-/.test(aligned) && !aligned.startsWith('0000-') ? aligned.slice(0, 10) : null; +}; +function method(group: EnvironmentLifetimeGroup | 'all', cohort: 'completed' | 'current') { + if (cohort === 'current') return { method: 'first_record_age' as const, quality: 'record_age' as const }; + return group === 'api' + ? { method: 'created_to_deleted' as const, quality: 'recorded' as const } + : { method: 'created_to_last_update' as const, quality: 'estimated' as const }; +} + +export function serializeEnvironmentLifetimeStats( + group: EnvironmentLifetimeGroup | 'all', + cohort: 'completed' | 'current', + row?: LifetimeAggregate +): EnvironmentLifetimeStats { + const samples = count(row?.samples); + return { + ...method(group, cohort), + eligible: count(row?.eligible), + samples, + missing: count(row?.missing), + invalid: count(row?.invalid), + meanHours: samples ? nullableNumber(row?.mean_hours) : null, + medianHours: samples ? nullableNumber(row?.median_hours) : null, + p90Hours: samples ? nullableNumber(row?.p90_hours) : null, + distribution: ENVIRONMENT_LIFETIME_BINS.map((bin) => ({ ...bin, count: count(row?.[bin.id]) })), + }; +} + +export function parseEnvironmentLifetimeRecordsQuery(params: URLSearchParams): EnvironmentLifetimeRecordsQuery { + const cohort = params.get('cohort') ?? 'completed'; + if (cohort !== 'completed' && cohort !== 'current') + throw new BadRequestError('cohort must be completed or current.', 'invalid_query'); + const group = params.get('group') ?? 'all'; + if (!['all', 'api', 'pr', 'other'].includes(group) || (cohort === 'completed' && group === 'other')) + throw new BadRequestError( + 'group must be all, api or pr; other is available for current age only.', + 'invalid_query' + ); + const bin = params.get('bin'); + if (bin !== null && !['missing', 'invalid', ...ENVIRONMENT_LIFETIME_BINS.map(({ id }) => id)].includes(bin)) + throw new BadRequestError('bin must be a lifetime histogram or coverage bucket.', 'invalid_query'); + const pageText = params.get('page') ?? '1', + limitText = params.get('limit') ?? '25'; + const page = Number(pageText), + limit = Number(limitText); + if ( + !/^[1-9]\d*$/.test(pageText) || + !/^[1-9]\d*$/.test(limitText) || + !Number.isSafeInteger(page) || + page > MAX_PAGE || + !Number.isSafeInteger(limit) || + limit > 100 + ) + throw new BadRequestError('page must be between 1 and 10000 and limit between 1 and 100.', 'invalid_query'); + const query = + cohort === 'current' + ? { ...parseEnvironmentAnalyticsScope(params), rankBy: 'first_seen' as const } + : parseEnvironmentAnalyticsQuery(params); + return { + ...query, + cohort, + group: group as EnvironmentLifetimeRecordsQuery['group'], + bin: bin as EnvironmentLifetimeBin | null, + page, + limit, + }; +} + +function factsSql( + knex: Knex, + scope: EnvironmentAnalyticsScope, + cohort: 'completed' | 'current' +): { sql: string; bindings: Knex.RawBinding[] } { + let source = environmentBase(knex, scope).select('e.*', 'r.fullName', 'r.githubInstallationId'); + if (cohort === 'current') source = currentEnvironmentQuery(source); + const compiled = source.toSQL(); + return { + sql: ` + lifetime_source AS (${compiled.sql}), + classified AS ( + SELECT *, CASE WHEN "triggerType" = 'api' THEN 'api' + WHEN "hasPullRequest" THEN 'pr' ELSE 'other' END AS group_name + FROM lifetime_source + ), measured AS ( + SELECT *, "createdAt" AS start_at, + ${ + cohort === 'current' + ? 'CURRENT_TIMESTAMP' + : `CASE WHEN group_name = 'api' THEN "deletedAt" WHEN group_name = 'pr' THEN "updatedAt" END` + } AS end_at + FROM classified + ${ + cohort === 'completed' + ? `WHERE (group_name = 'api' AND ("deletedAt" IS NOT NULL OR status = ?)) OR (group_name = 'pr' AND status = ?) OR (group_name = 'other' AND ("deletedAt" IS NOT NULL OR status = ?))` + : '' + } + ), evaluated AS ( + SELECT *, CASE WHEN start_at IS NULL OR end_at IS NULL THEN 'missing' + WHEN NOT isfinite(start_at) OR NOT isfinite(end_at) OR end_at < start_at OR end_at > CURRENT_TIMESTAMP THEN 'invalid' + ELSE 'valid' END AS sample_state + FROM measured + ), facts AS ( + SELECT *, CASE WHEN sample_state = 'valid' THEN EXTRACT(EPOCH FROM (end_at - start_at)) / 3600.0 END::double precision AS duration_hours + FROM evaluated + )`, + bindings: [ + ...compiled.bindings, + ...(cohort === 'current' ? [] : [BuildStatus.TORN_DOWN, BuildStatus.TORN_DOWN, BuildStatus.TORN_DOWN]), + ], + }; +} + +const AGGREGATES = `COUNT(*) AS eligible, + COUNT(*) FILTER (WHERE sample_state = 'valid') AS samples, + COUNT(*) FILTER (WHERE sample_state = 'missing') AS missing, + COUNT(*) FILTER (WHERE sample_state = 'invalid') AS invalid, + AVG(duration_hours) AS mean_hours, + percentile_cont(0.5) WITHIN GROUP (ORDER BY duration_hours) AS median_hours, + percentile_cont(0.9) WITHIN GROUP (ORDER BY duration_hours) AS p90_hours, + ${ENVIRONMENT_LIFETIME_BINS.map( + (bin) => + `COUNT(*) FILTER (WHERE duration_hours >= ${bin.fromHours}${ + bin.toHours === null ? '' : ` AND duration_hours < ${bin.toHours}` + }) AS "${bin.id}"` + ).join(',\n ')}`; + +function scopeOf(query: EnvironmentAnalyticsScope): EnvironmentAnalyticsScope { + return { + repositoryId: query.repositoryId, + organization: query.organization, + environmentType: query.environmentType, + environmentAuthor: query.environmentAuthor, + unattributed: query.unattributed, + }; +} + +export default class EnvironmentLifetimeAnalyticsService { + constructor(private readonly db: Pick = defaultDb) {} + + async getSummary(query: EnvironmentAnalyticsQuery): Promise { + return this.db.knex.transaction(async (trx) => { + await trx.raw('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY'); + const range = await resolveAnalyticsRange(trx, query); + const scope = scopeOf(query); + const completed = factsSql(trx, scope, 'completed'); + const result = await trx.raw( + `WITH ${completed.sql}, period_facts AS ( + SELECT *, CASE WHEN end_at >= ?::timestamptz THEN 'current' ELSE 'previous' END AS period, + to_char(date_trunc(?, (end_at AT TIME ZONE ?) + + (CASE WHEN end_at < ?::timestamptz THEN ?::int ELSE 0 END * interval '1 day')), 'YYYY-MM-DD') AS bucket + FROM facts WHERE group_name IN ('api','pr') AND isfinite(end_at) + AND end_at >= ?::timestamptz AND end_at < ?::timestamptz + ) SELECT period, group_name, bucket, ${AGGREGATES} + FROM period_facts GROUP BY GROUPING SETS ((period,group_name), (period,group_name,bucket))`, + [ + ...completed.bindings, + range.fromUtc, + range.interval, + range.timezone, + range.fromUtc, + range.calendarDays, + range.previous?.fromUtc ?? range.fromUtc, + range.toUtc, + ] + ); + const current = factsSql(trx, scope, 'current'); + const currentResult = await trx.raw( + `WITH ${current.sql} SELECT COALESCE(group_name, 'all') AS group_name, ${AGGREGATES} + FROM facts GROUP BY GROUPING SETS ((group_name), ())`, + current.bindings + ); + const unplacedResult = await trx.raw( + `WITH ${completed.sql} SELECT group_name, + COUNT(*) FILTER (WHERE end_at IS NULL) AS missing_end, + COUNT(*) FILTER (WHERE end_at IS NOT NULL AND NOT isfinite(end_at)) AS invalid_end, + COUNT(*) AS total FROM facts GROUP BY group_name`, + completed.bindings + ); + const rows = result.rows as LifetimeAggregate[]; + const groupStats = (period: string, bucket: string | null): EnvironmentLifetimeCompleted => ({ + api: serializeEnvironmentLifetimeStats( + 'api', + 'completed', + rows.find((row) => row.period === period && row.bucket === bucket && row.group_name === 'api') + ), + pr: serializeEnvironmentLifetimeStats( + 'pr', + 'completed', + rows.find((row) => row.period === period && row.bucket === bucket && row.group_name === 'pr') + ), + }); + const dates = analyticsBucketDates(range); + const unplaced = (group: string) => unplacedResult.rows.find((row) => row.group_name === group); + return { + range, + asOf: range.asOf, + scope, + completed: groupStats('current', null), + previousCompleted: range.previous ? groupStats('previous', null) : null, + buckets: dates.map((date) => ({ + date, + previousDate: previousBucketDate(date, range), + current: groupStats('current', date), + previous: range.previous ? groupStats('previous', date) : null, + })), + currentAge: Object.fromEntries( + ['all', 'api', 'pr', 'other'].map((group: 'all' | EnvironmentLifetimeGroup) => [ + group, + serializeEnvironmentLifetimeStats( + group, + 'current', + currentResult.rows.find((row) => row.group_name === group) + ), + ]) + ) as EnvironmentLifetimeAnalytics['currentAge'], + unplacedRetirements: { + api: { missingEnd: count(unplaced('api')?.missing_end), invalidEnd: count(unplaced('api')?.invalid_end) }, + pr: { missingEnd: count(unplaced('pr')?.missing_end), invalidEnd: count(unplaced('pr')?.invalid_end) }, + other: count(unplaced('other')?.total), + }, + caveats: CAVEATS, + }; + }); + } + + async getRecords(query: EnvironmentLifetimeRecordsQuery): Promise { + return this.db.knex.transaction(async (trx) => { + await trx.raw('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY'); + const range = query.cohort === 'completed' ? await resolveAnalyticsRange(trx, query) : null; + const asOf = range?.asOf ?? timestamp((await trx.raw('SELECT CURRENT_TIMESTAMP AS "asOf"')).rows[0].asOf)!; + const scope = scopeOf(query); + const facts = factsSql(trx, scope, query.cohort); + const predicates: string[] = []; + const bindings: Knex.RawBinding[] = [...facts.bindings]; + if (range) { + predicates.push( + `group_name IN ('api','pr') AND isfinite(end_at) AND end_at >= ?::timestamptz AND end_at < ?::timestamptz` + ); + bindings.push(range.fromUtc, range.toUtc); + } + if (query.group !== 'all') { + predicates.push('group_name = ?'); + bindings.push(query.group); + } + if (query.bin === 'missing' || query.bin === 'invalid') { + predicates.push('sample_state = ?'); + bindings.push(query.bin); + } else if (query.bin) { + const bin = ENVIRONMENT_LIFETIME_BINS.find(({ id }) => id === query.bin)!; + predicates.push('duration_hours >= ?'); + bindings.push(bin.fromHours); + if (bin.toHours !== null) { + predicates.push('duration_hours < ?'); + bindings.push(bin.toHours); + } + } + const result = await trx.raw( + `WITH ${facts.sql}, selected AS MATERIALIZED ( + SELECT * FROM facts ${predicates.length ? `WHERE ${predicates.join(' AND ')}` : ''} + ), page AS ( + SELECT * FROM selected ORDER BY duration_hours DESC NULLS LAST, end_at DESC NULLS LAST, id DESC LIMIT ? OFFSET ? + ) SELECT (SELECT COUNT(*) FROM selected) AS total, + COALESCE((SELECT jsonb_agg(jsonb_build_object( + 'id', id, 'uuid', uuid, 'status', status, 'isStatic', COALESCE("isStatic", false), + 'author', author, 'repositoryId', "repositoryId", 'fullName', "fullName", + 'githubInstallationId', "githubInstallationId", 'repositoryAmbiguous', "repositoryAmbiguous", + 'resourceAvailable', ("deletedAt" IS NULL AND (status IS NULL OR status <> ?) AND NULLIF(uuid, '') IS NOT NULL), + 'pullRequest', CASE WHEN group_name = 'pr' THEN jsonb_build_object( + 'number', "pullRequestNumber", 'title', "prTitle", 'author', "prAuthor") END, + 'group', group_name, 'sampleState', sample_state, + 'startedAt', CASE WHEN isfinite(start_at) THEN start_at END, + 'measuredUntilAt', CASE WHEN isfinite(end_at) THEN end_at END, 'durationHours', duration_hours + ) ORDER BY duration_hours DESC NULLS LAST, end_at DESC NULLS LAST, id DESC) FROM page), '[]'::jsonb) AS records`, + [...bindings, query.limit, (query.page - 1) * query.limit, BuildStatus.TORN_DOWN] + ); + const row = result.rows[0]; + const total = count(row.total); + return { + asOf, + scope, + range, + cohort: query.cohort, + group: query.group, + bin: query.bin, + records: row.records.map((record) => ({ + ...record, + ...method(record.group, query.cohort), + id: count(record.id), + repositoryId: record.repositoryId == null ? null : count(record.repositoryId), + githubInstallationId: record.githubInstallationId == null ? null : count(record.githubInstallationId), + durationHours: nullableNumber(record.durationHours), + startedAt: timestamp(record.startedAt), + measuredUntilAt: timestamp(record.measuredUntilAt), + })), + pagination: { + page: query.page, + limit: query.limit, + total, + hasMore: query.page < MAX_PAGE && query.page * query.limit < total, + maxPage: MAX_PAGE, + truncated: total > MAX_PAGE * query.limit, + }, + caveats: CAVEATS, + }; + }); + } +} diff --git a/src/shared/environmentAnalyticsOpenApi.test.ts b/src/shared/environmentAnalyticsOpenApi.test.ts index f0a563a9..204bbe23 100644 --- a/src/shared/environmentAnalyticsOpenApi.test.ts +++ b/src/shared/environmentAnalyticsOpenApi.test.ts @@ -10,6 +10,7 @@ describe('Admin environment analytics OpenAPI contract', () => { ['inventory', 'getInventoryAnalytics', 'GetInventoryAnalyticsSuccessResponse'], ['options', 'getAnalyticsOptions', 'GetAnalyticsOptionsSuccessResponse'], ['environments/records', 'getEnvironmentAnalyticsRecords', 'GetEnvironmentAnalyticsRecordsSuccessResponse'], + ['services', 'getManagedServiceRecords', 'GetManagedServiceRecordsSuccessResponse'], ])('documents %s with a concrete generated response and session authentication', (path, operationId, response) => { const operation = spec.paths[`/api/v2/admin/analytics/${path}`].get; expect(operation.operationId).toBe(operationId); @@ -71,4 +72,26 @@ describe('Admin environment analytics OpenAPI contract', () => { expect.arrayContaining(['repositoryTotal', 'repositoriesTruncated', 'truncated']) ); }); + + it('separates unique configured Services, runtime instances, exclusions, and current-only environment counts', () => { + expect(schemas.InventoryAnalytics.properties.services.$ref).toBe('#/components/schemas/ManagedServiceInventory'); + expect(schemas.ManagedServiceInventory.properties.distinctServices.description).toContain('owning repository'); + expect(schemas.ManagedServiceInventory.required).toEqual( + expect.arrayContaining(['instances', 'unresolvedIdentityInstances', 'excluded', 'byType']) + ); + expect(schemas.ManagedServiceInventory.properties.excluded.required).toEqual([ + 'externalInstances', + 'buildOnlyInstances', + 'unknownTypeInstances', + ]); + expect(schemas.ManagedServiceRecord.properties.type.enum).toEqual(['docker', 'github', 'helm', 'aurora-restore']); + expect(schemas.ManagedServiceRecord.required).toEqual( + expect.arrayContaining(['identityResolved', 'sourceGithubRepositoryId', 'serviceId', 'key']) + ); + expect(schemas.EnvironmentAnalyticsRecord.properties.serviceInstances.nullable).toBe(true); + expect(schemas.EnvironmentAnalyticsRecord.properties.serviceTypes.nullable).toBe(true); + const operation = spec.paths['/api/v2/admin/analytics/services'].get; + expect(operation.description).toContain('Historical date controls are ignored'); + expect(operation.parameters.find((param: any) => param.name === 'limit').schema.maximum).toBe(100); + }); }); diff --git a/src/shared/environmentAnalyticsOpenApi.ts b/src/shared/environmentAnalyticsOpenApi.ts index 723f7a4e..1b716043 100644 --- a/src/shared/environmentAnalyticsOpenApi.ts +++ b/src/shared/environmentAnalyticsOpenApi.ts @@ -54,6 +54,8 @@ const phase = { type: 'string', enum: ['ready', 'deployed_not_ready', 'in_progress', 'paused', 'failed', 'tearing_down', 'torn_down'], }; +const managedServiceType = { type: 'string', enum: ['docker', 'github', 'helm', 'aurora-restore'] }; +const serviceInstanceType = object({ type: managedServiceType, instances: integer }); export const environmentAnalyticsSchemas = { AnalyticsPeriod: object(periodProperties), @@ -124,6 +126,58 @@ export const environmentAnalyticsSchemas = { }), InventoryAnalyticsTotals: object(inventoryProperties), InventoryAnalyticsRepository: object({ ...repositoryProperties, ...inventoryProperties }), + ManagedServiceTypeCounts: object({ + type: managedServiceType, + distinctServices: integer, + instances: integer, + readyInstances: integer, + }), + ManagedServiceInventory: object({ + distinctServices: { + ...integer, + description: 'Identified configured Services by owning repository, source or template, name, and type.', + }, + instances: { + ...integer, + description: 'Selected current managed runtime units; includes queued and failed units, not deployment events.', + }, + readyInstances: { ...integer, description: 'Instances with recorded READY status.' }, + environmentsWithServices: integer, + unresolvedIdentityInstances: { + ...integer, + description: 'Instances excluded from unique counts because the owning repository is unresolved.', + }, + byType: array(ref('ManagedServiceTypeCounts')), + excluded: object({ externalInstances: integer, buildOnlyInstances: integer, unknownTypeInstances: integer }), + }), + ManagedServiceRecord: object({ + ...repositoryProperties, + key: { ...text, description: 'Opaque configured identity; unresolved identities remain separate by environment.' }, + name: text, + type: managedServiceType, + sourceGithubRepositoryId: nullableInteger, + serviceId: nullableInteger, + identityResolved: boolean, + instances: integer, + readyInstances: integer, + environments: integer, + }), + ManagedServiceRecords: object({ + asOf: instant, + scope: ref('EnvironmentAnalyticsScope'), + type: { ...managedServiceType, nullable: true }, + records: array(ref('ManagedServiceRecord')), + pagination: ref('ManagedServicePagination'), + caveats: array(text), + }), + ManagedServicePagination: object({ + page: { type: 'integer', minimum: 1, maximum: 1000000 }, + limit: { type: 'integer', minimum: 1, maximum: 100 }, + total: integer, + hasMore: boolean, + maxPage: { type: 'integer', enum: [1000000] }, + truncated: boolean, + }), EnvironmentAnalyticsPullRequest: object({ number: nullableInteger, title: nullableText, author: nullableText }), EnvironmentAnalyticsRecord: object({ ...repositoryProperties, @@ -141,12 +195,19 @@ export const environmentAnalyticsSchemas = { deletedAt: nullableInstant, resourceAvailable: boolean, repositoryAmbiguous: boolean, + serviceInstances: { + ...nullableInteger, + description: + 'Current managed runtime instances; null when the historical cohort does not request current service inventory.', + }, + serviceTypes: { ...array(serviceInstanceType), nullable: true }, pullRequest: nullableRef('EnvironmentAnalyticsPullRequest'), }), InventoryAnalytics: object({ asOf: instant, scope: ref('EnvironmentAnalyticsScope'), totals: ref('InventoryAnalyticsTotals'), + services: ref('ManagedServiceInventory'), repositories: array(ref('InventoryAnalyticsRepository')), exceptions: array(ref('EnvironmentAnalyticsRecord')), truncated: boolean, @@ -183,5 +244,6 @@ export const environmentAnalyticsSchemas = { GetAnalyticsOptionsSuccessResponse: success('AnalyticsOptions'), GetEnvironmentAnalyticsSuccessResponse: success('EnvironmentAnalytics'), GetInventoryAnalyticsSuccessResponse: success('InventoryAnalytics'), + GetManagedServiceRecordsSuccessResponse: success('ManagedServiceRecords'), GetEnvironmentAnalyticsRecordsSuccessResponse: success('EnvironmentAnalyticsRecords'), }; diff --git a/src/shared/environmentLifetimeAnalyticsOpenApi.ts b/src/shared/environmentLifetimeAnalyticsOpenApi.ts new file mode 100644 index 00000000..216ab1e0 --- /dev/null +++ b/src/shared/environmentLifetimeAnalyticsOpenApi.ts @@ -0,0 +1,126 @@ +/** + * 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. + */ + +const integer = { type: 'integer', minimum: 0 }; +const nullableInteger = { ...integer, nullable: true }; +const text = { type: 'string' }; +const nullableText = { ...text, nullable: true }; +const instant = { type: 'string', format: 'date-time' }; +const nullableInstant = { ...instant, nullable: true }; +const hours = { type: 'number', minimum: 0, nullable: true }; +const boolean = { type: 'boolean' }; +const ref = (name: string) => ({ $ref: `#/components/schemas/${name}` }); +const nullableRef = (name: string) => ({ allOf: [ref(name)], nullable: true }); +const array = (items: object) => ({ type: 'array', items }); +const object = (properties: Record) => ({ + type: 'object', + properties, + required: Object.keys(properties), + additionalProperties: false, +}); +const success = (name: string) => ({ + allOf: [ref('SuccessApiResponse'), { type: 'object', properties: { data: ref(name) }, required: ['data'] }], +}); +const method = { type: 'string', enum: ['created_to_deleted', 'created_to_last_update', 'first_record_age'] }; +const quality = { type: 'string', enum: ['recorded', 'estimated', 'record_age'] }; +const group = { type: 'string', enum: ['api', 'pr', 'other'] }; +const bin = { + type: 'string', + enum: ['under_1h', '1_to_6h', '6_to_24h', '1_to_3d', '3_to_7d', '7_to_30d', '30d_plus', 'missing', 'invalid'], + nullable: true, +}; +const unplaced = object({ missingEnd: integer, invalidEnd: integer }); + +export const environmentLifetimeAnalyticsSchemas = { + EnvironmentLifetimeDistribution: object({ + id: { type: 'string', enum: ['under_1h', '1_to_6h', '6_to_24h', '1_to_3d', '3_to_7d', '7_to_30d', '30d_plus'] }, + label: text, + fromHours: { type: 'number', minimum: 0 }, + toHours: hours, + count: integer, + }), + EnvironmentLifetimeStats: object({ + method, + quality, + eligible: integer, + samples: integer, + missing: integer, + invalid: integer, + meanHours: hours, + medianHours: hours, + p90Hours: hours, + distribution: array(ref('EnvironmentLifetimeDistribution')), + }), + EnvironmentLifetimeCompleted: object({ api: ref('EnvironmentLifetimeStats'), pr: ref('EnvironmentLifetimeStats') }), + EnvironmentLifetimeBucket: object({ + date: { type: 'string', format: 'date' }, + previousDate: { type: 'string', format: 'date', nullable: true }, + current: ref('EnvironmentLifetimeCompleted'), + previous: nullableRef('EnvironmentLifetimeCompleted'), + }), + EnvironmentLifetimeAnalytics: object({ + range: ref('AnalyticsRange'), + asOf: instant, + scope: ref('EnvironmentAnalyticsScope'), + completed: ref('EnvironmentLifetimeCompleted'), + previousCompleted: nullableRef('EnvironmentLifetimeCompleted'), + buckets: array(ref('EnvironmentLifetimeBucket')), + currentAge: object({ + all: ref('EnvironmentLifetimeStats'), + api: ref('EnvironmentLifetimeStats'), + pr: ref('EnvironmentLifetimeStats'), + other: ref('EnvironmentLifetimeStats'), + }), + unplacedRetirements: object({ api: unplaced, pr: unplaced, other: integer }), + caveats: array(text), + }), + EnvironmentLifetimeRecord: object({ + id: integer, + uuid: nullableText, + status: nullableText, + isStatic: boolean, + author: nullableText, + repositoryId: nullableInteger, + fullName: nullableText, + githubInstallationId: nullableInteger, + repositoryAmbiguous: boolean, + resourceAvailable: boolean, + pullRequest: nullableRef('EnvironmentAnalyticsPullRequest'), + group, + method, + quality, + sampleState: { type: 'string', enum: ['valid', 'missing', 'invalid'] }, + startedAt: nullableInstant, + measuredUntilAt: nullableInstant, + durationHours: hours, + }), + EnvironmentLifetimeRecords: object({ + asOf: instant, + scope: ref('EnvironmentAnalyticsScope'), + range: nullableRef('AnalyticsRange'), + cohort: { type: 'string', enum: ['completed', 'current'] }, + group: { type: 'string', enum: ['all', 'api', 'pr', 'other'] }, + bin, + records: array(ref('EnvironmentLifetimeRecord')), + pagination: object({ + page: { type: 'integer', minimum: 1, maximum: 10000 }, + limit: { type: 'integer', minimum: 1, maximum: 100 }, + total: integer, + hasMore: boolean, + maxPage: { type: 'integer', enum: [10000] }, + truncated: boolean, + }), + caveats: array(text), + }), + GetEnvironmentLifetimeAnalyticsSuccessResponse: success('EnvironmentLifetimeAnalytics'), + GetEnvironmentLifetimeAnalyticsRecordsSuccessResponse: success('EnvironmentLifetimeRecords'), +}; diff --git a/src/shared/openApiSpec.ts b/src/shared/openApiSpec.ts index 502fcf2d..b7209017 100644 --- a/src/shared/openApiSpec.ts +++ b/src/shared/openApiSpec.ts @@ -1,5 +1,6 @@ import { OAS3Options } from 'swagger-jsdoc'; import { environmentAnalyticsSchemas } from './environmentAnalyticsOpenApi'; +import { environmentLifetimeAnalyticsSchemas } from './environmentLifetimeAnalyticsOpenApi'; import { agentAnalyticsSchemas } from './agentAnalyticsOpenApi'; import { AGENT_FEEDBACK_SCOPES, @@ -172,6 +173,7 @@ export const openApiSpecificationForV2Api: OAS3Options = { }, schemas: { ...environmentAnalyticsSchemas, + ...environmentLifetimeAnalyticsSchemas, ...agentAnalyticsSchemas, SitesBrowserMintRequest: { type: 'object', From 24c6f460acc91f1fdd88d07719d7089b705867bb Mon Sep 17 00:00:00 2001 From: vmelikyan Date: Sat, 3 Oct 2026 09:54:14 -0700 Subject: [PATCH 3/9] fix(analytics): reduce PR coverage and component query costs --- .../analytics/AgentAnalyticsService.test.ts | 28 +++--- .../analytics/AgentAnalyticsService.ts | 59 ++++++------ .../EnvironmentAnalyticsService.test.ts | 72 ++++++++++++--- .../analytics/EnvironmentAnalyticsService.ts | 89 +++++++++---------- ...nvironmentLifetimeAnalyticsService.test.ts | 2 +- .../EnvironmentLifetimeAnalyticsService.ts | 7 +- src/server/services/analytics/execution.ts | 21 +++++ 7 files changed, 175 insertions(+), 103 deletions(-) create mode 100644 src/server/services/analytics/execution.ts diff --git a/src/server/services/analytics/AgentAnalyticsService.test.ts b/src/server/services/analytics/AgentAnalyticsService.test.ts index 13ac649e..19c080ea 100644 --- a/src/server/services/analytics/AgentAnalyticsService.test.ts +++ b/src/server/services/analytics/AgentAnalyticsService.test.ts @@ -47,9 +47,7 @@ const range: ResolvedAnalyticsRange = { }; function serviceWithRows(results: unknown[][]) { - const raw = jest.fn(async (sql: string) => - sql.startsWith('SET TRANSACTION') ? undefined : { rows: results.shift() } - ); + const raw = jest.fn(async (sql: string) => (sql.startsWith('SET ') ? undefined : { rows: results.shift() })); const transaction = jest.fn(async (callback) => callback({ raw })); const service = new AgentAnalyticsService({ knex: { transaction } } as any); return { service, raw }; @@ -181,7 +179,10 @@ describe('AgentAnalyticsService', () => { expect(result.rankingTotal.repositories).toBe(23); expect(result.rankingTruncated.repositories).toBe(true); expect(result.earliestRunAt).toBe('2025-04-01T00:00:00.000Z'); - const [sql, bindings] = raw.mock.calls[1] as unknown as [string, unknown[]]; + const [sql, bindings] = raw.mock.calls.filter(([sql]) => !sql.startsWith('SET '))[0] as unknown as [ + string, + unknown[] + ]; expect(sql).toContain('COUNT(DISTINCT session_id)'); expect(sql).toContain("jsonb_typeof(r.\"usageSummary\"->'totalTokens') = 'number'"); expect(sql).toContain("'{source,repoFullName}'"); @@ -201,8 +202,10 @@ describe('AgentAnalyticsService', () => { 'org/repo', 20, ]); - expect(raw.mock.calls[2][0]).toContain('ORDER BY r."createdAt", r.id LIMIT 1'); - expect(raw.mock.calls[3][0]).toContain('min("createdAt")'); + expect(raw.mock.calls.filter(([sql]) => !sql.startsWith('SET '))[1][0]).toContain( + 'ORDER BY r."createdAt", r.id LIMIT 1' + ); + expect(raw.mock.calls.filter(([sql]) => !sql.startsWith('SET '))[2][0]).toContain('min("createdAt")'); }); it('omits previous results when comparison is disabled and preserves empty runs beyond the last page', async () => { @@ -211,7 +214,10 @@ describe('AgentAnalyticsService', () => { const result = await service.listRuns({ repository: 'unattributed', runStatus: 'failed' }, 2, 25); expect(result.runs).toEqual([]); expect(result.pagination).toEqual({ page: 2, limit: 25, total: 1, hasMore: false }); - const [sql, bindings] = raw.mock.calls[1] as unknown as [string, unknown[]]; + const [sql, bindings] = raw.mock.calls.filter(([sql]) => !sql.startsWith('SET '))[0] as unknown as [ + string, + unknown[] + ]; expect(sql).toContain('repository IS NULL AND status = ?'); expect(sql).toContain('ORDER BY submitted_at DESC, id DESC LIMIT ? OFFSET ?'); expect(bindings).toEqual([range.fromUtc, range.toUtc, 'failed', 25, 25]); @@ -283,7 +289,7 @@ describe('AgentAnalyticsService', () => { ]); const result = await service.listRuns({}, 1, 25); expect(result.runs[0]).toMatchObject({ submittedAt: range.fromUtc, queuedAt: requeuedAt, tokens: { total: 300 } }); - const sql = raw.mock.calls[1][0]; + const sql = raw.mock.calls.filter(([sql]) => !sql.startsWith('SET '))[0][0]; expect(sql).toContain('r."createdAt" AS submitted_at'); expect(sql).toContain('r."queuedAt" AS queued_at'); expect(sql).toContain('r."createdAt" >= ?::timestamptz AND r."createdAt" < ?::timestamptz'); @@ -307,11 +313,13 @@ describe('AgentAnalyticsService', () => { }, ], }); - const result = await new AgentAnalyticsService({ knex: { raw } } as any).getOptions(); + const result = await new AgentAnalyticsService({ + knex: { transaction: async (callback: any) => callback({ raw }) }, + } as any).getOptions(); expect(result.truncated).toEqual({ repositories: true, owners: false, models: false }); expect(result.owners).toEqual([{ id: 'owner', githubUsername: null }]); expect(result.hasUnattributed).toBe(true); - expect(raw.mock.calls[0][1]).toEqual([200, 200, 200]); + expect(raw.mock.calls.filter(([sql]) => !sql.startsWith('SET '))[0][1]).toEqual([200, 200, 200]); }); }); diff --git a/src/server/services/analytics/AgentAnalyticsService.ts b/src/server/services/analytics/AgentAnalyticsService.ts index 221f6291..8986fed8 100644 --- a/src/server/services/analytics/AgentAnalyticsService.ts +++ b/src/server/services/analytics/AgentAnalyticsService.ts @@ -14,6 +14,7 @@ import type { Knex } from 'knex'; import type Database from 'server/database'; import { defaultDb } from 'server/lib/dependencies'; import { BadRequestError } from 'server/lib/appError'; +import { analyticsTransaction } from './execution'; import { parseAnalyticsCalendarQuery, resolveAnalyticsRange, @@ -340,8 +341,7 @@ export default class AgentAnalyticsService { constructor(private readonly db: Pick = defaultDb) {} async getSummary(query: AgentAnalyticsQuery): Promise { - return this.db.knex.transaction(async (trx) => { - await trx.raw('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY'); + return analyticsTransaction(this.db.knex, async (trx) => { const range = await resolveAnalyticsRange(trx, query); const scope = scopeSql(query); const from = range.previous?.fromUtc ?? range.fromUtc; @@ -481,8 +481,7 @@ export default class AgentAnalyticsService { async listRuns(query: AgentAnalyticsRunsQuery, page = 1, limit = 25): Promise { if (!Number.isInteger(page) || page < 1 || page > 10000 || !Number.isInteger(limit) || limit < 1 || limit > 100) throw new BadRequestError('Invalid run pagination.'); - return this.db.knex.transaction(async (trx) => { - await trx.raw('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY'); + return analyticsTransaction(this.db.knex, async (trx) => { const range = await resolveAnalyticsRange(trx, query); const scope = scopeSql(query); const statusCondition = query.runStatus ? `${scope.sql ? ' AND' : ' WHERE'} status = ?` : ''; @@ -540,8 +539,9 @@ export default class AgentAnalyticsService { async getOptions(): Promise { const limit = 200; - const result = await this.db.knex.raw( - ` + return analyticsTransaction(this.db.knex, async (trx) => { + const result = await trx.raw( + ` WITH facts AS MATERIALIZED (SELECT ${RUN_DIMENSIONS} FROM agent_runs r), repositories AS (SELECT DISTINCT repository FROM facts WHERE repository IS NOT NULL), models AS (SELECT DISTINCT provider, model FROM facts), @@ -559,28 +559,29 @@ export default class AgentAnalyticsService { COALESCE((SELECT jsonb_agg(m ORDER BY provider, model) FROM (SELECT * FROM models ORDER BY provider, model LIMIT ?) m), '[]'::jsonb) AS models, COALESCE((SELECT jsonb_agg(o ORDER BY "githubUsername" NULLS LAST, id) FROM (SELECT * FROM owners ORDER BY "githubUsername" NULLS LAST, id LIMIT ?) o), '[]'::jsonb) AS owners `, - [limit, limit, limit] - ); - const row = result.rows[0]; - const totals = { - repositories: number(row.repositories_total), - models: number(row.models_total), - owners: number(row.owners_total), - }; - return { - asOf: timestamp(row.as_of)!, - repositoryScope: 'recorded_name', - repositories: row.repositories, - owners: row.owners, - models: row.models, - hasUnattributed: Boolean(row.has_unattributed), - limit, - totals, - truncated: { - repositories: totals.repositories > limit, - owners: totals.owners > limit, - models: totals.models > limit, - }, - }; + [limit, limit, limit] + ); + const row = result.rows[0]; + const totals = { + repositories: number(row.repositories_total), + models: number(row.models_total), + owners: number(row.owners_total), + }; + return { + asOf: timestamp(row.as_of)!, + repositoryScope: 'recorded_name', + repositories: row.repositories, + owners: row.owners, + models: row.models, + hasUnattributed: Boolean(row.has_unattributed), + limit, + totals, + truncated: { + repositories: totals.repositories > limit, + owners: totals.owners > limit, + models: totals.models > limit, + }, + }; + }); } } diff --git a/src/server/services/analytics/EnvironmentAnalyticsService.test.ts b/src/server/services/analytics/EnvironmentAnalyticsService.test.ts index bc95b4c1..6d4d94ae 100644 --- a/src/server/services/analytics/EnvironmentAnalyticsService.test.ts +++ b/src/server/services/analytics/EnvironmentAnalyticsService.test.ts @@ -183,7 +183,7 @@ describe('EnvironmentAnalyticsService contracts', () => { }) ); const { service, queries } = serviceWithExecutor((query) => { - if (query.sql.startsWith('SET TRANSACTION')) return { rows: [] }; + if (query.sql.startsWith('SET ')) return { rows: [] }; if (query.sql.includes('CURRENT_TIMESTAMP')) return [{ asOf: range.asOf }]; const lastId = Number(query.bindings.at(-2)); return rows.filter((record) => record.id > lastId).slice(0, 1000); @@ -193,12 +193,13 @@ describe('EnvironmentAnalyticsService contracts', () => { expect(result.exceptions).toHaveLength(20); expect(result.truncated).toBe(true); expect(queries.filter((query) => query.sql.includes(' AS deploys'))).toHaveLength(2); - expect(queries[0].sql).toBe('SET TRANSACTION READ ONLY'); + expect(queries[0].sql).toBe('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY'); + expect(queries[1].sql).toBe("SET LOCAL statement_timeout = '10s'"); }); it('pages current records without a full readiness scan when no phase filter is requested', async () => { const { service, queries } = serviceWithExecutor((query) => { - if (query.sql.startsWith('SET TRANSACTION')) return { rows: [] }; + if (query.sql.startsWith('SET ')) return { rows: [] }; if (query.sql.includes('CURRENT_TIMESTAMP')) return [{ asOf: range.asOf }]; if (query.sql.includes('count("e"."id")')) return { total: '1005' }; return [row(26), row(27)]; @@ -216,9 +217,9 @@ describe('EnvironmentAnalyticsService contracts', () => { expect(projections[0].sql).not.toContain('"e"."id" >'); }); - it('deduplicates PR coverage with EXISTS and preserves independent source-repository aggregates', async () => { + it('deduplicates retained PR coverage in one relation and preserves independent source-repository aggregates', async () => { const { service, queries } = serviceWithExecutor((query) => { - if (query.sql.startsWith('SET TRANSACTION')) return { rows: [] }; + if (query.sql.startsWith('SET ')) return { rows: [] }; if (query.sql.includes(' AS deploys')) return [row(1), row(2), row(3, { repositoryId: null, repositoryAmbiguous: true })]; if (query.method === 'first') return { earliest: '2026-09-01T00:00:00Z' }; @@ -254,8 +255,10 @@ describe('EnvironmentAnalyticsService contracts', () => { }); expect(result.buckets.map((bucket) => bucket.firstSeenEnvironments)).toEqual([3, 0]); const coverage = queries.find((query) => query.sql.includes('AS covered'))!; - expect(coverage.sql).toContain('WHERE EXISTS ('); - expect(coverage.sql).toContain('coverage."pullRequestId" = p.id'); + expect(coverage.sql).toContain('left join (select distinct "coverage"."pullRequestId"'); + expect(coverage.sql).toContain('"coverage"."pullRequestId" = "p"."id"'); + expect(coverage.sql).toContain('count(*) FILTER (WHERE coverage."pullRequestId" IS NOT NULL) AS covered'); + expect(coverage.sql).not.toContain('EXISTS'); expect(coverage.sql).not.toContain('"coverage"."createdAt"'); expect(coverage.sql).not.toContain('join "builds"'); const envs = queries.find((query) => query.sql.includes('AS ambiguous'))!; @@ -265,7 +268,7 @@ describe('EnvironmentAnalyticsService contracts', () => { it('returns null PR measures for static-only scope instead of a false zero', async () => { const { service } = serviceWithExecutor((query) => { - if (query.sql.startsWith('SET TRANSACTION')) return { rows: [] }; + if (query.sql.startsWith('SET ')) return { rows: [] }; if (query.sql.includes(' AS deploys')) return []; if (query.method === 'first') return { earliest: null }; return []; @@ -278,12 +281,13 @@ describe('EnvironmentAnalyticsService contracts', () => { it('adds independent managed Service coverage and per-environment instances without changing readiness', async () => { const { service, queries } = serviceWithExecutor((query) => { - if (query.sql.startsWith('SET TRANSACTION')) return { rows: [] }; + if (query.sql.startsWith('SET ')) return { rows: [] }; if (query.sql.includes('CURRENT_TIMESTAMP')) return [{ asOf: range.asOf }]; if (query.sql.includes(' AS deploys')) return [row(1), row(2, { repositoryId: null })]; if (query.sql.includes('AS "environmentsWithServices"')) return [ { + isTotal: 1, distinctServices: '1', instances: '3', readyInstances: '2', @@ -293,6 +297,7 @@ describe('EnvironmentAnalyticsService contracts', () => { buildOnlyInstances: '2', unknownTypeInstances: '0', }, + { isTotal: 0, type: 'docker', distinctServices: '1', instances: '3', readyInstances: '2' }, ]; if (query.sql.includes('group by "s"."environmentId"')) return [ @@ -318,11 +323,45 @@ describe('EnvironmentAnalyticsService contracts', () => { excluded: { externalInstances: 1, buildOnlyInstances: 2, unknownTypeInstances: 0 }, }); expect(queries.some((query) => query.sql.includes('group by "s"."environmentId"'))).toBe(false); + const aggregates = queries.filter((query) => query.sql.includes('AS "environmentsWithServices"')); + expect(aggregates).toHaveLength(1); + expect(aggregates[0].sql).toContain('GROUPING(s.type) AS "isTotal"'); + expect(aggregates[0].sql).toContain('group by GROUPING SETS ((s.type), ())'); + }); + + it('keeps a real null component type separate from the aggregate total', async () => { + const { service } = serviceWithExecutor((query) => { + if (query.sql.startsWith('SET ')) return { rows: [] }; + if (query.sql.includes('CURRENT_TIMESTAMP')) return [{ asOf: range.asOf }]; + if (query.sql.includes(' AS deploys')) return []; + return [ + { isTotal: 0, type: null, unknownTypeInstances: '2' }, + { isTotal: 0, type: 'helm', instances: '1', readyInstances: '0', distinctServices: '1' }, + { + isTotal: 1, + type: null, + instances: '1', + readyInstances: '0', + distinctServices: '1', + environmentsWithServices: '1', + unresolvedIdentityInstances: '0', + unknownTypeInstances: '2', + }, + ]; + }); + const { services } = await service.getInventory(scope); + expect(services).toMatchObject({ instances: 1, distinctServices: 1, excluded: { unknownTypeInstances: 2 } }); + expect(services.byType.find((row) => row.type === 'helm')).toEqual({ + type: 'helm', + instances: 1, + readyInstances: 0, + distinctServices: 1, + }); }); it('SQL-pages stable configured Service groups and converts independent nullable identities', async () => { const { service, queries } = serviceWithExecutor((query) => { - if (query.sql.startsWith('SET TRANSACTION')) return { rows: [] }; + if (query.sql.startsWith('SET ')) return { rows: [] }; if (query.sql.includes('CURRENT_TIMESTAMP')) return [{ asOf: range.asOf }]; if (query.method === 'first') return { total: '30' }; return [ @@ -372,7 +411,7 @@ describe('EnvironmentAnalyticsService contracts', () => { [999_999, 1_000_001, true, true], ])('reports Services paging bounds at page %i with total %i', async (page, total, hasMore, truncated) => { const { service, queries } = serviceWithExecutor((query) => { - if (query.sql.startsWith('SET TRANSACTION')) return { rows: [] }; + if (query.sql.startsWith('SET ')) return { rows: [] }; if (query.sql.includes('CURRENT_TIMESTAMP')) return [{ asOf: range.asOf }]; if (query.method === 'first') return { total: String(total) }; return [ @@ -402,7 +441,7 @@ describe('EnvironmentAnalyticsService contracts', () => { it('loads service counts only for the current environment page and leaves historical counts unknown', async () => { const { service, queries } = serviceWithExecutor((query) => { - if (query.sql.startsWith('SET TRANSACTION')) return { rows: [] }; + if (query.sql.startsWith('SET ')) return { rows: [] }; if (query.sql.includes('CURRENT_TIMESTAMP')) return [{ asOf: range.asOf }]; if (query.method === 'first') return { total: '1' }; if (query.sql.includes('service_deployables')) return [{ environmentId: 26, type: 'helm', instances: '2' }]; @@ -417,8 +456,13 @@ describe('EnvironmentAnalyticsService contracts', () => { expect(serviceQuery.sql).toContain('a."buildUUID" = e.uuid'); expect(serviceQuery.sql).toContain('a."deletedAt" IS NULL'); expect(serviceQuery.sql).toContain('d."deletedAt" IS NULL'); - expect(serviceQuery.sql).toContain('PARTITION BY a."buildId", a.name ORDER BY a.id DESC'); - expect(serviceQuery.sql).toContain('PARTITION BY d."buildId", d."deployableId" ORDER BY d.id DESC'); + expect(serviceQuery.sql).toContain('SELECT max(a.id) AS id'); + expect(serviceQuery.sql).toContain('GROUP BY a."buildId", a.name'); + expect(serviceQuery.sql).toContain('selected.id = a.id'); + expect(serviceQuery.sql).toContain('SELECT max(d.id) AS id'); + expect(serviceQuery.sql).toContain('GROUP BY d."buildId", d."deployableId"'); + expect(serviceQuery.sql).toContain('selected.id = d.id'); + expect(serviceQuery.sql).not.toContain('row_number()'); expect(serviceQuery.bindings).toEqual( expect.arrayContaining(['torn_down', 'docker', 'github', 'helm', 'aurora-restore']) ); diff --git a/src/server/services/analytics/EnvironmentAnalyticsService.ts b/src/server/services/analytics/EnvironmentAnalyticsService.ts index 01e1899c..5e944322 100644 --- a/src/server/services/analytics/EnvironmentAnalyticsService.ts +++ b/src/server/services/analytics/EnvironmentAnalyticsService.ts @@ -17,6 +17,7 @@ import { defaultDb } from 'server/lib/dependencies'; import { BadRequestError } from 'server/lib/appError'; import { getEnvironmentPhase, type EnvironmentPhase, type ReadinessDeploy } from 'server/lib/environments/readiness'; import { BuildKind, BuildStatus, DeployStatus, DeployTypes } from 'shared/constants'; +import { analyticsTransaction } from './execution'; import { analyticsBucketDates, analyticsBucketExpression, @@ -446,29 +447,38 @@ function serviceInstances(knex: Knex, scope: EnvironmentAnalyticsScope, environm 'r.githubInstallationId' ); if (environmentIds) environments.whereIn('e.id', environmentIds); + const latestDeployables = knex.raw(` + SELECT max(a.id) AS id + FROM deployables a JOIN service_environments e ON a."buildId" = e."environmentId" AND a."buildUUID" = e.uuid + WHERE a."deletedAt" IS NULL AND NULLIF(btrim(a.name), '') IS NOT NULL + GROUP BY a."buildId", a.name + `); const deployables = knex.raw(` SELECT a.id, a.name, a.type, a.active, a."serviceId", e.*, COALESCE(NULLIF(a."resolvedFromRepositoryId", 0)::bigint, CASE WHEN btrim(a."repositoryId") ~ '^[0-9]{1,10}$' THEN NULLIF(btrim(a."repositoryId")::bigint, 0) END) - AS "sourceGithubRepositoryId", - row_number() OVER (PARTITION BY a."buildId", a.name ORDER BY a.id DESC) AS ordinal - FROM deployables a JOIN service_environments e ON a."buildId" = e."environmentId" AND a."buildUUID" = e.uuid - WHERE a."deletedAt" IS NULL AND NULLIF(btrim(a.name), '') IS NOT NULL + AS "sourceGithubRepositoryId" + FROM deployables a JOIN service_latest_deployable_ids selected ON selected.id = a.id + JOIN service_environments e ON a."buildId" = e."environmentId" `); - const deploys = knex.raw(` - SELECT d."deployableId", d.status, - row_number() OVER (PARTITION BY d."buildId", d."deployableId" ORDER BY d.id DESC) AS ordinal + const latestDeploys = knex.raw(` + SELECT max(d.id) AS id FROM deploys d JOIN service_deployables a ON a.id = d."deployableId" AND a."environmentId" = d."buildId" - WHERE a.ordinal = 1 AND a.active = true AND d.active = true AND d."deletedAt" IS NULL + WHERE a.active = true AND d.active = true AND d."deletedAt" IS NULL + GROUP BY d."buildId", d."deployableId" + `); + const deploys = knex.raw(` + SELECT d."deployableId", d.status + FROM deploys d JOIN service_latest_deploy_ids selected ON selected.id = d.id `); return knex .with('service_environments', environments) + .with('service_latest_deployable_ids', latestDeployables) .with('service_deployables', deployables) + .with('service_latest_deploy_ids', latestDeploys) .with('service_deploys', deploys) .from('service_deployables as s') .join('service_deploys as d', 'd.deployableId', 's.id') - .where('s.ordinal', 1) - .where('d.ordinal', 1) .where('s.active', true) .whereRaw('(d.status IS NULL OR d.status <> ?)', [DeployStatus.TORN_DOWN]); } @@ -535,13 +545,7 @@ export default class EnvironmentAnalyticsService { constructor(private readonly db: Pick = defaultDb) {} private async transaction(work: (knex: Knex) => Promise): Promise { - return this.db.knex.transaction( - async (trx) => { - await trx.raw('SET TRANSACTION READ ONLY'); - return work(trx); - }, - { isolationLevel: 'repeatable read' } - ); + return analyticsTransaction(this.db.knex, work); } private async visitCurrent( @@ -625,9 +629,11 @@ export default class EnvironmentAnalyticsService { private async serviceInventory(knex: Knex, scope: EnvironmentAnalyticsScope): Promise { const result = emptyServiceInventory(); - const [totals] = await serviceInstances(knex, scope).select( - knex.raw( - ` + const rows = await serviceInstances(knex, scope) + .select( + knex.raw( + ` + s.type, GROUPING(s.type) AS "isTotal", count(*) FILTER (WHERE s.type = ANY(?::text[])) AS instances, count(DISTINCT ${serviceKeySql}) FILTER (WHERE s.type = ANY(?::text[]) AND s."repositoryId" IS NOT NULL) AS "distinctServices", count(*) FILTER (WHERE s.type = ANY(?::text[]) AND d.status = ?) AS "readyInstances", @@ -637,30 +643,22 @@ export default class EnvironmentAnalyticsService { count(*) FILTER (WHERE s.type = ANY(?::text[])) AS "buildOnlyInstances", count(*) FILTER (WHERE s.type IS NULL OR s.type <> ALL(?::text[])) AS "unknownTypeInstances" `, - [ - MANAGED_SERVICE_TYPES, - MANAGED_SERVICE_TYPES, - MANAGED_SERVICE_TYPES, - DeployStatus.READY, - MANAGED_SERVICE_TYPES, - MANAGED_SERVICE_TYPES, - DeployTypes.EXTERNAL_HTTP, - [DeployTypes.CONFIGURATION, DeployTypes.CODEFRESH], - [...MANAGED_SERVICE_TYPES, DeployTypes.EXTERNAL_HTTP, DeployTypes.CONFIGURATION, DeployTypes.CODEFRESH], - ] - ) - ); - const types = (await managedInstances(knex, scope) - .select('s.type') - .count('* as instances') - .select( - knex.raw( - `count(DISTINCT ${serviceKeySql}) FILTER (WHERE s."repositoryId" IS NOT NULL) AS "distinctServices", - count(*) FILTER (WHERE d.status = ?) AS "readyInstances"`, - [DeployStatus.READY] + [ + MANAGED_SERVICE_TYPES, + MANAGED_SERVICE_TYPES, + MANAGED_SERVICE_TYPES, + DeployStatus.READY, + MANAGED_SERVICE_TYPES, + MANAGED_SERVICE_TYPES, + DeployTypes.EXTERNAL_HTTP, + [DeployTypes.CONFIGURATION, DeployTypes.CODEFRESH], + [...MANAGED_SERVICE_TYPES, DeployTypes.EXTERNAL_HTTP, DeployTypes.CONFIGURATION, DeployTypes.CODEFRESH], + ] ) ) - .groupBy('s.type')) as Array; + .groupByRaw('GROUPING SETS ((s.type), ())'); + const totals = rows.find((row) => Number(row.isTotal) === 1); + const types = rows.filter((row) => Number(row.isTotal) === 0) as Array; for (const field of [ 'distinctServices', 'instances', @@ -710,15 +708,16 @@ export default class EnvironmentAnalyticsService { .where('p.createdAt', '>=', period.fromUtc) .where('p.createdAt', '<', period.toUtc); const coverage = knex('builds as coverage') - .select(knex.raw('1')) - .whereRaw('coverage."pullRequestId" = p.id') + .distinct('coverage.pullRequestId') + .whereNotNull('coverage.pullRequestId') .where('coverage.kind', BuildKind.ENVIRONMENT); if (query.environmentType === 'ephemeral') coverage.whereRaw('COALESCE(coverage."isStatic", false) = false'); prRows = (await prs .clone() + .leftJoin(coverage.as('coverage'), 'coverage.pullRequestId', 'p.id') .select('p.repositoryId', 'r.fullName', 'r.githubInstallationId') .count('p.id as count') - .select(knex.raw('count(*) FILTER (WHERE EXISTS (?)) AS covered', [coverage])) + .select(knex.raw('count(*) FILTER (WHERE coverage."pullRequestId" IS NOT NULL) AS covered')) .groupBy('p.repositoryId', 'r.fullName', 'r.githubInstallationId')) as CountRow[]; prBuckets = (await prs .clone() diff --git a/src/server/services/analytics/EnvironmentLifetimeAnalyticsService.test.ts b/src/server/services/analytics/EnvironmentLifetimeAnalyticsService.test.ts index c95b9f64..df64d669 100644 --- a/src/server/services/analytics/EnvironmentLifetimeAnalyticsService.test.ts +++ b/src/server/services/analytics/EnvironmentLifetimeAnalyticsService.test.ts @@ -48,7 +48,7 @@ const range: ResolvedAnalyticsRange = { const compiler = knexFactory({ client: 'pg' }); function serviceWithRows(results: unknown[][]) { const raw = jest.fn((sql: string, bindings?: unknown[]) => { - if (sql.startsWith('SET TRANSACTION')) return Promise.resolve(); + if (sql.startsWith('SET ')) return Promise.resolve(); if (sql.includes('lifetime_source AS')) return Promise.resolve({ rows: results.shift() }); if (sql === 'SELECT CURRENT_TIMESTAMP AS "asOf"') return Promise.resolve({ rows: [{ asOf: range.asOf }] }); return compiler.raw(sql, bindings as any); diff --git a/src/server/services/analytics/EnvironmentLifetimeAnalyticsService.ts b/src/server/services/analytics/EnvironmentLifetimeAnalyticsService.ts index 579a514e..291f16d8 100644 --- a/src/server/services/analytics/EnvironmentLifetimeAnalyticsService.ts +++ b/src/server/services/analytics/EnvironmentLifetimeAnalyticsService.ts @@ -14,6 +14,7 @@ import type { Knex } from 'knex'; import type Database from 'server/database'; import { defaultDb } from 'server/lib/dependencies'; import { BadRequestError } from 'server/lib/appError'; +import { analyticsTransaction } from './execution'; import { BuildStatus } from 'shared/constants'; import { environmentBase, @@ -288,8 +289,7 @@ export default class EnvironmentLifetimeAnalyticsService { constructor(private readonly db: Pick = defaultDb) {} async getSummary(query: EnvironmentAnalyticsQuery): Promise { - return this.db.knex.transaction(async (trx) => { - await trx.raw('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY'); + return analyticsTransaction(this.db.knex, async (trx) => { const range = await resolveAnalyticsRange(trx, query); const scope = scopeOf(query); const completed = factsSql(trx, scope, 'completed'); @@ -374,8 +374,7 @@ export default class EnvironmentLifetimeAnalyticsService { } async getRecords(query: EnvironmentLifetimeRecordsQuery): Promise { - return this.db.knex.transaction(async (trx) => { - await trx.raw('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY'); + return analyticsTransaction(this.db.knex, async (trx) => { const range = query.cohort === 'completed' ? await resolveAnalyticsRange(trx, query) : null; const asOf = range?.asOf ?? timestamp((await trx.raw('SELECT CURRENT_TIMESTAMP AS "asOf"')).rows[0].asOf)!; const scope = scopeOf(query); diff --git a/src/server/services/analytics/execution.ts b/src/server/services/analytics/execution.ts new file mode 100644 index 00000000..70ac5967 --- /dev/null +++ b/src/server/services/analytics/execution.ts @@ -0,0 +1,21 @@ +/** + * 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 type { Knex } from 'knex'; + +export function analyticsTransaction(knex: Knex, work: (trx: Knex.Transaction) => Promise): Promise { + return knex.transaction(async (trx) => { + await trx.raw('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY'); + await trx.raw("SET LOCAL statement_timeout = '10s'"); + return work(trx); + }); +} From 94b58b2be4b27ea8f58772286024810cbf6c59fc Mon Sep 17 00:00:00 2001 From: vmelikyan Date: Sat, 3 Oct 2026 10:42:45 -0700 Subject: [PATCH 4/9] fix(analytics): reduce repeated component reads The inventory query can reach its time limit as the number of current environments increases. Read the selected component rows once and keep their latest deployment state for the result. Counts and filters stay the same. Return HTTP503 with analytics_timeout when PostgreSQL confirms a statement time limit. Keep other failures unchanged and exclude database details from the response. --- .../admin/analytics/agents/options/route.ts | 1 + .../api/v2/admin/analytics/agents/route.ts | 1 + .../v2/admin/analytics/agents/runs/route.ts | 1 + .../analytics/environments/records/route.ts | 1 + .../analytics/environments/route.test.ts | 22 +++++ .../v2/admin/analytics/environments/route.ts | 1 + .../api/v2/admin/analytics/inventory/route.ts | 1 + .../analytics/lifetimes/records/route.ts | 1 + .../api/v2/admin/analytics/lifetimes/route.ts | 1 + .../api/v2/admin/analytics/options/route.ts | 1 + .../api/v2/admin/analytics/services/route.ts | 1 + .../EnvironmentAnalyticsService.test.ts | 30 ++++++- .../analytics/EnvironmentAnalyticsService.ts | 22 ++--- .../services/analytics/execution.test.ts | 80 +++++++++++++++++++ src/server/services/analytics/execution.ts | 31 +++++-- 15 files changed, 172 insertions(+), 23 deletions(-) create mode 100644 src/server/services/analytics/execution.test.ts diff --git a/src/app/api/v2/admin/analytics/agents/options/route.ts b/src/app/api/v2/admin/analytics/agents/options/route.ts index e0dfea97..4bde91a2 100644 --- a/src/app/api/v2/admin/analytics/agents/options/route.ts +++ b/src/app/api/v2/admin/analytics/agents/options/route.ts @@ -23,6 +23,7 @@ import AgentAnalyticsService from 'server/services/analytics/AgentAnalyticsServi * operationId: getAgentAnalyticsOptions * tags: [Admin Analytics] * responses: + * '503': { description: 'Analytics request did not complete before the time limit. Error code: analytics_timeout.', content: { application/json: { schema: { $ref: '#/components/schemas/ApiErrorResponse' } } } } * '200': * description: Recorded-name options with independent truncation flags. * content: diff --git a/src/app/api/v2/admin/analytics/agents/route.ts b/src/app/api/v2/admin/analytics/agents/route.ts index 39f1d2e4..7eea13b7 100644 --- a/src/app/api/v2/admin/analytics/agents/route.ts +++ b/src/app/api/v2/admin/analytics/agents/route.ts @@ -37,6 +37,7 @@ import AgentAnalyticsService, { parseAgentAnalyticsQuery } from 'server/services * - {in: query, name: provider, schema: {type: string}} * - {in: query, name: model, schema: {type: string}} * responses: + * '503': { description: 'Analytics request did not complete before the time limit. Error code: analytics_timeout.', content: { application/json: { schema: { $ref: '#/components/schemas/ApiErrorResponse' } } } } * '200': * description: Bounded aggregate data and coverage metadata. * content: diff --git a/src/app/api/v2/admin/analytics/agents/runs/route.ts b/src/app/api/v2/admin/analytics/agents/runs/route.ts index 17fa09cf..260fe646 100644 --- a/src/app/api/v2/admin/analytics/agents/runs/route.ts +++ b/src/app/api/v2/admin/analytics/agents/runs/route.ts @@ -42,6 +42,7 @@ import AgentAnalyticsService, { * - {in: query, name: page, schema: {type: integer, minimum: 1, maximum: 10000, default: 1}} * - {in: query, name: limit, schema: {type: integer, minimum: 1, maximum: 100, default: 25}} * responses: + * '503': { description: 'Analytics request did not complete before the time limit. Error code: analytics_timeout.', content: { application/json: { schema: { $ref: '#/components/schemas/ApiErrorResponse' } } } } * '200': * description: SQL-paginated run metadata and recorded usage. * content: diff --git a/src/app/api/v2/admin/analytics/environments/records/route.ts b/src/app/api/v2/admin/analytics/environments/records/route.ts index 93aa8e35..b6ad2790 100644 --- a/src/app/api/v2/admin/analytics/environments/records/route.ts +++ b/src/app/api/v2/admin/analytics/environments/records/route.ts @@ -43,6 +43,7 @@ export const runtime = 'nodejs'; * - { in: query, name: page, schema: { type: integer, minimum: 1, maximum: 1000000, default: 1 } } * - { in: query, name: limit, schema: { type: integer, minimum: 1, maximum: 100, default: 25 } } * responses: + * '503': { description: 'Analytics request did not complete before the time limit. Error code: analytics_timeout.', content: { application/json: { schema: { $ref: '#/components/schemas/ApiErrorResponse' } } } } * '200': * description: Analytics result. * content: diff --git a/src/app/api/v2/admin/analytics/environments/route.test.ts b/src/app/api/v2/admin/analytics/environments/route.test.ts index 4e08a207..48d3d3d8 100644 --- a/src/app/api/v2/admin/analytics/environments/route.test.ts +++ b/src/app/api/v2/admin/analytics/environments/route.test.ts @@ -1,4 +1,5 @@ import { NextRequest } from 'next/server'; +import { AppError } from 'server/lib/appError'; const mockIdentity = jest.fn(), mockGetUser = jest.fn(); @@ -131,4 +132,25 @@ describe('environment analytics administrator boundaries', () => { expect((await environments(request())).status).toBe(200); expect((await options(request())).status).toBe(200); }); + + it('returns the specific inventory time-limit response without database details', async () => { + mockInventory.mockRejectedValueOnce( + new AppError({ + httpStatus: 503, + code: 'analytics_timeout', + message: 'Analytics request did not complete before the time limit.', + nextAction: { kind: 'retry', label: 'Try again' }, + retryable: true, + cause: new Error('SELECT private_data'), + }) + ); + const response = await inventory(request()); + expect(response.status).toBe(503); + expect((await response.json()).error).toEqual({ + code: 'analytics_timeout', + message: 'Analytics request did not complete before the time limit.', + nextAction: { kind: 'retry', label: 'Try again' }, + }); + expect((await environments(request())).status).toBe(200); + }); }); diff --git a/src/app/api/v2/admin/analytics/environments/route.ts b/src/app/api/v2/admin/analytics/environments/route.ts index 0f6572b6..cde6b800 100644 --- a/src/app/api/v2/admin/analytics/environments/route.ts +++ b/src/app/api/v2/admin/analytics/environments/route.ts @@ -40,6 +40,7 @@ export const runtime = 'nodejs'; * - { in: query, name: interval, schema: { type: string, enum: [day, week], default: day } } * - { in: query, name: rankBy, schema: { type: string, enum: [first_seen, observed_prs, pr_coverage], default: first_seen } } * responses: + * '503': { description: 'Analytics request did not complete before the time limit. Error code: analytics_timeout.', content: { application/json: { schema: { $ref: '#/components/schemas/ApiErrorResponse' } } } } * '200': * description: Analytics result. * content: diff --git a/src/app/api/v2/admin/analytics/inventory/route.ts b/src/app/api/v2/admin/analytics/inventory/route.ts index d437a3ae..f342c039 100644 --- a/src/app/api/v2/admin/analytics/inventory/route.ts +++ b/src/app/api/v2/admin/analytics/inventory/route.ts @@ -36,6 +36,7 @@ export const runtime = 'nodejs'; * responses: + * '503': { description: 'Analytics request did not complete before the time limit. Error code: analytics_timeout.', content: { application/json: { schema: { $ref: '#/components/schemas/ApiErrorResponse' } } } } * '200': * description: Analytics result. * content: diff --git a/src/app/api/v2/admin/analytics/lifetimes/records/route.ts b/src/app/api/v2/admin/analytics/lifetimes/records/route.ts index a9a4db08..baa0b160 100644 --- a/src/app/api/v2/admin/analytics/lifetimes/records/route.ts +++ b/src/app/api/v2/admin/analytics/lifetimes/records/route.ts @@ -44,6 +44,7 @@ export const runtime = 'nodejs'; * - { in: query, name: page, schema: { type: integer, minimum: 1, maximum: 10000, default: 1 } } * - { in: query, name: limit, schema: { type: integer, minimum: 1, maximum: 100, default: 25 } } * responses: + * '503': { description: 'Analytics request did not complete before the time limit. Error code: analytics_timeout.', content: { application/json: { schema: { $ref: '#/components/schemas/ApiErrorResponse' } } } } * '200': * description: Lifetime analytics result. * content: diff --git a/src/app/api/v2/admin/analytics/lifetimes/route.ts b/src/app/api/v2/admin/analytics/lifetimes/route.ts index 81160819..d64f83b8 100644 --- a/src/app/api/v2/admin/analytics/lifetimes/route.ts +++ b/src/app/api/v2/admin/analytics/lifetimes/route.ts @@ -38,6 +38,7 @@ export const runtime = 'nodejs'; * - { in: query, name: compare, schema: { type: boolean, default: true } } * - { in: query, name: interval, schema: { type: string, enum: [day, week], default: day } } * responses: + * '503': { description: 'Analytics request did not complete before the time limit. Error code: analytics_timeout.', content: { application/json: { schema: { $ref: '#/components/schemas/ApiErrorResponse' } } } } * '200': * description: Lifetime analytics result. * content: diff --git a/src/app/api/v2/admin/analytics/options/route.ts b/src/app/api/v2/admin/analytics/options/route.ts index 723d9308..6ed5e0d8 100644 --- a/src/app/api/v2/admin/analytics/options/route.ts +++ b/src/app/api/v2/admin/analytics/options/route.ts @@ -36,6 +36,7 @@ export const runtime = 'nodejs'; * responses: + * '503': { description: 'Analytics request did not complete before the time limit. Error code: analytics_timeout.', content: { application/json: { schema: { $ref: '#/components/schemas/ApiErrorResponse' } } } } * '200': * description: Analytics result. * content: diff --git a/src/app/api/v2/admin/analytics/services/route.ts b/src/app/api/v2/admin/analytics/services/route.ts index ddd0c457..8ee286b1 100644 --- a/src/app/api/v2/admin/analytics/services/route.ts +++ b/src/app/api/v2/admin/analytics/services/route.ts @@ -37,6 +37,7 @@ export const runtime = 'nodejs'; * - { in: query, name: page, schema: { type: integer, minimum: 1, maximum: 1000000, default: 1 } } * - { in: query, name: limit, schema: { type: integer, minimum: 1, maximum: 100, default: 25 } } * responses: + * '503': { description: 'Analytics request did not complete before the time limit. Error code: analytics_timeout.', content: { application/json: { schema: { $ref: '#/components/schemas/ApiErrorResponse' } } } } * '200': * description: Current managed Service records. * content: diff --git a/src/server/services/analytics/EnvironmentAnalyticsService.test.ts b/src/server/services/analytics/EnvironmentAnalyticsService.test.ts index 6d4d94ae..5ae04198 100644 --- a/src/server/services/analytics/EnvironmentAnalyticsService.test.ts +++ b/src/server/services/analytics/EnvironmentAnalyticsService.test.ts @@ -405,6 +405,28 @@ describe('EnvironmentAnalyticsService contracts', () => { expect(pageQuery.sql).not.toContain('"e"."createdAt" >='); }); + it('carries selected deployment state into aggregation without rejoining the selected component relation', async () => { + const { service, queries } = serviceWithExecutor((query) => { + if (query.sql.startsWith('SET ')) return { rows: [] }; + if (query.sql.includes('CURRENT_TIMESTAMP')) return [{ asOf: range.asOf }]; + if (query.method === 'first') return { total: '0' }; + return []; + }); + await service.getServices(parseManagedServiceRecordsQuery(new URLSearchParams())); + const selectedQueries = queries.filter((query) => query.sql.includes('service_instances')); + expect(selectedQueries).toHaveLength(2); + for (const { sql } of selectedQueries) { + expect(sql.match(/JOIN service_deployables a/g)).toHaveLength(1); + expect(sql).toContain('SELECT DISTINCT ON (a.id) a.*, d.status AS "deployStatus"'); + expect(sql).toContain('WHERE a.active = true AND d.active = true AND d."deletedAt" IS NULL'); + expect(sql).toContain('ORDER BY a.id, d.id DESC'); + expect(sql).toContain('from "service_instances" as "s"'); + expect(sql).not.toContain('d.status <>'); + expect(sql).not.toContain('join "service_deploys"'); + expect(sql.indexOf('s."deployStatus" IS NULL')).toBeGreaterThan(sql.indexOf('ORDER BY a.id, d.id DESC')); + } + }); + it.each([ [1_000_000, 1_000_001, false, true], [1_000_000, 1_000_000, false, false], @@ -459,9 +481,11 @@ describe('EnvironmentAnalyticsService contracts', () => { expect(serviceQuery.sql).toContain('SELECT max(a.id) AS id'); expect(serviceQuery.sql).toContain('GROUP BY a."buildId", a.name'); expect(serviceQuery.sql).toContain('selected.id = a.id'); - expect(serviceQuery.sql).toContain('SELECT max(d.id) AS id'); - expect(serviceQuery.sql).toContain('GROUP BY d."buildId", d."deployableId"'); - expect(serviceQuery.sql).toContain('selected.id = d.id'); + expect(serviceQuery.sql).toContain('SELECT DISTINCT ON (a.id) a.*, d.status AS "deployStatus"'); + expect(serviceQuery.sql).toContain('ORDER BY a.id, d.id DESC'); + expect(serviceQuery.sql).toContain('from "service_instances" as "s"'); + expect(serviceQuery.sql).not.toContain('join "service_deploys"'); + expect(serviceQuery.sql).not.toContain('service_latest_deploy_ids'); expect(serviceQuery.sql).not.toContain('row_number()'); expect(serviceQuery.bindings).toEqual( expect.arrayContaining(['torn_down', 'docker', 'github', 'helm', 'aurora-restore']) diff --git a/src/server/services/analytics/EnvironmentAnalyticsService.ts b/src/server/services/analytics/EnvironmentAnalyticsService.ts index 5e944322..b0e2083a 100644 --- a/src/server/services/analytics/EnvironmentAnalyticsService.ts +++ b/src/server/services/analytics/EnvironmentAnalyticsService.ts @@ -461,26 +461,20 @@ function serviceInstances(knex: Knex, scope: EnvironmentAnalyticsScope, environm FROM deployables a JOIN service_latest_deployable_ids selected ON selected.id = a.id JOIN service_environments e ON a."buildId" = e."environmentId" `); - const latestDeploys = knex.raw(` - SELECT max(d.id) AS id + const instances = knex.raw(` + SELECT DISTINCT ON (a.id) a.*, d.status AS "deployStatus" FROM deploys d JOIN service_deployables a ON a.id = d."deployableId" AND a."environmentId" = d."buildId" WHERE a.active = true AND d.active = true AND d."deletedAt" IS NULL - GROUP BY d."buildId", d."deployableId" - `); - const deploys = knex.raw(` - SELECT d."deployableId", d.status - FROM deploys d JOIN service_latest_deploy_ids selected ON selected.id = d.id + ORDER BY a.id, d.id DESC `); return knex .with('service_environments', environments) .with('service_latest_deployable_ids', latestDeployables) .with('service_deployables', deployables) - .with('service_latest_deploy_ids', latestDeploys) - .with('service_deploys', deploys) - .from('service_deployables as s') - .join('service_deploys as d', 'd.deployableId', 's.id') + .with('service_instances', instances) + .from('service_instances as s') .where('s.active', true) - .whereRaw('(d.status IS NULL OR d.status <> ?)', [DeployStatus.TORN_DOWN]); + .whereRaw('(s."deployStatus" IS NULL OR s."deployStatus" <> ?)', [DeployStatus.TORN_DOWN]); } function managedInstances(knex: Knex, scope: EnvironmentAnalyticsScope, environmentIds?: number[]): Knex.QueryBuilder { @@ -636,7 +630,7 @@ export default class EnvironmentAnalyticsService { s.type, GROUPING(s.type) AS "isTotal", count(*) FILTER (WHERE s.type = ANY(?::text[])) AS instances, count(DISTINCT ${serviceKeySql}) FILTER (WHERE s.type = ANY(?::text[]) AND s."repositoryId" IS NOT NULL) AS "distinctServices", - count(*) FILTER (WHERE s.type = ANY(?::text[]) AND d.status = ?) AS "readyInstances", + count(*) FILTER (WHERE s.type = ANY(?::text[]) AND s."deployStatus" = ?) AS "readyInstances", count(DISTINCT s."environmentId") FILTER (WHERE s.type = ANY(?::text[])) AS "environmentsWithServices", count(*) FILTER (WHERE s.type = ANY(?::text[]) AND s."repositoryId" IS NULL) AS "unresolvedIdentityInstances", count(*) FILTER (WHERE s.type = ?) AS "externalInstances", @@ -877,7 +871,7 @@ export default class EnvironmentAnalyticsService { `${serviceKeySql} AS key, CASE WHEN count(DISTINCT s."sourceGithubRepositoryId") = 1 THEN min(s."sourceGithubRepositoryId") END AS "sourceGithubRepositoryId", min(s."serviceId") AS "serviceId", (s."repositoryId" IS NOT NULL) AS "identityResolved", - count(*) AS instances, count(*) FILTER (WHERE d.status = ?) AS "readyInstances", + count(*) AS instances, count(*) FILTER (WHERE s."deployStatus" = ?) AS "readyInstances", count(DISTINCT s."environmentId") AS environments`, [DeployStatus.READY] ) diff --git a/src/server/services/analytics/execution.test.ts b/src/server/services/analytics/execution.test.ts new file mode 100644 index 00000000..8a06dd91 --- /dev/null +++ b/src/server/services/analytics/execution.test.ts @@ -0,0 +1,80 @@ +/** + * 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 type { Knex } from 'knex'; +import { AppError, toErrorResponseError } from 'server/lib/appError'; +import { analyticsTransaction } from './execution'; + +function database() { + const raw = jest.fn().mockResolvedValue(undefined); + const trx = { raw } as unknown as Knex.Transaction; + const knex = { transaction: jest.fn(async (work) => work(trx)) } as unknown as Knex; + return { knex, trx, raw }; +} + +describe('Analytics transaction failures', () => { + it('keeps a read-only snapshot and statement limit before returning successful data', async () => { + const { knex, trx, raw } = database(); + const work = jest.fn().mockResolvedValue({ count: 0 }); + await expect(analyticsTransaction(knex, work)).resolves.toEqual({ count: 0 }); + expect(raw.mock.calls).toEqual([ + ['SET TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY'], + ["SET LOCAL statement_timeout = '10s'"], + ]); + expect(work).toHaveBeenCalledWith(trx); + }); + + it('returns a safe, specific unavailable error for a database statement timeout', async () => { + const { knex } = database(); + const original = Object.assign(new Error('SELECT private_column -- canceling statement due to statement timeout'), { + code: '57014', + }); + const error = await analyticsTransaction(knex, async () => { + throw original; + }).catch((failure) => failure); + expect(error).toBeInstanceOf(AppError); + expect(error).toMatchObject({ httpStatus: 503, code: 'analytics_timeout', retryable: true, cause: original }); + expect(toErrorResponseError(error)).toEqual({ + message: 'Analytics request did not complete before the time limit.', + code: 'analytics_timeout', + nextAction: { kind: 'retry', label: 'Try again' }, + }); + }); + + it.each([ + Object.assign(new Error('canceling statement due to user request'), { code: '57014' }), + Object.assign( + new Error("SELECT 'canceling statement due to statement timeout' - canceling statement due to user request"), + { code: '57014' } + ), + Object.assign(new Error('canceling statement due to statement timeout'), { code: '55P03' }), + new Error('canceling statement due to statement timeout'), + new Error('Connection failed'), + null, + ])('preserves failures that do not establish a statement timeout: %p', async (original) => { + const { knex } = database(); + await expect( + analyticsTransaction(knex, async () => { + throw original; + }) + ).rejects.toBe(original); + }); + + it('does not start work when transaction setup fails', async () => { + const { knex, raw } = database(); + const original = new Error('Connection failed'); + raw.mockRejectedValueOnce(original); + const work = jest.fn(); + await expect(analyticsTransaction(knex, work)).rejects.toBe(original); + expect(work).not.toHaveBeenCalled(); + }); +}); diff --git a/src/server/services/analytics/execution.ts b/src/server/services/analytics/execution.ts index 70ac5967..db5fd609 100644 --- a/src/server/services/analytics/execution.ts +++ b/src/server/services/analytics/execution.ts @@ -11,11 +11,30 @@ */ import type { Knex } from 'knex'; +import { AppError } from 'server/lib/appError'; -export function analyticsTransaction(knex: Knex, work: (trx: Knex.Transaction) => Promise): Promise { - return knex.transaction(async (trx) => { - await trx.raw('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY'); - await trx.raw("SET LOCAL statement_timeout = '10s'"); - return work(trx); - }); +export async function analyticsTransaction(knex: Knex, work: (trx: Knex.Transaction) => Promise): Promise { + try { + return await knex.transaction(async (trx) => { + await trx.raw('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY'); + await trx.raw("SET LOCAL statement_timeout = '10s'"); + return work(trx); + }); + } catch (error) { + if ( + error instanceof Error && + (error as { code?: string }).code === '57014' && + error.message.endsWith('canceling statement due to statement timeout') + ) { + throw new AppError({ + httpStatus: 503, + code: 'analytics_timeout', + message: 'Analytics request did not complete before the time limit.', + nextAction: { kind: 'retry', label: 'Try again' }, + retryable: true, + cause: error, + }); + } + throw error; + } } From 616aca84e812abb47b3a71e363c25c7363f5b091 Mon Sep 17 00:00:00 2001 From: vmelikyan Date: Sun, 4 Oct 2026 11:00:23 -0700 Subject: [PATCH 5/9] feat(analytics): add session summaries and lifetime scatter data Count enabled optional components in current inventory. Add complete lifetime scatter data and current age groups. Add session summaries and exact session run filters. --- .../v2/admin/analytics/agents/route.test.ts | 40 +++- .../v2/admin/analytics/agents/runs/route.ts | 1 + .../admin/analytics/agents/sessions/route.ts | 77 ++++++ .../analytics/lifetimes/records/route.ts | 2 +- .../admin/analytics/lifetimes/route.test.ts | 33 ++- .../analytics/lifetimes/scatter/route.ts | 51 ++++ .../analytics/lifetimes/scatter/schemas.ts | 62 +++++ src/server/lib/v2RoutePolicyManifest.ts | 2 + .../analytics/AgentAnalyticsService.test.ts | 86 +++++++ .../analytics/AgentAnalyticsService.ts | 109 ++++++++- .../EnvironmentAnalyticsService.test.ts | 37 ++- .../analytics/EnvironmentAnalyticsService.ts | 3 +- ...nvironmentLifetimeAnalyticsService.test.ts | 149 ++++++++++++ .../EnvironmentLifetimeAnalyticsService.ts | 220 +++++++++++++++--- src/shared/agentAnalyticsOpenApi.ts | 21 ++ .../environmentAnalyticsOpenApi.test.ts | 46 ++++ .../environmentLifetimeAnalyticsOpenApi.ts | 31 ++- src/shared/openApiSpec.ts | 2 + 18 files changed, 925 insertions(+), 47 deletions(-) create mode 100644 src/app/api/v2/admin/analytics/agents/sessions/route.ts create mode 100644 src/app/api/v2/admin/analytics/lifetimes/scatter/route.ts create mode 100644 src/app/api/v2/admin/analytics/lifetimes/scatter/schemas.ts diff --git a/src/app/api/v2/admin/analytics/agents/route.test.ts b/src/app/api/v2/admin/analytics/agents/route.test.ts index 796b69ef..3d60e649 100644 --- a/src/app/api/v2/admin/analytics/agents/route.test.ts +++ b/src/app/api/v2/admin/analytics/agents/route.test.ts @@ -16,6 +16,7 @@ const mockGetUser = jest.fn(); const mockIdentity = jest.fn(); const mockSummary = jest.fn(); const mockRuns = jest.fn(); +const mockSessions = jest.fn(); const mockOptions = jest.fn(); jest.mock('server/lib/dependencies', () => ({ defaultDb: {} })); @@ -26,13 +27,17 @@ jest.mock('server/lib/get-user', () => ({ jest.mock('server/services/analytics/AgentAnalyticsService', () => ({ ...jest.requireActual('server/services/analytics/AgentAnalyticsService'), __esModule: true, - default: jest - .fn() - .mockImplementation(() => ({ getSummary: mockSummary, listRuns: mockRuns, getOptions: mockOptions })), + default: jest.fn().mockImplementation(() => ({ + getSummary: mockSummary, + listRuns: mockRuns, + listSessions: mockSessions, + getOptions: mockOptions, + })), })); import { GET as summary } from './route'; import { GET as runs } from './runs/route'; +import { GET as sessions } from './sessions/route'; import { GET as options } from './options/route'; function request(query = ''): NextRequest { @@ -51,6 +56,7 @@ describe('Agent analytics admin API boundaries', () => { mockIdentity.mockReturnValue({ userId: 'admin', roles: ['admin'] }); mockSummary.mockResolvedValue({ totals: { runs: 2 } }); mockRuns.mockResolvedValue({ runs: [], pagination: { page: 1, limit: 25, total: 0, hasMore: false } }); + mockSessions.mockResolvedValue({ sessions: [], pagination: { page: 1, limit: 25, total: 0, hasMore: false } }); mockOptions.mockResolvedValue({ repositories: [], owners: [], models: [] }); }); afterEach(() => { @@ -61,6 +67,7 @@ describe('Agent analytics admin API boundaries', () => { it.each([ ['summary', summary], ['runs', runs], + ['sessions', sessions], ['options', options], ] as const)('prevents caching successful admin %s responses', async (_name, handler) => { const response = await handler(request()); @@ -72,18 +79,21 @@ describe('Agent analytics admin API boundaries', () => { it.each([ ['summary', summary], ['runs', runs], + ['sessions', sessions], ['options', options], ] as const)('rejects unauthenticated %s requests before data access', async (_name, handler) => { mockIdentity.mockReturnValue(null); expect((await handler(request())).status).toBe(401); expect(mockSummary).not.toHaveBeenCalled(); expect(mockRuns).not.toHaveBeenCalled(); + expect(mockSessions).not.toHaveBeenCalled(); expect(mockOptions).not.toHaveBeenCalled(); }); it.each([ ['summary', summary], ['runs', runs], + ['sessions', sessions], ['options', options], ] as const)('rejects non-admin %s requests before data access', async (_name, handler) => { mockGetUser.mockReturnValue({ sub: 'user', realm_access: { roles: ['user'] } }); @@ -91,12 +101,14 @@ describe('Agent analytics admin API boundaries', () => { expect((await handler(request())).status).toBe(403); expect(mockSummary).not.toHaveBeenCalled(); expect(mockRuns).not.toHaveBeenCalled(); + expect(mockSessions).not.toHaveBeenCalled(); expect(mockOptions).not.toHaveBeenCalled(); }); it.each([ ['summary', summary], ['runs', runs], + ['sessions', sessions], ['options', options], ] as const)('rejects API-key bearer credentials for %s', async (_name, handler) => { const req = request(); @@ -104,6 +116,7 @@ describe('Agent analytics admin API boundaries', () => { expect((await handler(req)).status).toBe(403); expect(mockSummary).not.toHaveBeenCalled(); expect(mockRuns).not.toHaveBeenCalled(); + expect(mockSessions).not.toHaveBeenCalled(); expect(mockOptions).not.toHaveBeenCalled(); }); @@ -141,6 +154,7 @@ describe('Agent analytics admin API boundaries', () => { mockRuns.mockClear(); expect((await runs(request('?limit=101'))).status).toBe(400); expect(mockRuns).not.toHaveBeenCalled(); + expect(mockSessions).not.toHaveBeenCalled(); }); it('returns filter options independently and propagates its own failure', async () => { @@ -150,4 +164,24 @@ describe('Agent analytics admin API boundaries', () => { expect((await options(request())).status).toBe(500); expect((await summary(request())).status).toBe(200); }); + it('paginates sessions using the Agent source scopes independently of a secondary run outcome', async () => { + expect( + (await sessions(request('?repository=org%2Frepo&owner=owner&page=2&limit=25&runStatus=failed'))).status + ).toBe(200); + expect(mockSessions).toHaveBeenCalledWith( + expect.objectContaining({ repository: 'org/repo', owner: 'owner' }), + 2, + 25 + ); + expect(mockSessions.mock.calls[0][0]).not.toHaveProperty('runStatus'); + }); + + it('rejects invalid session IDs and forwards valid run drawer scope', async () => { + const sessionId = '00000000-0000-4000-8000-000000000001'; + expect((await runs(request(`?sessionId=${sessionId}&page=2`))).status).toBe(200); + expect(mockRuns).toHaveBeenCalledWith(expect.objectContaining({ sessionId }), 2, 25); + mockRuns.mockClear(); + expect((await runs(request('?sessionId=invalid'))).status).toBe(400); + expect(mockRuns).not.toHaveBeenCalled(); + }); }); diff --git a/src/app/api/v2/admin/analytics/agents/runs/route.ts b/src/app/api/v2/admin/analytics/agents/runs/route.ts index 260fe646..ad3d9e40 100644 --- a/src/app/api/v2/admin/analytics/agents/runs/route.ts +++ b/src/app/api/v2/admin/analytics/agents/runs/route.ts @@ -38,6 +38,7 @@ import AgentAnalyticsService, { * - {in: query, name: agentId, schema: {type: string}} * - {in: query, name: provider, schema: {type: string}} * - {in: query, name: model, schema: {type: string}} + * - {in: query, name: sessionId, schema: {type: string, format: uuid}, description: Exact retained session UUID.} * - {in: query, name: runStatus, schema: {type: string, enum: [queued, starting, running, waiting_for_approval, waiting_for_input, transitioned, completed, failed, cancelled, unknown]}} * - {in: query, name: page, schema: {type: integer, minimum: 1, maximum: 10000, default: 1}} * - {in: query, name: limit, schema: {type: integer, minimum: 1, maximum: 100, default: 25}} diff --git a/src/app/api/v2/admin/analytics/agents/sessions/route.ts b/src/app/api/v2/admin/analytics/agents/sessions/route.ts new file mode 100644 index 00000000..d6224f8a --- /dev/null +++ b/src/app/api/v2/admin/analytics/agents/sessions/route.ts @@ -0,0 +1,77 @@ +/** + * 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 { successResponse, withNoStore } from 'server/lib/response'; +import AgentAnalyticsService, { + parseAgentAnalyticsQuery, + parseAgentAnalyticsPagination, +} from 'server/services/analytics/AgentAnalyticsService'; + +/** + * @openapi + * /api/v2/admin/analytics/agents/sessions: + * get: + * summary: List sessions with matching retained Agent runs + * operationId: getAgentAnalyticsSessions + * tags: [Admin Analytics] + * parameters: + * - {in: query, name: from, schema: {type: string, format: date}} + * - {in: query, name: to, schema: {type: string, format: date}} + * - {in: query, name: timezone, schema: {type: string, default: UTC}} + * - {in: query, name: compare, schema: {type: boolean, default: true}} + * - {in: query, name: interval, schema: {type: string, enum: [day, week], default: day}} + * - {in: query, name: repository, schema: {type: string}, description: Exact recorded owner/repository name or unattributed.} + * - {in: query, name: organization, schema: {type: string}} + * - {in: query, name: owner, schema: {type: string}} + * - {in: query, name: sessionKind, schema: {type: string, enum: [chat, environment, sandbox]}} + * - {in: query, name: agentId, schema: {type: string}} + * - {in: query, name: provider, schema: {type: string}} + * - {in: query, name: model, schema: {type: string}} + * - {in: query, name: page, schema: {type: integer, minimum: 1, maximum: 10000, default: 1}} + * - {in: query, name: limit, schema: {type: integer, minimum: 1, maximum: 100, default: 25}} + * responses: + * '503': { description: 'Analytics request did not complete before the time limit. Error code: analytics_timeout.', content: { application/json: { schema: { $ref: '#/components/schemas/ApiErrorResponse' } } } } + * '200': + * description: Session aggregates across all matching runs, grouped before pagination. + * content: + * application/json: + * schema: {$ref: '#/components/schemas/GetAgentAnalyticsSessionsSuccessResponse'} + * '400': + * description: Invalid range, scope, or pagination. + * content: + * application/json: + * schema: {$ref: '#/components/schemas/ApiErrorResponse'} + * '401': + * description: Authentication required. + * content: + * application/json: + * schema: {$ref: '#/components/schemas/ApiErrorResponse'} + * '403': + * description: Admin permission required. + * content: + * application/json: + * schema: {$ref: '#/components/schemas/ApiErrorResponse'} + */ +export const GET = createApiHandler( + async (req: NextRequest) => { + const { page, limit } = parseAgentAnalyticsPagination(req.nextUrl.searchParams); + const result = await new AgentAnalyticsService().listSessions( + parseAgentAnalyticsQuery(req.nextUrl.searchParams), + page, + limit + ); + return withNoStore(successResponse(result, { status: 200 }, req)); + }, + { auth: 'session', roles: ['admin'] } +); diff --git a/src/app/api/v2/admin/analytics/lifetimes/records/route.ts b/src/app/api/v2/admin/analytics/lifetimes/records/route.ts index baa0b160..54d8f7e7 100644 --- a/src/app/api/v2/admin/analytics/lifetimes/records/route.ts +++ b/src/app/api/v2/admin/analytics/lifetimes/records/route.ts @@ -40,7 +40,7 @@ export const runtime = 'nodejs'; * - { in: query, name: interval, schema: { type: string, enum: [day, week], default: day } } * - { in: query, name: cohort, schema: { type: string, enum: [completed, current], default: completed }, description: Current age ignores all calendar controls and returns range null. } * - { in: query, name: group, schema: { type: string, enum: [all, api, pr, other], default: all }, description: Other is current age only. Completed all combines records without combining summary statistics. } - * - { in: query, name: bin, schema: { type: string, enum: [under_1h, 1_to_6h, 6_to_24h, 1_to_3d, 3_to_7d, 7_to_30d, 30d_plus, missing, invalid] }, description: Half-open duration hours or sample coverage bucket. Unplaced end dates are excluded from completed window records. } + * - { in: query, name: bin, schema: { type: string, enum: [under_1h, 1_to_6h, 6_to_24h, 1_to_3d, 3_to_7d, 7_to_30d, 30d_plus, 30_to_90d, 90_to_180d, 180d_plus, missing, invalid] }, description: Half-open duration hours or coverage bucket. The 30–90 day and older bands apply to current age only. The 30d_plus filter still selects all records aged at least 30 days. Unplaced end dates are excluded from completed window records. } * - { in: query, name: page, schema: { type: integer, minimum: 1, maximum: 10000, default: 1 } } * - { in: query, name: limit, schema: { type: integer, minimum: 1, maximum: 100, default: 25 } } * responses: diff --git a/src/app/api/v2/admin/analytics/lifetimes/route.test.ts b/src/app/api/v2/admin/analytics/lifetimes/route.test.ts index 613d6fa1..408759f6 100644 --- a/src/app/api/v2/admin/analytics/lifetimes/route.test.ts +++ b/src/app/api/v2/admin/analytics/lifetimes/route.test.ts @@ -3,7 +3,8 @@ import { NextRequest } from 'next/server'; const mockIdentity = jest.fn(), mockGetUser = jest.fn(); const mockSummary = jest.fn(), - mockRecords = jest.fn(); + mockRecords = jest.fn(), + mockScatter = jest.fn(); jest.mock('server/lib/dependencies', () => ({ defaultDb: {} })); jest.mock('server/lib/get-user', () => ({ getRequestUserIdentity: (...args: unknown[]) => mockIdentity(...args), @@ -12,14 +13,18 @@ jest.mock('server/lib/get-user', () => ({ jest.mock('server/services/analytics/EnvironmentLifetimeAnalyticsService', () => ({ ...jest.requireActual('server/services/analytics/EnvironmentLifetimeAnalyticsService'), __esModule: true, - default: jest.fn().mockImplementation(() => ({ getSummary: mockSummary, getRecords: mockRecords })), + default: jest + .fn() + .mockImplementation(() => ({ getSummary: mockSummary, getRecords: mockRecords, getScatter: mockScatter })), })); import { GET as summary } from './route'; import { GET as records } from './records/route'; +import { GET as scatter } from './scatter/route'; const handlers = [ ['lifetimes', summary], ['lifetime records', records], + ['lifetime scatter', scatter], ] as const; function request(query = ''): NextRequest { return { @@ -30,6 +35,7 @@ function request(query = ''): NextRequest { function expectNoAccess() { expect(mockSummary).not.toHaveBeenCalled(); expect(mockRecords).not.toHaveBeenCalled(); + expect(mockScatter).not.toHaveBeenCalled(); } describe('lifetime analytics administrator boundaries', () => { @@ -41,6 +47,7 @@ describe('lifetime analytics administrator boundaries', () => { mockGetUser.mockReturnValue({ sub: 'admin', realm_access: { roles: ['admin'] } }); mockSummary.mockResolvedValue({}); mockRecords.mockResolvedValue({}); + mockScatter.mockResolvedValue({}); }); afterEach(() => { if (originalAuth === undefined) delete process.env.ENABLE_AUTH; @@ -102,6 +109,28 @@ describe('lifetime analytics administrator boundaries', () => { ); expect(mockRecords.mock.calls[0][0].from).toBeUndefined(); }); + it('passes scatter scope and selected retirement window without previous-period points', async () => { + const response = await scatter( + request('?group=pr&from=2026-03-08&to=2026-03-10&compare=true&environmentType=ephemeral') + ); + expect(response.status).toBe(200); + expect(mockScatter).toHaveBeenCalledWith( + expect.objectContaining({ + group: 'pr', + from: '2026-03-08', + to: '2026-03-10', + compare: false, + environmentType: 'ephemeral', + }) + ); + }); + it.each(['?group=other', '?from=2026-02-30&to=2026-03-02', '?repositoryId=1&unattributed=true'])( + 'rejects invalid scatter query %s before queries', + async (query) => { + expect((await scatter(request(query))).status).toBe(400); + expectNoAccess(); + } + ); it.each([ '?group=other', '?bin=episode', diff --git a/src/app/api/v2/admin/analytics/lifetimes/scatter/route.ts b/src/app/api/v2/admin/analytics/lifetimes/scatter/route.ts new file mode 100644 index 00000000..a597b462 --- /dev/null +++ b/src/app/api/v2/admin/analytics/lifetimes/scatter/route.ts @@ -0,0 +1,51 @@ +/** + * 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 the following URL: + * https://www.apache.org/licenses/LICENSE-2.0 + */ + +import { NextRequest } from 'next/server'; +import { createApiHandler } from 'server/lib/createApiHandler'; +import { successResponse, withNoStore } from 'server/lib/response'; +import EnvironmentLifetimeAnalyticsService, { + parseEnvironmentLifetimeScatterQuery, +} from 'server/services/analytics/EnvironmentLifetimeAnalyticsService'; + +export const runtime = 'nodejs'; + +/** + * @openapi + * /api/v2/admin/analytics/lifetimes/scatter: + * get: + * summary: Get bounded recorded API lifetimes or PR lifetime estimates + * description: Administrator-only retained-record analytics. Points use retirement dates in the selected window. API durations use createdAt to deletedAt. Torn-down PR estimates use createdAt to updatedAt and can include inactive gaps or later edits. Up to 5000 valid points are returned in full. Above that limit, no points are returned and the scope must be narrowed. Previous-period points are not returned. + * tags: [Admin Analytics] + * operationId: getEnvironmentLifetimeScatter + * parameters: + * - { in: query, name: repositoryId, schema: { type: integer, minimum: 1 }, description: Stable installation-aware repository row ID. } + * - { in: query, name: unattributed, schema: { type: boolean, default: false }, description: Mutually exclusive with repositoryId. } + * - { in: query, name: organization, schema: { type: string, maxLength: 128 } } + * - { in: query, name: environmentType, schema: { type: string, enum: [all, ephemeral, static], default: all } } + * - { in: query, name: environmentAuthor, schema: { type: string, maxLength: 128 } } + * - { in: query, name: from, schema: { type: string, format: date }, description: Inclusive retirement calendar date. Defaults to 30 complete days before to. } + * - { in: query, name: to, schema: { type: string, format: date }, description: Exclusive retirement calendar date. Defaults to today in timezone. } + * - { in: query, name: timezone, schema: { type: string, default: UTC }, description: IANA timezone for retirement boundaries. } + * - { in: query, name: group, schema: { type: string, enum: [all, api, pr], default: all }, description: API recorded durations and PR estimates retain separate methods. } + * - { in: query, name: compare, schema: { type: boolean, default: false }, description: Accepted for shared-filter compatibility. Scatter always resolves compare false and returns selected-window points only. } + * - { in: query, name: interval, schema: { type: string, enum: [day, week], default: day }, description: Retained in range metadata. Points are individual environments and are not bucketed. } + * responses: + * '200': { description: 'Complete bounded scatter data or an explicit over-limit result.', content: { application/json: { schema: { $ref: '#/components/schemas/GetEnvironmentLifetimeScatterSuccessResponse' } } } } + * '400': { description: Invalid query, content: { application/json: { schema: { $ref: '#/components/schemas/ApiErrorResponse' } } } } + * '401': { description: Authentication required, content: { application/json: { schema: { $ref: '#/components/schemas/ApiErrorResponse' } } } } + * '403': { description: Administrator role required, content: { application/json: { schema: { $ref: '#/components/schemas/ApiErrorResponse' } } } } + * '503': { description: 'Analytics request did not complete before the time limit. Error code: analytics_timeout.', content: { application/json: { schema: { $ref: '#/components/schemas/ApiErrorResponse' } } } } + */ +const getHandler = async (req: NextRequest) => { + const query = parseEnvironmentLifetimeScatterQuery(req.nextUrl.searchParams); + const result = await new EnvironmentLifetimeAnalyticsService().getScatter(query); + return withNoStore(successResponse(result, { status: 200 }, req)); +}; + +export const GET = createApiHandler(getHandler, { auth: 'session', roles: ['admin'] }); diff --git a/src/app/api/v2/admin/analytics/lifetimes/scatter/schemas.ts b/src/app/api/v2/admin/analytics/lifetimes/scatter/schemas.ts new file mode 100644 index 00000000..6cfb932c --- /dev/null +++ b/src/app/api/v2/admin/analytics/lifetimes/scatter/schemas.ts @@ -0,0 +1,62 @@ +/** + * 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 the following URL: + * https://www.apache.org/licenses/LICENSE-2.0 + */ + +import { environmentLifetimeAnalyticsSchemas } from 'shared/environmentLifetimeAnalyticsOpenApi'; + +const ref = (name: string) => ({ $ref: `#/components/schemas/${name}` }); +const integer = { type: 'integer', minimum: 0 }; +const object = (properties: Record) => ({ + type: 'object', + properties, + required: Object.keys(properties), + additionalProperties: false, +}); + +export const environmentLifetimeScatterSchemas = { + EnvironmentLifetimeScatterPoint: object({ + ...environmentLifetimeAnalyticsSchemas.EnvironmentLifetimeRecord.properties, + group: { type: 'string', enum: ['api', 'pr'] }, + method: { type: 'string', enum: ['created_to_deleted', 'created_to_last_update'] }, + quality: { type: 'string', enum: ['recorded', 'estimated'] }, + sampleState: { type: 'string', enum: ['valid'] }, + durationHours: { type: 'number', minimum: 0 }, + measuredUntilAt: { type: 'string', format: 'date-time' }, + }), + EnvironmentLifetimeScatterCoverage: object({ + method: { type: 'string', enum: ['created_to_deleted', 'created_to_last_update'] }, + quality: { type: 'string', enum: ['recorded', 'estimated'] }, + eligible: integer, + samples: integer, + missing: integer, + invalid: integer, + }), + EnvironmentLifetimeScatter: object({ + asOf: { type: 'string', format: 'date-time' }, + scope: ref('EnvironmentAnalyticsScope'), + range: ref('AnalyticsRange'), + group: { type: 'string', enum: ['all', 'api', 'pr'] }, + total: { ...integer, description: 'All valid durations in the selected retirement window and scope.' }, + returned: { ...integer, maximum: 5000 }, + pointLimit: { type: 'integer', enum: [5000] }, + truncated: { type: 'boolean', description: 'Above the point limit, no points are returned. This is not a sample.' }, + state: { type: 'string', enum: ['ready', 'empty', 'over_limit'] }, + nextAction: { type: 'string', nullable: true }, + coverage: object({ + api: ref('EnvironmentLifetimeScatterCoverage'), + pr: ref('EnvironmentLifetimeScatterCoverage'), + }), + points: { type: 'array', maxItems: 5000, items: ref('EnvironmentLifetimeScatterPoint') }, + caveats: { type: 'array', items: { type: 'string' } }, + }), + GetEnvironmentLifetimeScatterSuccessResponse: { + allOf: [ + ref('SuccessApiResponse'), + { type: 'object', properties: { data: ref('EnvironmentLifetimeScatter') }, required: ['data'] }, + ], + }, +}; diff --git a/src/server/lib/v2RoutePolicyManifest.ts b/src/server/lib/v2RoutePolicyManifest.ts index f23a33e6..9e09aa57 100644 --- a/src/server/lib/v2RoutePolicyManifest.ts +++ b/src/server/lib/v2RoutePolicyManifest.ts @@ -36,11 +36,13 @@ export const V2_ROUTE_POLICY_MANIFEST: readonly V2RoutePolicyEntry[] = [ { method: 'GET', route: '/api/v2/admin/analytics/agents', policy: 'session', roles: ['admin'] }, { method: 'GET', route: '/api/v2/admin/analytics/agents/options', policy: 'session', roles: ['admin'] }, { method: 'GET', route: '/api/v2/admin/analytics/agents/runs', policy: 'session', roles: ['admin'] }, + { method: 'GET', route: '/api/v2/admin/analytics/agents/sessions', policy: 'session', roles: ['admin'] }, { method: 'GET', route: '/api/v2/admin/analytics/environments', policy: 'session', roles: ['admin'] }, { method: 'GET', route: '/api/v2/admin/analytics/environments/records', policy: 'session', roles: ['admin'] }, { method: 'GET', route: '/api/v2/admin/analytics/inventory', policy: 'session', roles: ['admin'] }, { method: 'GET', route: '/api/v2/admin/analytics/lifetimes', policy: 'session', roles: ['admin'] }, { method: 'GET', route: '/api/v2/admin/analytics/lifetimes/records', policy: 'session', roles: ['admin'] }, + { method: 'GET', route: '/api/v2/admin/analytics/lifetimes/scatter', policy: 'session', roles: ['admin'] }, { method: 'GET', route: '/api/v2/admin/analytics/options', policy: 'session', roles: ['admin'] }, { method: 'GET', route: '/api/v2/admin/analytics/services', policy: 'session', roles: ['admin'] }, { method: 'GET', route: '/api/v2/ai/admin/agent/capabilities', policy: 'session', roles: ['admin'] }, diff --git a/src/server/services/analytics/AgentAnalyticsService.test.ts b/src/server/services/analytics/AgentAnalyticsService.test.ts index 19c080ea..c5339ab1 100644 --- a/src/server/services/analytics/AgentAnalyticsService.test.ts +++ b/src/server/services/analytics/AgentAnalyticsService.test.ts @@ -298,6 +298,82 @@ describe('AgentAnalyticsService', () => { expect(sql).not.toContain('ORDER BY queued_at'); }); + it('aggregates all matching runs into a session before paging and keeps independent missing cost', async () => { + const { service, raw } = serviceWithRows([ + [ + { + total: 3, + sessions: [ + { + session_uuid: '00000000-0000-4000-8000-000000000001', + title: 'Review changes', + owner_id: 'owner', + owner_github_username: 'alice', + session_kind: 'chat', + session_status: 'active', + repositories: ['org/one', 'org/two'], + repository_count: 2, + first_submitted_at: range.fromUtc, + last_submitted_at: range.toUtc, + runs: 3, + sessions: 1, + owners: 1, + completed: 2, + failed: 1, + total_tokens: 0, + total_reported_runs: 1, + input_tokens: 7, + input_reported_runs: 2, + output_tokens: null, + output_reported_runs: 0, + estimated_cost: '0.02', + estimated_cost_runs: 2, + }, + ], + }, + ], + ]); + const result = await service.listSessions( + { repository: 'org/one', owner: 'owner', provider: 'openai', model: 'm' }, + 2, + 1 + ); + expect(result.pagination).toEqual({ page: 2, limit: 1, total: 3, hasMore: true }); + expect(result.sessions[0]).toMatchObject({ + runs: 3, + sessions: 1, + repositories: ['org/one', 'org/two'], + repositoryCount: 2, + outcomes: { completed: 2, failed: 1 }, + tokens: { total: 0, input: 7, output: null, missingRuns: 2 }, + reportedCost: { usd: null, coveredRuns: 0 }, + estimatedCost: { usd: 0.02, coveredRuns: 2 }, + }); + const [sql, bindings] = raw.mock.calls.filter(([sql]) => !sql.startsWith('SET '))[0]; + expect(sql.indexOf('GROUP BY session_id')).toBeLessThan(sql.indexOf('LIMIT ? OFFSET ?')); + expect(sql).toContain('WHERE position <= 5'); + expect(sql).toContain('t.id = s."defaultThreadId" AND t."sessionId" = s.id'); + expect(sql).toContain('ORDER BY last_submitted_at DESC, session_id DESC LIMIT ? OFFSET ?'); + expect(bindings).toEqual([range.fromUtc, range.toUtc, 'org/one', 'owner', 'openai', 'm', 1, 1]); + }); + + it('keeps session totals when a requested page is empty and validates direct pagination', async () => { + const { service } = serviceWithRows([[{ total: 1, sessions: [] }]]); + expect((await service.listSessions({}, 2)).pagination).toEqual({ page: 2, limit: 25, total: 1, hasMore: false }); + await expect(service.listSessions({}, 0)).rejects.toThrow('Invalid session pagination.'); + await expect(service.listSessions({}, 1, 101)).rejects.toThrow('Invalid session pagination.'); + }); + + it('applies an exact session UUID in the run source before pagination and retains all other scopes', async () => { + const sessionId = '00000000-0000-4000-8000-000000000001'; + const { service, raw } = serviceWithRows([[{ total: 0, runs: [] }]]); + await service.listRuns({ sessionId, repository: 'org/one', runStatus: 'failed' }, 3, 2); + const [sql, bindings] = raw.mock.calls.filter(([sql]) => !sql.startsWith('SET '))[0]; + expect(sql).toContain('AND s.uuid = ?::uuid'); + expect(sql.indexOf('AND s.uuid = ?::uuid')).toBeLessThan(sql.indexOf('LIMIT ? OFFSET ?')); + expect(bindings).toEqual([range.fromUtc, range.toUtc, sessionId, 'org/one', 'failed', 2, 4]); + }); + it('returns bounded independent options and truncation counts', async () => { const raw = jest.fn().mockResolvedValue({ rows: [ @@ -356,4 +432,14 @@ describe('Agent analytics input boundaries', () => { expect(() => parseAgentAnalyticsRunsQuery(new URLSearchParams('runStatus=success'))).toThrow(); expect(parseAgentAnalyticsRunsQuery(new URLSearchParams('runStatus=unknown')).runStatus).toBe('unknown'); }); + it('validates the exact session UUID without changing other Agent scopes', () => { + const sessionId = 'AAAAAAAA-0000-4000-8000-000000000001'; + expect(parseAgentAnalyticsRunsQuery(new URLSearchParams(`sessionId=${sessionId}&owner=owner`))).toMatchObject({ + sessionId: sessionId.toLowerCase(), + owner: 'owner', + }); + expect(() => parseAgentAnalyticsRunsQuery(new URLSearchParams('sessionId=bad'))).toThrow( + 'sessionId must be a UUID.' + ); + }); }); diff --git a/src/server/services/analytics/AgentAnalyticsService.ts b/src/server/services/analytics/AgentAnalyticsService.ts index 8986fed8..091c2565 100644 --- a/src/server/services/analytics/AgentAnalyticsService.ts +++ b/src/server/services/analytics/AgentAnalyticsService.ts @@ -49,6 +49,7 @@ export interface AgentAnalyticsQuery extends AnalyticsCalendarQuery { export interface AgentAnalyticsRunsQuery extends AgentAnalyticsQuery { runStatus?: AgentAnalyticsStatus; + sessionId?: string; } export interface AgentAnalyticsMetrics { @@ -128,6 +129,27 @@ export interface AgentAnalyticsRuns { pagination: { page: number; limit: number; total: number; hasMore: boolean }; } +export interface AgentAnalyticsSession extends AgentAnalyticsMetrics { + sessionId: string; + title: string | null; + ownerId: string; + ownerGithubUsername: string | null; + sessionKind: string; + sessionStatus: string; + repositories: string[]; + repositoryCount: number; + firstSubmittedAt: string; + lastSubmittedAt: string; +} + +export interface AgentAnalyticsSessions { + range: ResolvedAnalyticsRange; + asOf: string; + caveats: string[]; + sessions: AgentAnalyticsSession[]; + pagination: { page: number; limit: number; total: number; hasMore: boolean }; +} + export interface AgentAnalyticsOptions { asOf: string; repositoryScope: 'recorded_name'; @@ -191,7 +213,14 @@ export function parseAgentAnalyticsRunsQuery(params: URLSearchParams): AgentAnal const runStatus = filterValue(params, 'runStatus'); if (runStatus && !AGENT_ANALYTICS_STATUSES.includes(runStatus as AgentAnalyticsStatus)) throw new BadRequestError('runStatus must be a supported run outcome.'); - return { ...query, ...(runStatus ? { runStatus: runStatus as AgentAnalyticsStatus } : {}) }; + const sessionId = filterValue(params, 'sessionId'); + if (sessionId && !/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(sessionId)) + throw new BadRequestError('sessionId must be a UUID.'); + return { + ...query, + ...(runStatus ? { runStatus: runStatus as AgentAnalyticsStatus } : {}), + ...(sessionId ? { sessionId: sessionId.toLowerCase() } : {}), + }; } export function parseAgentAnalyticsPagination(params: URLSearchParams): { page: number; limit: number } { @@ -486,14 +515,22 @@ export default class AgentAnalyticsService { const scope = scopeSql(query); const statusCondition = query.runStatus ? `${scope.sql ? ' AND' : ' WHERE'} status = ?` : ''; const statusBindings = query.runStatus ? [query.runStatus] : []; + const sessionCondition = query.sessionId ? ' AND s.uuid = ?::uuid' : ''; const facts = `WITH facts AS MATERIALIZED ( SELECT ${RUN_DIMENSIONS}, ${USAGE_COLUMNS}, s."userId" AS owner_id, s."ownerGithubUsername" AS owner_github_username, s."sessionKind" AS session_kind, s.uuid AS session_uuid, t.uuid AS thread_uuid, ${jsonString(`r.error->'code'`)} AS error_code FROM agent_runs r JOIN agent_sessions s ON s.id = r."sessionId" JOIN agent_threads t ON t.id = r."threadId" WHERE r."createdAt" >= ?::timestamptz AND r."createdAt" < ?::timestamptz + ${sessionCondition} ), scoped AS MATERIALIZED (SELECT * FROM facts ${scope.sql}${statusCondition})`; - const bindings = [range.fromUtc, range.toUtc, ...scope.bindings, ...statusBindings]; + const bindings = [ + range.fromUtc, + range.toUtc, + ...(query.sessionId ? [query.sessionId] : []), + ...scope.bindings, + ...statusBindings, + ]; const result = await trx.raw( `${facts} SELECT (SELECT COUNT(*)::int FROM scoped) AS total, COALESCE(( @@ -537,6 +574,74 @@ export default class AgentAnalyticsService { }); } + async listSessions(query: AgentAnalyticsQuery, page = 1, limit = 25): Promise { + if (!Number.isInteger(page) || page < 1 || page > 10000 || !Number.isInteger(limit) || limit < 1 || limit > 100) + throw new BadRequestError('Invalid session pagination.'); + return analyticsTransaction(this.db.knex, async (trx) => { + const range = await resolveAnalyticsRange(trx, query); + const scope = scopeSql(query); + const result = await trx.raw( + `WITH facts AS MATERIALIZED ( + SELECT ${RUN_DIMENSIONS}, ${USAGE_COLUMNS}, s."userId" AS owner_id, s."sessionKind" AS session_kind + FROM agent_runs r JOIN agent_sessions s ON s.id = r."sessionId" + WHERE r."createdAt" >= ?::timestamptz AND r."createdAt" < ?::timestamptz + ), scoped AS MATERIALIZED (SELECT * FROM facts ${scope.sql}), + grouped AS MATERIALIZED ( + SELECT session_id, MIN(submitted_at) AS first_submitted_at, MAX(submitted_at) AS last_submitted_at, + ${AGGREGATES} + FROM scoped GROUP BY session_id + ), page_rows AS MATERIALIZED ( + SELECT * FROM grouped ORDER BY last_submitted_at DESC, session_id DESC LIMIT ? OFFSET ? + ), repository_names AS ( + SELECT session_id, repository, row_number() OVER (PARTITION BY session_id ORDER BY repository) AS position + FROM (SELECT DISTINCT scoped.session_id, repository FROM scoped + JOIN page_rows ON page_rows.session_id = scoped.session_id WHERE repository IS NOT NULL) names + ), repositories AS ( + SELECT session_id, COUNT(*)::int AS repository_count, + array_agg(repository ORDER BY repository) FILTER (WHERE position <= 5) AS repositories + FROM repository_names GROUP BY session_id + ) SELECT (SELECT COUNT(*)::int FROM grouped) AS total, COALESCE(( + SELECT jsonb_agg(records ORDER BY last_submitted_at DESC, session_id DESC) FROM ( + SELECT g.*, s.uuid AS session_uuid, NULLIF(BTRIM(t.title), '') AS title, + s."userId" AS owner_id, s."ownerGithubUsername" AS owner_github_username, + s."sessionKind" AS session_kind, s.status AS session_status, + COALESCE(repos.repository_count, 0) AS repository_count, + COALESCE(repos.repositories, ARRAY[]::text[]) AS repositories + FROM page_rows g JOIN agent_sessions s ON s.id = g.session_id + LEFT JOIN agent_threads t ON t.id = s."defaultThreadId" AND t."sessionId" = s.id + LEFT JOIN repositories repos ON repos.session_id = g.session_id + ) records + ), '[]'::jsonb) AS sessions`, + [range.fromUtc, range.toUtc, ...scope.bindings, limit, (page - 1) * limit] + ); + const row = result.rows[0]; + const total = number(row.total); + return { + range, + asOf: range.asOf, + caveats: [ + ...CAVEATS, + 'Session rows aggregate all matching runs before pagination; a session can appear in more than one repository, model, or date scope.', + 'Session titles and status reflect the retained current session; titles use its default conversation and repositories show at most five recorded names.', + ], + sessions: (row.sessions as SqlRow[]).map((session) => ({ + ...serializeAgentAnalyticsMetrics(session), + sessionId: String(session.session_uuid), + title: session.title == null ? null : String(session.title), + ownerId: String(session.owner_id), + ownerGithubUsername: session.owner_github_username == null ? null : String(session.owner_github_username), + sessionKind: String(session.session_kind), + sessionStatus: String(session.session_status), + repositories: session.repositories as string[], + repositoryCount: number(session.repository_count), + firstSubmittedAt: timestamp(session.first_submitted_at)!, + lastSubmittedAt: timestamp(session.last_submitted_at)!, + })), + pagination: { page, limit, total, hasMore: page * limit < total }, + }; + }); + } + async getOptions(): Promise { const limit = 200; return analyticsTransaction(this.db.knex, async (trx) => { diff --git a/src/server/services/analytics/EnvironmentAnalyticsService.test.ts b/src/server/services/analytics/EnvironmentAnalyticsService.test.ts index 5ae04198..58050156 100644 --- a/src/server/services/analytics/EnvironmentAnalyticsService.test.ts +++ b/src/server/services/analytics/EnvironmentAnalyticsService.test.ts @@ -418,7 +418,9 @@ describe('EnvironmentAnalyticsService contracts', () => { for (const { sql } of selectedQueries) { expect(sql.match(/JOIN service_deployables a/g)).toHaveLength(1); expect(sql).toContain('SELECT DISTINCT ON (a.id) a.*, d.status AS "deployStatus"'); - expect(sql).toContain('WHERE a.active = true AND d.active = true AND d."deletedAt" IS NULL'); + expect(sql).toContain('WHERE d.active = true AND d."deletedAt" IS NULL'); + expect(sql).not.toContain('a.active = true'); + expect(sql).not.toContain('"s"."active"'); expect(sql).toContain('ORDER BY a.id, d.id DESC'); expect(sql).toContain('from "service_instances" as "s"'); expect(sql).not.toContain('d.status <>'); @@ -427,6 +429,39 @@ describe('EnvironmentAnalyticsService contracts', () => { } }); + it('counts an enabled optional component using deployment state rather than its deployable default', async () => { + const { service, queries } = serviceWithExecutor((query) => { + if (query.sql.startsWith('SET ')) return { rows: [] }; + if (query.sql.includes('CURRENT_TIMESTAMP')) return [{ asOf: range.asOf }]; + if (query.method === 'first') return { total: '1' }; + return [ + { + key: 'optional-redis', + name: 'redis', + type: 'helm', + repositoryId: 4, + fullName: 'org/repo', + githubInstallationId: 8, + serviceId: null, + sourceGithubRepositoryId: null, + identityResolved: true, + instances: '1', + readyInstances: '1', + environments: '1', + }, + ]; + }); + const result = await service.getServices(parseManagedServiceRecordsQuery(new URLSearchParams('type=helm'))); + expect(result.records[0]).toMatchObject({ name: 'redis', instances: 1, readyInstances: 1, environments: 1 }); + const selected = queries.find((query) => query.sql.includes('service_instances'))!; + expect(selected.sql).not.toContain('a.active = true'); + expect(selected.sql).not.toContain('"s"."active"'); + expect(selected.sql).toContain('a."buildUUID" = e.uuid'); + expect(selected.sql).toContain('SELECT max(a.id)'); + expect(selected.sql).toContain('d.active = true'); + expect(selected.bindings).toContain('torn_down'); + }); + it.each([ [1_000_000, 1_000_001, false, true], [1_000_000, 1_000_000, false, false], diff --git a/src/server/services/analytics/EnvironmentAnalyticsService.ts b/src/server/services/analytics/EnvironmentAnalyticsService.ts index b0e2083a..63f93818 100644 --- a/src/server/services/analytics/EnvironmentAnalyticsService.ts +++ b/src/server/services/analytics/EnvironmentAnalyticsService.ts @@ -464,7 +464,7 @@ function serviceInstances(knex: Knex, scope: EnvironmentAnalyticsScope, environm const instances = knex.raw(` SELECT DISTINCT ON (a.id) a.*, d.status AS "deployStatus" FROM deploys d JOIN service_deployables a ON a.id = d."deployableId" AND a."environmentId" = d."buildId" - WHERE a.active = true AND d.active = true AND d."deletedAt" IS NULL + WHERE d.active = true AND d."deletedAt" IS NULL ORDER BY a.id, d.id DESC `); return knex @@ -473,7 +473,6 @@ function serviceInstances(knex: Knex, scope: EnvironmentAnalyticsScope, environm .with('service_deployables', deployables) .with('service_instances', instances) .from('service_instances as s') - .where('s.active', true) .whereRaw('(s."deployStatus" IS NULL OR s."deployStatus" <> ?)', [DeployStatus.TORN_DOWN]); } diff --git a/src/server/services/analytics/EnvironmentLifetimeAnalyticsService.test.ts b/src/server/services/analytics/EnvironmentLifetimeAnalyticsService.test.ts index df64d669..f41aa823 100644 --- a/src/server/services/analytics/EnvironmentLifetimeAnalyticsService.test.ts +++ b/src/server/services/analytics/EnvironmentLifetimeAnalyticsService.test.ts @@ -20,7 +20,9 @@ jest.mock('./query', () => ({ import knexFactory from 'knex'; import EnvironmentLifetimeAnalyticsService, { ENVIRONMENT_LIFETIME_BINS, + ENVIRONMENT_CURRENT_AGE_BINS, parseEnvironmentLifetimeRecordsQuery, + parseEnvironmentLifetimeScatterQuery, serializeEnvironmentLifetimeStats, } from './EnvironmentLifetimeAnalyticsService'; import { parseEnvironmentAnalyticsQuery } from './EnvironmentAnalyticsService'; @@ -114,6 +116,43 @@ describe('EnvironmentLifetimeAnalyticsService', () => { }); expect(ENVIRONMENT_LIFETIME_BINS.at(-1)?.toHours).toBeNull(); }); + it('splits only current age beyond thirty days while preserving completed bins', () => { + expect(ENVIRONMENT_LIFETIME_BINS.at(-1)?.id).toBe('30d_plus'); + expect(ENVIRONMENT_CURRENT_AGE_BINS.slice(-3)).toEqual([ + { id: '30_to_90d', label: '30–90 days', fromHours: 720, toHours: 2160 }, + { id: '90_to_180d', label: '90–180 days', fromHours: 2160, toHours: 4320 }, + { id: '180d_plus', label: '180 days or more', fromHours: 4320, toHours: null }, + ]); + ENVIRONMENT_CURRENT_AGE_BINS.slice(1).forEach((bin, index) => { + expect(bin.fromHours).toBe(ENVIRONMENT_CURRENT_AGE_BINS[index].toHours); + }); + const row = { samples: 6, eligible: 6, '30d_plus': 6, '30_to_90d': 1, '90_to_180d': 2, '180d_plus': 3 }; + expect( + serializeEnvironmentLifetimeStats('all', 'current', row) + .distribution.slice(-3) + .map(({ count }) => count) + ).toEqual([1, 2, 3]); + expect(serializeEnvironmentLifetimeStats('api', 'completed', row).distribution.at(-1)).toMatchObject({ + id: '30d_plus', + count: 6, + }); + }); + it.each([ + ['30d_plus', 720, null], + ['30_to_90d', 720, 2160], + ['90_to_180d', 2160, 4320], + ['180d_plus', 4320, null], + ])('keeps current-age record filter %s consistent with half-open duration bounds', async (bin, lower, upper) => { + const { service, raw } = serviceWithRows([[{ total: 0, records: [] }]]); + await service.getRecords(parseEnvironmentLifetimeRecordsQuery(params(`cohort=current&bin=${bin}`))); + const call = raw.mock.calls.find(([sql]) => sql.includes('selected AS MATERIALIZED'))!; + expect(call[1]).toContain(lower); + if (upper !== null) expect(call[1]).toContain(upper); + expect(call[0].includes('duration_hours < ?')).toBe(upper !== null); + }); + it.each(['30_to_90d', '90_to_180d', '180d_plus'])('rejects current-only age band %s for completed records', (bin) => { + expect(() => parseEnvironmentLifetimeRecordsQuery(params(`bin=${bin}`))).toThrow(); + }); it('fills sparse dates, separates methods and preserves previous DST alignment', async () => { const { service, raw } = serviceWithRows([ [ @@ -282,4 +321,114 @@ describe('EnvironmentLifetimeAnalyticsService', () => { ])('rejects invalid inputs %s', (query) => { expect(() => parseEnvironmentLifetimeRecordsQuery(params(query))).toThrow(); }); + + it('scatter uses the selected retirement window, preserves scope and ignores previous comparison', async () => { + mockResolveRange.mockResolvedValue({ ...range, compare: false, previous: null }); + const point = { + id: 1, + uuid: 'retired-api', + group: 'api', + sampleState: 'valid', + durationHours: 0, + startedAt: '2026-03-09T12:00:00Z', + measuredUntilAt: '2026-03-09T12:00:00Z', + repositoryId: 4, + githubInstallationId: 8, + resourceAvailable: false, + }; + const { service, raw } = serviceWithRows([ + [ + { + total: '1', + points: [point], + coverage: [{ group_name: 'api', eligible: 3, samples: 1, missing: 1, invalid: 1 }], + }, + ], + ]); + const result = await service.getScatter( + parseEnvironmentLifetimeScatterQuery(params('repositoryId=4&group=api&compare=true')) + ); + expect(mockResolveRange.mock.calls[0][1]).toMatchObject({ compare: false, repositoryId: 4 }); + expect(result).toMatchObject({ + total: 1, + returned: 1, + pointLimit: 5000, + truncated: false, + state: 'ready', + nextAction: null, + }); + expect(result.range.previous).toBeNull(); + expect(result.coverage.api).toEqual({ + method: 'created_to_deleted', + quality: 'recorded', + eligible: 3, + samples: 1, + missing: 1, + invalid: 1, + }); + expect(result.points[0]).toMatchObject({ durationHours: 0, quality: 'recorded', resourceAvailable: false }); + const call = raw.mock.calls.find(([sql]) => sql.includes('windowed AS MATERIALIZED'))!; + expect(call[0]).toContain("WHERE sample_state = 'valid'"); + expect(call[0]).toContain('CASE WHEN total <= ? THEN'); + expect(call[0]).toContain('ORDER BY end_at, id LIMIT ?'); + expect(call[1]).toEqual(expect.arrayContaining([4, range.fromUtc, range.toUtc, 'api', 5000])); + expect(call[1]).not.toContain(range.previous!.fromUtc); + }); + it.each([0, 5000, 5001])('scatter returns all or none at a total of %i, without a biased sample', async (total) => { + const points = + total <= 5000 + ? Array.from({ length: total }, (_, index) => ({ + id: index + 1, + group: 'pr', + sampleState: 'valid', + durationHours: 2, + startedAt: '2026-03-09T10:00:00Z', + measuredUntilAt: '2026-03-09T12:00:00Z', + repositoryId: null, + githubInstallationId: null, + resourceAvailable: false, + })) + : []; + const { service } = serviceWithRows([ + [{ total, points, coverage: total ? [{ group_name: 'pr', eligible: total, samples: total }] : [] }], + ]); + const result = await service.getScatter(parseEnvironmentLifetimeScatterQuery(params())); + expect(result).toMatchObject({ + total, + returned: total > 5000 ? 0 : total, + truncated: total > 5000, + state: total > 5000 ? 'over_limit' : total ? 'ready' : 'empty', + }); + expect(result.nextAction).toBe(total > 5000 ? 'Select a shorter time window or more filters.' : null); + if (total === 5000) + expect(result.points[0]).toMatchObject({ method: 'created_to_last_update', quality: 'estimated' }); + }); + it('rejects an incomplete scatter result rather than silently presenting a partial cohort', async () => { + const { service } = serviceWithRows([[{ total: 1, points: [], coverage: [] }]]); + await expect(service.getScatter(parseEnvironmentLifetimeScatterQuery(params()))).rejects.toThrow( + 'Incomplete lifetime scatter result.' + ); + }); + it.each(['group=other', 'group=unknown', 'from=2026-02-30&to=2026-03-02', 'repositoryId=1&unattributed=true'])( + 'rejects invalid scatter query %s', + (query) => { + expect(() => parseEnvironmentLifetimeScatterQuery(params(query))).toThrow(); + } + ); + it('rejects nonfinite scatter durations instead of serializing an invented zero', async () => { + const { service } = serviceWithRows([ + [ + { + total: 1, + points: [ + { id: 1, group: 'api', sampleState: 'valid', durationHours: 'Infinity', measuredUntilAt: range.asOf }, + ], + coverage: [], + }, + ], + ]); + await expect(service.getScatter(parseEnvironmentLifetimeScatterQuery(params()))).rejects.toThrow( + 'Invalid lifetime scatter point.' + ); + }); }); diff --git a/src/server/services/analytics/EnvironmentLifetimeAnalyticsService.ts b/src/server/services/analytics/EnvironmentLifetimeAnalyticsService.ts index 291f16d8..d7beaee6 100644 --- a/src/server/services/analytics/EnvironmentLifetimeAnalyticsService.ts +++ b/src/server/services/analytics/EnvironmentLifetimeAnalyticsService.ts @@ -37,11 +37,24 @@ export const ENVIRONMENT_LIFETIME_BINS = [ { id: '30d_plus', label: '30 days or more', fromHours: 720, toHours: null }, ] as const; +export const ENVIRONMENT_CURRENT_AGE_BINS = [ + ...ENVIRONMENT_LIFETIME_BINS.slice(0, -1), + { id: '30_to_90d', label: '30–90 days', fromHours: 720, toHours: 2160 }, + { id: '90_to_180d', label: '90–180 days', fromHours: 2160, toHours: 4320 }, + { id: '180d_plus', label: '180 days or more', fromHours: 4320, toHours: null }, +] as const; + +export const ENVIRONMENT_LIFETIME_SCATTER_POINT_LIMIT = 5000; + export type EnvironmentLifetimeGroup = 'api' | 'pr' | 'other'; export type EnvironmentLifetimeMethod = 'created_to_deleted' | 'created_to_last_update' | 'first_record_age'; export type EnvironmentLifetimeQuality = 'recorded' | 'estimated' | 'record_age'; export type EnvironmentLifetimeSampleState = 'valid' | 'missing' | 'invalid'; -export type EnvironmentLifetimeBin = (typeof ENVIRONMENT_LIFETIME_BINS)[number]['id'] | 'missing' | 'invalid'; +export type EnvironmentLifetimeBin = + | (typeof ENVIRONMENT_LIFETIME_BINS)[number]['id'] + | (typeof ENVIRONMENT_CURRENT_AGE_BINS)[number]['id'] + | 'missing' + | 'invalid'; export type EnvironmentLifetimeStats = { method: EnvironmentLifetimeMethod; quality: EnvironmentLifetimeQuality; @@ -116,6 +129,32 @@ export type EnvironmentLifetimeRecords = { pagination: { page: number; limit: number; total: number; hasMore: boolean; maxPage: number; truncated: boolean }; caveats: string[]; }; +export type EnvironmentLifetimeScatterQuery = EnvironmentAnalyticsQuery & { group: 'all' | 'api' | 'pr' }; +export type EnvironmentLifetimeScatterPoint = EnvironmentLifetimeRecord & { + group: 'api' | 'pr'; + sampleState: 'valid'; + durationHours: number; + measuredUntilAt: string; +}; +export type EnvironmentLifetimeScatterCoverage = Pick< + EnvironmentLifetimeStats, + 'method' | 'quality' | 'eligible' | 'samples' | 'missing' | 'invalid' +>; +export type EnvironmentLifetimeScatter = { + asOf: string; + scope: EnvironmentAnalyticsScope; + range: ResolvedAnalyticsRange; + group: 'all' | 'api' | 'pr'; + total: number; + returned: number; + pointLimit: typeof ENVIRONMENT_LIFETIME_SCATTER_POINT_LIMIT; + truncated: boolean; + state: 'ready' | 'empty' | 'over_limit'; + nextAction: string | null; + coverage: { api: EnvironmentLifetimeScatterCoverage; pr: EnvironmentLifetimeScatterCoverage }; + points: EnvironmentLifetimeScatterPoint[]; + caveats: string[]; +}; const MAX_PAGE = 10_000; const CAVEATS = [ @@ -173,7 +212,10 @@ export function serializeEnvironmentLifetimeStats( meanHours: samples ? nullableNumber(row?.mean_hours) : null, medianHours: samples ? nullableNumber(row?.median_hours) : null, p90Hours: samples ? nullableNumber(row?.p90_hours) : null, - distribution: ENVIRONMENT_LIFETIME_BINS.map((bin) => ({ ...bin, count: count(row?.[bin.id]) })), + distribution: (cohort === 'current' ? ENVIRONMENT_CURRENT_AGE_BINS : ENVIRONMENT_LIFETIME_BINS).map((bin) => ({ + ...bin, + count: count(row?.[bin.id]), + })), }; } @@ -188,7 +230,9 @@ export function parseEnvironmentLifetimeRecordsQuery(params: URLSearchParams): E 'invalid_query' ); const bin = params.get('bin'); - if (bin !== null && !['missing', 'invalid', ...ENVIRONMENT_LIFETIME_BINS.map(({ id }) => id)].includes(bin)) + const bins = + cohort === 'current' ? [...ENVIRONMENT_LIFETIME_BINS, ...ENVIRONMENT_CURRENT_AGE_BINS] : ENVIRONMENT_LIFETIME_BINS; + if (bin !== null && !['missing', 'invalid', ...bins.map(({ id }) => id)].includes(bin)) throw new BadRequestError('bin must be a lifetime histogram or coverage bucket.', 'invalid_query'); const pageText = params.get('page') ?? '1', limitText = params.get('limit') ?? '25'; @@ -217,6 +261,13 @@ export function parseEnvironmentLifetimeRecordsQuery(params: URLSearchParams): E }; } +export function parseEnvironmentLifetimeScatterQuery(params: URLSearchParams): EnvironmentLifetimeScatterQuery { + const group = params.get('group') ?? 'all'; + if (group !== 'all' && group !== 'api' && group !== 'pr') + throw new BadRequestError('group must be all, api or pr.', 'invalid_query'); + return { ...parseEnvironmentAnalyticsQuery(params), compare: false, group }; +} + function factsSql( knex: Knex, scope: EnvironmentAnalyticsScope, @@ -261,19 +312,52 @@ function factsSql( }; } -const AGGREGATES = `COUNT(*) AS eligible, +function aggregateSql(cohort: 'completed' | 'current'): string { + const bins = cohort === 'current' ? ENVIRONMENT_CURRENT_AGE_BINS : ENVIRONMENT_LIFETIME_BINS; + return `COUNT(*) AS eligible, COUNT(*) FILTER (WHERE sample_state = 'valid') AS samples, COUNT(*) FILTER (WHERE sample_state = 'missing') AS missing, COUNT(*) FILTER (WHERE sample_state = 'invalid') AS invalid, AVG(duration_hours) AS mean_hours, percentile_cont(0.5) WITHIN GROUP (ORDER BY duration_hours) AS median_hours, percentile_cont(0.9) WITHIN GROUP (ORDER BY duration_hours) AS p90_hours, - ${ENVIRONMENT_LIFETIME_BINS.map( - (bin) => - `COUNT(*) FILTER (WHERE duration_hours >= ${bin.fromHours}${ - bin.toHours === null ? '' : ` AND duration_hours < ${bin.toHours}` - }) AS "${bin.id}"` - ).join(',\n ')}`; + ${bins + .map( + (bin) => + `COUNT(*) FILTER (WHERE duration_hours >= ${bin.fromHours}${ + bin.toHours === null ? '' : ` AND duration_hours < ${bin.toHours}` + }) AS "${bin.id}"` + ) + .join(',\n ')}`; +} + +const RECORD_JSON_SQL = `jsonb_build_object( + 'id', id, 'uuid', uuid, 'status', status, 'isStatic', COALESCE("isStatic", false), + 'author', author, 'repositoryId', "repositoryId", 'fullName', "fullName", + 'githubInstallationId', "githubInstallationId", 'repositoryAmbiguous', "repositoryAmbiguous", + 'resourceAvailable', ("deletedAt" IS NULL AND (status IS NULL OR status <> ?) AND NULLIF(uuid, '') IS NOT NULL), + 'pullRequest', CASE WHEN group_name = 'pr' THEN jsonb_build_object( + 'number', "pullRequestNumber", 'title', "prTitle", 'author', "prAuthor") END, + 'group', group_name, 'sampleState', sample_state, + 'startedAt', CASE WHEN isfinite(start_at) THEN start_at END, + 'measuredUntilAt', CASE WHEN isfinite(end_at) THEN end_at END, 'durationHours', duration_hours +)`; + +function serializeRecord( + record: EnvironmentLifetimeRecord, + cohort: 'completed' | 'current' +): EnvironmentLifetimeRecord { + return { + ...record, + ...method(record.group, cohort), + id: count(record.id), + repositoryId: record.repositoryId == null ? null : count(record.repositoryId), + githubInstallationId: record.githubInstallationId == null ? null : count(record.githubInstallationId), + durationHours: nullableNumber(record.durationHours), + startedAt: timestamp(record.startedAt), + measuredUntilAt: timestamp(record.measuredUntilAt), + }; +} function scopeOf(query: EnvironmentAnalyticsScope): EnvironmentAnalyticsScope { return { @@ -300,7 +384,7 @@ export default class EnvironmentLifetimeAnalyticsService { (CASE WHEN end_at < ?::timestamptz THEN ?::int ELSE 0 END * interval '1 day')), 'YYYY-MM-DD') AS bucket FROM facts WHERE group_name IN ('api','pr') AND isfinite(end_at) AND end_at >= ?::timestamptz AND end_at < ?::timestamptz - ) SELECT period, group_name, bucket, ${AGGREGATES} + ) SELECT period, group_name, bucket, ${aggregateSql('completed')} FROM period_facts GROUP BY GROUPING SETS ((period,group_name), (period,group_name,bucket))`, [ ...completed.bindings, @@ -315,7 +399,7 @@ export default class EnvironmentLifetimeAnalyticsService { ); const current = factsSql(trx, scope, 'current'); const currentResult = await trx.raw( - `WITH ${current.sql} SELECT COALESCE(group_name, 'all') AS group_name, ${AGGREGATES} + `WITH ${current.sql} SELECT COALESCE(group_name, 'all') AS group_name, ${aggregateSql('current')} FROM facts GROUP BY GROUPING SETS ((group_name), ())`, current.bindings ); @@ -373,6 +457,92 @@ export default class EnvironmentLifetimeAnalyticsService { }); } + async getScatter(query: EnvironmentLifetimeScatterQuery): Promise { + return analyticsTransaction(this.db.knex, async (trx) => { + const range = await resolveAnalyticsRange(trx, { ...query, compare: false }); + const scope = scopeOf(query); + const facts = factsSql(trx, scope, 'completed'); + const groupPredicate = query.group === 'all' ? '' : 'AND group_name = ?'; + const result = await trx.raw( + `WITH ${facts.sql}, windowed AS MATERIALIZED ( + SELECT id, uuid, status, "isStatic", author, "repositoryId", "fullName", "githubInstallationId", + "repositoryAmbiguous", "deletedAt", "pullRequestNumber", "prTitle", "prAuthor", + group_name, start_at, end_at, sample_state, duration_hours + FROM facts WHERE group_name IN ('api','pr') AND isfinite(end_at) + AND end_at >= ?::timestamptz AND end_at < ?::timestamptz ${groupPredicate} + ), coverage AS ( + SELECT group_name, COUNT(*) AS eligible, + COUNT(*) FILTER (WHERE sample_state = 'valid') AS samples, + COUNT(*) FILTER (WHERE sample_state = 'missing') AS missing, + COUNT(*) FILTER (WHERE sample_state = 'invalid') AS invalid + FROM windowed GROUP BY group_name + ), limits AS ( + SELECT COALESCE(SUM(samples), 0) AS total FROM coverage + ) SELECT total, COALESCE((SELECT jsonb_agg(to_jsonb(coverage)) FROM coverage), '[]'::jsonb) AS coverage, + CASE WHEN total <= ? THEN ( + SELECT COALESCE(jsonb_agg(${RECORD_JSON_SQL} ORDER BY end_at, id), '[]'::jsonb) + FROM (SELECT * FROM windowed WHERE sample_state = 'valid' ORDER BY end_at, id LIMIT ?) points + ) ELSE '[]'::jsonb END AS points + FROM limits`, + [ + ...facts.bindings, + range.fromUtc, + range.toUtc, + ...(query.group === 'all' ? [] : [query.group]), + ENVIRONMENT_LIFETIME_SCATTER_POINT_LIMIT, + BuildStatus.TORN_DOWN, + ENVIRONMENT_LIFETIME_SCATTER_POINT_LIMIT, + ] + ); + const row = result.rows[0]; + const total = count(row.total); + const truncated = total > ENVIRONMENT_LIFETIME_SCATTER_POINT_LIMIT; + const points: EnvironmentLifetimeScatterPoint[] = row.points.map((raw) => { + const point = serializeRecord(raw, 'completed'); + if ( + (point.group !== 'api' && point.group !== 'pr') || + point.sampleState !== 'valid' || + point.durationHours === null || + point.measuredUntilAt === null + ) + throw new Error('Invalid lifetime scatter point.'); + return { + ...point, + group: point.group, + sampleState: 'valid', + durationHours: point.durationHours, + measuredUntilAt: point.measuredUntilAt, + }; + }); + if (points.length !== (truncated ? 0 : total)) throw new Error('Incomplete lifetime scatter result.'); + const coverage = (group: 'api' | 'pr'): EnvironmentLifetimeScatterCoverage => { + const values = row.coverage.find((entry) => entry.group_name === group); + return { + ...method(group, 'completed'), + eligible: count(values?.eligible), + samples: count(values?.samples), + missing: count(values?.missing), + invalid: count(values?.invalid), + }; + }; + return { + asOf: range.asOf, + scope, + range, + group: query.group, + total, + returned: points.length, + pointLimit: ENVIRONMENT_LIFETIME_SCATTER_POINT_LIMIT, + truncated, + state: truncated ? 'over_limit' : total ? 'ready' : 'empty', + nextAction: truncated ? 'Select a shorter time window or more filters.' : null, + coverage: { api: coverage('api'), pr: coverage('pr') }, + points, + caveats: CAVEATS, + }; + }); + } + async getRecords(query: EnvironmentLifetimeRecordsQuery): Promise { return analyticsTransaction(this.db.knex, async (trx) => { const range = query.cohort === 'completed' ? await resolveAnalyticsRange(trx, query) : null; @@ -395,7 +565,7 @@ export default class EnvironmentLifetimeAnalyticsService { predicates.push('sample_state = ?'); bindings.push(query.bin); } else if (query.bin) { - const bin = ENVIRONMENT_LIFETIME_BINS.find(({ id }) => id === query.bin)!; + const bin = [...ENVIRONMENT_LIFETIME_BINS, ...ENVIRONMENT_CURRENT_AGE_BINS].find(({ id }) => id === query.bin)!; predicates.push('duration_hours >= ?'); bindings.push(bin.fromHours); if (bin.toHours !== null) { @@ -409,17 +579,8 @@ export default class EnvironmentLifetimeAnalyticsService { ), page AS ( SELECT * FROM selected ORDER BY duration_hours DESC NULLS LAST, end_at DESC NULLS LAST, id DESC LIMIT ? OFFSET ? ) SELECT (SELECT COUNT(*) FROM selected) AS total, - COALESCE((SELECT jsonb_agg(jsonb_build_object( - 'id', id, 'uuid', uuid, 'status', status, 'isStatic', COALESCE("isStatic", false), - 'author', author, 'repositoryId', "repositoryId", 'fullName', "fullName", - 'githubInstallationId', "githubInstallationId", 'repositoryAmbiguous', "repositoryAmbiguous", - 'resourceAvailable', ("deletedAt" IS NULL AND (status IS NULL OR status <> ?) AND NULLIF(uuid, '') IS NOT NULL), - 'pullRequest', CASE WHEN group_name = 'pr' THEN jsonb_build_object( - 'number', "pullRequestNumber", 'title', "prTitle", 'author', "prAuthor") END, - 'group', group_name, 'sampleState', sample_state, - 'startedAt', CASE WHEN isfinite(start_at) THEN start_at END, - 'measuredUntilAt', CASE WHEN isfinite(end_at) THEN end_at END, 'durationHours', duration_hours - ) ORDER BY duration_hours DESC NULLS LAST, end_at DESC NULLS LAST, id DESC) FROM page), '[]'::jsonb) AS records`, + COALESCE((SELECT jsonb_agg(${RECORD_JSON_SQL} + ORDER BY duration_hours DESC NULLS LAST, end_at DESC NULLS LAST, id DESC) FROM page), '[]'::jsonb) AS records`, [...bindings, query.limit, (query.page - 1) * query.limit, BuildStatus.TORN_DOWN] ); const row = result.rows[0]; @@ -431,16 +592,7 @@ export default class EnvironmentLifetimeAnalyticsService { cohort: query.cohort, group: query.group, bin: query.bin, - records: row.records.map((record) => ({ - ...record, - ...method(record.group, query.cohort), - id: count(record.id), - repositoryId: record.repositoryId == null ? null : count(record.repositoryId), - githubInstallationId: record.githubInstallationId == null ? null : count(record.githubInstallationId), - durationHours: nullableNumber(record.durationHours), - startedAt: timestamp(record.startedAt), - measuredUntilAt: timestamp(record.measuredUntilAt), - })), + records: row.records.map((record) => serializeRecord(record, query.cohort)), pagination: { page: query.page, limit: query.limit, diff --git a/src/shared/agentAnalyticsOpenApi.ts b/src/shared/agentAnalyticsOpenApi.ts index 24c6158b..c77627e9 100644 --- a/src/shared/agentAnalyticsOpenApi.ts +++ b/src/shared/agentAnalyticsOpenApi.ts @@ -136,6 +136,26 @@ export const agentAnalyticsSchemas = { runs: array(ref('AgentAnalyticsRun')), pagination: object({ page: count, limit: count, total: count, hasMore: { type: 'boolean' } }), }), + AgentAnalyticsSession: object({ + ...metricProperties, + sessionId: { type: 'string', format: 'uuid' }, + title: nullableString, + ownerId: { type: 'string' }, + ownerGithubUsername: nullableString, + sessionKind: { type: 'string' }, + sessionStatus: { type: 'string' }, + repositories: { ...array({ type: 'string' }), maxItems: 5 }, + repositoryCount: count, + firstSubmittedAt: { type: 'string', format: 'date-time' }, + lastSubmittedAt: { type: 'string', format: 'date-time' }, + }), + AgentAnalyticsSessions: object({ + range: ref('AnalyticsRange'), + asOf: { type: 'string', format: 'date-time' }, + caveats: array({ type: 'string' }), + sessions: array(ref('AgentAnalyticsSession')), + pagination: object({ page: count, limit: count, total: count, hasMore: { type: 'boolean' } }), + }), AgentAnalyticsOptions: object({ asOf: { type: 'string', format: 'date-time' }, repositoryScope: { type: 'string', enum: ['recorded_name'] }, @@ -149,5 +169,6 @@ export const agentAnalyticsSchemas = { }), GetAgentAnalyticsSuccessResponse: success('AgentAnalyticsSummary'), GetAgentAnalyticsRunsSuccessResponse: success('AgentAnalyticsRuns'), + GetAgentAnalyticsSessionsSuccessResponse: success('AgentAnalyticsSessions'), GetAgentAnalyticsOptionsSuccessResponse: success('AgentAnalyticsOptions'), }; diff --git a/src/shared/environmentAnalyticsOpenApi.test.ts b/src/shared/environmentAnalyticsOpenApi.test.ts index 204bbe23..ec47fec9 100644 --- a/src/shared/environmentAnalyticsOpenApi.test.ts +++ b/src/shared/environmentAnalyticsOpenApi.test.ts @@ -11,6 +11,7 @@ describe('Admin environment analytics OpenAPI contract', () => { ['options', 'getAnalyticsOptions', 'GetAnalyticsOptionsSuccessResponse'], ['environments/records', 'getEnvironmentAnalyticsRecords', 'GetEnvironmentAnalyticsRecordsSuccessResponse'], ['services', 'getManagedServiceRecords', 'GetManagedServiceRecordsSuccessResponse'], + ['lifetimes/scatter', 'getEnvironmentLifetimeScatter', 'GetEnvironmentLifetimeScatterSuccessResponse'], ])('documents %s with a concrete generated response and session authentication', (path, operationId, response) => { const operation = spec.paths[`/api/v2/admin/analytics/${path}`].get; expect(operation.operationId).toBe(operationId); @@ -43,6 +44,51 @@ describe('Admin environment analytics OpenAPI contract', () => { expect(schemas.EnvironmentAnalyticsPagination.properties.limit.maximum).toBe(100); }); + it('documents complete bounded lifetime points and preserves distinct current-age bands', () => { + expect(schemas.EnvironmentLifetimeScatter.properties.pointLimit.enum).toEqual([5000]); + expect(schemas.EnvironmentLifetimeScatter.properties.state.enum).toEqual(['ready', 'empty', 'over_limit']); + expect(schemas.EnvironmentLifetimeScatterPoint.required).toEqual( + expect.arrayContaining([ + 'resourceAvailable', + 'method', + 'quality', + 'sampleState', + 'durationHours', + 'measuredUntilAt', + ]) + ); + const operation = spec.paths['/api/v2/admin/analytics/lifetimes/records'].get; + expect(operation.parameters.find((param: any) => param.name === 'bin').schema.enum).toEqual( + expect.arrayContaining(['30d_plus', '30_to_90d', '90_to_180d', '180d_plus']) + ); + }); + + it('documents exact session drilldown and session totals before bounded paging', () => { + const sessions = spec.paths['/api/v2/admin/analytics/agents/sessions'].get; + expect(sessions.operationId).toBe('getAgentAnalyticsSessions'); + expect(sessions.responses['200'].content['application/json'].schema).toEqual({ + $ref: '#/components/schemas/GetAgentAnalyticsSessionsSuccessResponse', + }); + expect(sessions.security ?? spec.security).toEqual([{ BearerAuth: [] }]); + expect(sessions.responses['403'].description).toBe('Admin permission required.'); + expect(sessions.parameters.find((param: any) => param.name === 'limit').schema.maximum).toBe(100); + expect(sessions.parameters.find((param: any) => param.name === 'page').schema.maximum).toBe(10000); + expect(schemas.AgentAnalyticsSession.required).toEqual( + expect.arrayContaining([ + 'sessionId', + 'runs', + 'repositories', + 'repositoryCount', + 'tokens', + 'reportedCost', + 'estimatedCost', + ]) + ); + expect(schemas.AgentAnalyticsSession.properties.repositories.maxItems).toBe(5); + const runs = spec.paths['/api/v2/admin/analytics/agents/runs'].get; + expect(runs.parameters.find((param: any) => param.name === 'sessionId').schema.format).toBe('uuid'); + }); + it('models calendar bounds, optional comparison and inapplicable PR values honestly', () => { const operation = spec.paths['/api/v2/admin/analytics/environments'].get; expect(operation.parameters.find((param: any) => param.name === 'compare').schema.default).toBe(true); diff --git a/src/shared/environmentLifetimeAnalyticsOpenApi.ts b/src/shared/environmentLifetimeAnalyticsOpenApi.ts index 216ab1e0..06fc627f 100644 --- a/src/shared/environmentLifetimeAnalyticsOpenApi.ts +++ b/src/shared/environmentLifetimeAnalyticsOpenApi.ts @@ -35,14 +35,41 @@ const quality = { type: 'string', enum: ['recorded', 'estimated', 'record_age'] const group = { type: 'string', enum: ['api', 'pr', 'other'] }; const bin = { type: 'string', - enum: ['under_1h', '1_to_6h', '6_to_24h', '1_to_3d', '3_to_7d', '7_to_30d', '30d_plus', 'missing', 'invalid'], + enum: [ + 'under_1h', + '1_to_6h', + '6_to_24h', + '1_to_3d', + '3_to_7d', + '7_to_30d', + '30d_plus', + '30_to_90d', + '90_to_180d', + '180d_plus', + 'missing', + 'invalid', + ], nullable: true, }; const unplaced = object({ missingEnd: integer, invalidEnd: integer }); export const environmentLifetimeAnalyticsSchemas = { EnvironmentLifetimeDistribution: object({ - id: { type: 'string', enum: ['under_1h', '1_to_6h', '6_to_24h', '1_to_3d', '3_to_7d', '7_to_30d', '30d_plus'] }, + id: { + type: 'string', + enum: [ + 'under_1h', + '1_to_6h', + '6_to_24h', + '1_to_3d', + '3_to_7d', + '7_to_30d', + '30d_plus', + '30_to_90d', + '90_to_180d', + '180d_plus', + ], + }, label: text, fromHours: { type: 'number', minimum: 0 }, toHours: hours, diff --git a/src/shared/openApiSpec.ts b/src/shared/openApiSpec.ts index b7209017..5d306319 100644 --- a/src/shared/openApiSpec.ts +++ b/src/shared/openApiSpec.ts @@ -2,6 +2,7 @@ import { OAS3Options } from 'swagger-jsdoc'; import { environmentAnalyticsSchemas } from './environmentAnalyticsOpenApi'; import { environmentLifetimeAnalyticsSchemas } from './environmentLifetimeAnalyticsOpenApi'; import { agentAnalyticsSchemas } from './agentAnalyticsOpenApi'; +import { environmentLifetimeScatterSchemas } from '../app/api/v2/admin/analytics/lifetimes/scatter/schemas'; import { AGENT_FEEDBACK_SCOPES, MAX_AGENT_FEEDBACK_TEXT_LENGTH, @@ -175,6 +176,7 @@ export const openApiSpecificationForV2Api: OAS3Options = { ...environmentAnalyticsSchemas, ...environmentLifetimeAnalyticsSchemas, ...agentAnalyticsSchemas, + ...environmentLifetimeScatterSchemas, SitesBrowserMintRequest: { type: 'object', additionalProperties: false, From dbdf6ae4985c7b2741e92d5238b824781f482007 Mon Sep 17 00:00:00 2001 From: vmelikyan Date: Sun, 4 Oct 2026 13:15:33 -0700 Subject: [PATCH 6/9] feat(analytics): add repository activity breakdown Return repository groups for environment activity when requested. Keep date totals complete and existing responses unchanged by default. --- .../analytics/environments/route.test.ts | 10 + .../v2/admin/analytics/environments/route.ts | 1 + .../EnvironmentAnalyticsService.test.ts | 250 ++++++++++++++++++ .../analytics/EnvironmentAnalyticsService.ts | 177 ++++++++++++- .../environmentAnalyticsOpenApi.test.ts | 24 ++ src/shared/environmentAnalyticsOpenApi.ts | 59 +++-- 6 files changed, 496 insertions(+), 25 deletions(-) diff --git a/src/app/api/v2/admin/analytics/environments/route.test.ts b/src/app/api/v2/admin/analytics/environments/route.test.ts index 48d3d3d8..18eda454 100644 --- a/src/app/api/v2/admin/analytics/environments/route.test.ts +++ b/src/app/api/v2/admin/analytics/environments/route.test.ts @@ -117,6 +117,16 @@ describe('environment analytics administrator boundaries', () => { }) ); }); + + it('adds repository activity to the existing administrator route only when requested', async () => { + expect((await environments(request('?activityBreakdown=none'))).status).toBe(200); + expect(mockEnvironments.mock.calls[0][0]).not.toHaveProperty('activityBreakdown'); + expect((await environments(request('?activityBreakdown=repositories'))).status).toBe(200); + expect(mockEnvironments.mock.calls[1][0].activityBreakdown).toBe('repositories'); + mockEnvironments.mockClear(); + expect((await environments(request('?activityBreakdown=components'))).status).toBe(400); + expectNoAccess(); + }); it.each([ '?from=2026-02-30&to=2026-03-02', '?from=2025-01-01&to=2026-01-02', diff --git a/src/app/api/v2/admin/analytics/environments/route.ts b/src/app/api/v2/admin/analytics/environments/route.ts index cde6b800..e89d59f1 100644 --- a/src/app/api/v2/admin/analytics/environments/route.ts +++ b/src/app/api/v2/admin/analytics/environments/route.ts @@ -39,6 +39,7 @@ export const runtime = 'nodejs'; * - { in: query, name: compare, schema: { type: boolean, default: true } } * - { in: query, name: interval, schema: { type: string, enum: [day, week], default: day } } * - { in: query, name: rankBy, schema: { type: string, enum: [first_seen, observed_prs, pr_coverage], default: first_seen } } + * - { in: query, name: activityBreakdown, schema: { type: string, enum: [none, repositories], default: none }, description: 'Repository stacks name five repositories by selected-period activity. Previous activity resolves ties. Other repositories and Unattributed preserve remaining counts. Omitted or none preserves the existing result.' } * responses: * '503': { description: 'Analytics request did not complete before the time limit. Error code: analytics_timeout.', content: { application/json: { schema: { $ref: '#/components/schemas/ApiErrorResponse' } } } } * '200': diff --git a/src/server/services/analytics/EnvironmentAnalyticsService.test.ts b/src/server/services/analytics/EnvironmentAnalyticsService.test.ts index 58050156..38656139 100644 --- a/src/server/services/analytics/EnvironmentAnalyticsService.test.ts +++ b/src/server/services/analytics/EnvironmentAnalyticsService.test.ts @@ -71,6 +71,45 @@ function serviceWithExecutor( return { service: new EnvironmentAnalyticsService({ knex: { transaction } } as any), queries }; } +type ActivityFixtureBucket = { + date: string; + repositoryId: number | null; + fullName: string | null; + githubInstallationId: number | null; + count: number; +}; +function activityFixture(current: ActivityFixtureBucket[], previous: ActivityFixtureBucket[] = [], calendar = range) { + return serviceWithExecutor((query) => { + if (query.sql.startsWith('SET ')) return { rows: [] }; + if (query.sql.includes(' AS deploys') || query.sql.includes('"pull_requests" as "p"')) return []; + if (query.method === 'first') return { earliest: null }; + const rows = calendar.previous && query.bindings.includes(calendar.previous.fromUtc) ? previous : current; + if (query.sql.includes('AS ambiguous')) { + const totals = new Map(); + for (const row of rows) { + const prior = totals.get(row.repositoryId); + totals.set(row.repositoryId, { ...row, count: (prior?.count ?? 0) + row.count }); + } + return [...totals.values()].map((row) => ({ ...row, ambiguous: 0 })); + } + const ids = query.bindings.find((value) => Array.isArray(value)) as number[] | undefined; + const counts = new Map(); + for (const row of rows) { + const key = ids + ? row.repositoryId == null + ? 'unattributed' + : ids.includes(row.repositoryId) + ? `repository:${row.repositoryId}` + : 'other' + : undefined; + const identity = `${row.date}:${key ?? ''}`; + const prior = counts.get(identity); + counts.set(identity, { date: row.date, ...(key ? { key } : {}), count: (prior?.count ?? 0) + row.count }); + } + return [...counts.values()]; + }); +} + describe('EnvironmentAnalyticsService contracts', () => { beforeEach(() => { jest.clearAllMocks(); @@ -279,6 +318,217 @@ describe('EnvironmentAnalyticsService contracts', () => { expect(result.previousTotals).toBeNull(); }); + it('adds repository activity only when requested and keeps parser defaults unchanged', async () => { + const plain = parseEnvironmentAnalyticsQuery(new URLSearchParams()); + expect(parseEnvironmentAnalyticsQuery(new URLSearchParams('activityBreakdown=none'))).toEqual(plain); + expect(plain).not.toHaveProperty('activityBreakdown'); + expect(parseEnvironmentAnalyticsQuery(new URLSearchParams('activityBreakdown=repositories'))).toEqual({ + ...plain, + activityBreakdown: 'repositories', + }); + expect(() => parseEnvironmentAnalyticsQuery(new URLSearchParams('activityBreakdown=components'))).toThrow( + 'activityBreakdown must be none or repositories.' + ); + const plainResult = await activityFixture([]).service.getEnvironments(plain); + expect(plainResult).not.toHaveProperty('activity'); + const result = await activityFixture([]).service.getEnvironments({ ...plain, activityBreakdown: 'repositories' }); + expect(result.activity).toEqual({ repositoryLimit: 5, repositoryTotal: 0, groupedRepositories: 0, series: [] }); + const { activity, ...existing } = result; + expect(existing).toEqual(plainResult); + expect(activity?.series).toEqual([]); + }); + + it('replaces the total bucket scans without changing query counts or any existing result', async () => { + const current: ActivityFixtureBucket[] = Array.from({ length: 7 }, (_, index) => ({ + date: range.dates[index % 2], + repositoryId: index + 1, + fullName: `org/repo-${index + 1}`, + githubInstallationId: 8, + count: 7 - index, + })); + current.push({ + date: range.dates[0], + repositoryId: null, + fullName: null, + githubInstallationId: null, + count: 3, + }); + const plain = activityFixture(current); + const opted = activityFixture(current); + const query = { ...scope, rankBy: 'first_seen' as const }; + const expected = await plain.service.getEnvironments(query); + const result = await opted.service.getEnvironments({ ...query, activityBreakdown: 'repositories' }); + const { activity, ...existing } = result; + expect(existing).toEqual(expected); + expect(opted.queries).toHaveLength(plain.queries.length); + expect(opted.queries.filter((query) => query.sql.includes('AS key'))).toHaveLength(1); + expect(opted.queries.filter((query) => query.sql.includes('group by "date"'))).toHaveLength(1); + expect(activity?.series.map((series) => series.key)).toEqual([ + 'repository:1', + 'repository:2', + 'repository:3', + 'repository:4', + 'repository:5', + 'other', + 'unattributed', + ]); + expect(activity?.repositoryTotal).toBe(7); + expect(activity?.groupedRepositories).toBe(2); + expect(activity?.series.find((series) => series.kind === 'other')?.firstSeenEnvironments).toBe(3); + expect(activity?.series.find((series) => series.kind === 'unattributed')?.firstSeenEnvironments).toBe(3); + for (const [index, bucket] of result.buckets.entries()) + expect(activity?.series.reduce((sum, series) => sum + series.buckets[index].firstSeenEnvironments, 0)).toBe( + bucket.firstSeenEnvironments + ); + expect(activity?.series.reduce((sum, series) => sum + series.firstSeenEnvironments, 0)).toBe( + result.totals.firstSeenEnvironments + ); + expect(activity?.series.every((series) => series.previousFirstSeenEnvironments === null)).toBe(true); + }); + + it('ranks current activity before previous ties and keeps installation identities and complete comparison', async () => { + const calendar = { + ...range, + compare: true, + previous: { + from: '2026-09-28', + to: range.from, + fromUtc: '2026-09-28T00:00:00.000Z', + toUtc: range.fromUtc, + dates: ['2026-09-28', '2026-09-29'], + }, + }; + (resolveAnalyticsRange as jest.Mock).mockResolvedValue(calendar); + const current: ActivityFixtureBucket[] = [ + { date: range.dates[0], repositoryId: 4, fullName: 'org/repo', githubInstallationId: 8, count: 10 }, + { date: range.dates[1], repositoryId: 12, fullName: 'org/repo', githubInstallationId: 18, count: 10 }, + ...[2, 3, 5, 6, 7].map((repositoryId) => ({ + date: range.dates[0], + repositoryId, + fullName: `org/repo-${repositoryId}`, + githubInstallationId: 8, + count: 1, + })), + ]; + const previous: ActivityFixtureBucket[] = [ + { date: range.dates[0], repositoryId: 4, fullName: 'org/repo', githubInstallationId: 8, count: 1 }, + { date: range.dates[1], repositoryId: 12, fullName: 'org/repo', githubInstallationId: 18, count: 2 }, + { date: range.dates[0], repositoryId: 19, fullName: 'prior/repo', githubInstallationId: 9, count: 100 }, + ]; + const plain = activityFixture(current, previous, calendar); + const opted = activityFixture(current, previous, calendar); + const query = { ...scope, compare: true, rankBy: 'first_seen' as const }; + const expected = await plain.service.getEnvironments(query); + const result = await opted.service.getEnvironments({ ...query, activityBreakdown: 'repositories' }); + const { activity, ...existing } = result; + expect(existing).toEqual(expected); + expect(opted.queries).toHaveLength(plain.queries.length); + expect(opted.queries.filter((query) => query.sql.includes('AS key'))).toHaveLength(2); + expect(activity?.series.slice(0, 2).map((series) => [series.key, series.githubInstallationId])).toEqual([ + ['repository:12', 18], + ['repository:4', 8], + ]); + expect(activity?.series.find((series) => series.kind === 'other')?.previousFirstSeenEnvironments).toBe(100); + for (const [index, bucket] of result.buckets.entries()) + expect( + activity?.series.reduce((sum, series) => sum + series.buckets[index].previousFirstSeenEnvironments!, 0) + ).toBe(bucket.previousFirstSeenEnvironments); + expect(activity?.series.reduce((sum, series) => sum + series.previousFirstSeenEnvironments!, 0)).toBe( + result.previousTotals?.firstSeenEnvironments + ); + expect(activity?.repositoryTotal).toBe(8); + }); + + it('keeps previous-only repositories and zero-filled current buckets when the selected period is empty', async () => { + const calendar = { + ...range, + compare: true, + previous: { + from: '2026-09-28', + to: range.from, + fromUtc: '2026-09-28T00:00:00.000Z', + toUtc: range.fromUtc, + dates: ['2026-09-28', '2026-09-29'], + }, + }; + (resolveAnalyticsRange as jest.Mock).mockResolvedValue(calendar); + const previous: ActivityFixtureBucket[] = [ + { date: range.dates[1], repositoryId: 4, fullName: 'org/repo', githubInstallationId: 8, count: 7 }, + ]; + const result = await activityFixture([], previous, calendar).service.getEnvironments({ + ...scope, + compare: true, + rankBy: 'first_seen', + activityBreakdown: 'repositories', + }); + expect(result.activity?.series).toMatchObject([ + { key: 'repository:4', firstSeenEnvironments: 0, previousFirstSeenEnvironments: 7 }, + ]); + expect(result.activity?.series[0].buckets).toEqual( + range.dates.map((date, index) => ({ + date, + firstSeenEnvironments: 0, + previousFirstSeenEnvironments: index === 1 ? 7 : 0, + })) + ); + expect(result.activity?.groupedRepositories).toBe(0); + }); + + it('keeps scoped historical attribution and the existing DST and weekly bucket expressions', async () => { + const calendar = { + ...range, + from: '2026-03-08', + to: '2026-03-10', + fromUtc: '2026-03-08T08:00:00.000Z', + toUtc: '2026-03-10T07:00:00.000Z', + dates: ['2026-03-08', '2026-03-09'], + interval: 'week' as const, + timezone: 'America/Los_Angeles', + compare: true, + previous: { + from: '2026-03-06', + to: '2026-03-08', + fromUtc: '2026-03-06T08:00:00.000Z', + toUtc: '2026-03-08T08:00:00.000Z', + dates: ['2026-03-06', '2026-03-07'], + }, + }; + (resolveAnalyticsRange as jest.Mock).mockResolvedValue(calendar); + const rows = [ + { date: '2026-03-02', repositoryId: 4, fullName: 'org/repo', githubInstallationId: 8, count: 2 }, + { date: '2026-03-09', repositoryId: 4, fullName: 'org/repo', githubInstallationId: 8, count: 3 }, + ]; + const { service, queries } = activityFixture(rows, rows, calendar); + const result = await service.getEnvironments({ + ...scope, + repositoryId: 4, + organization: 'org', + environmentType: 'static', + environmentAuthor: 'Alice', + rankBy: 'first_seen', + activityBreakdown: 'repositories', + }); + expect(result.activity?.series[0].buckets).toEqual([ + { date: '2026-03-02', firstSeenEnvironments: 2, previousFirstSeenEnvironments: 2 }, + { date: '2026-03-09', firstSeenEnvironments: 3, previousFirstSeenEnvironments: 3 }, + ]); + const grouped = queries.filter((query) => query.sql.includes('AS key')); + expect(grouped).toHaveLength(2); + for (const query of grouped) { + expect(query.sql).toContain('WHEN rc.matches = 1'); + expect(query.sql).toContain('group by "key", "date"'); + expect(query.sql).not.toMatch(/\b(deploys|deployables)\b/); + expect(query.bindings).toEqual(expect.arrayContaining([[4], 'org', true, 'Alice'])); + expect(query.sql).not.toContain('"e"."deletedAt" is null'); + } + expect(grouped[0].bindings).toEqual( + expect.arrayContaining(['week', 'America/Los_Angeles', 0, calendar.fromUtc, calendar.toUtc]) + ); + expect(grouped[1].bindings).toEqual( + expect.arrayContaining(['week', 'America/Los_Angeles', 2, calendar.previous.fromUtc, calendar.previous.toUtc]) + ); + }); + it('adds independent managed Service coverage and per-environment instances without changing readiness', async () => { const { service, queries } = serviceWithExecutor((query) => { if (query.sql.startsWith('SET ')) return { rows: [] }; diff --git a/src/server/services/analytics/EnvironmentAnalyticsService.ts b/src/server/services/analytics/EnvironmentAnalyticsService.ts index 63f93818..e5815668 100644 --- a/src/server/services/analytics/EnvironmentAnalyticsService.ts +++ b/src/server/services/analytics/EnvironmentAnalyticsService.ts @@ -37,6 +37,7 @@ export type EnvironmentAnalyticsScope = { export type EnvironmentAnalyticsQuery = AnalyticsCalendarQuery & EnvironmentAnalyticsScope & { rankBy: 'first_seen' | 'observed_prs' | 'pr_coverage'; + activityBreakdown?: 'repositories'; }; export type EnvironmentAnalyticsRecordsQuery = EnvironmentAnalyticsQuery & { cohort: 'first_seen' | 'current'; @@ -65,6 +66,24 @@ export type EnvironmentAnalyticsRepository = AnalyticsRepositoryIdentity & { currentEnvironments: number; readyEnvironments: number; }; +export type EnvironmentActivityAnalyticsBucket = { + date: string; + firstSeenEnvironments: number; + previousFirstSeenEnvironments: number | null; +}; +export type EnvironmentActivityAnalyticsSeries = AnalyticsRepositoryIdentity & { + key: string; + kind: 'repository' | 'other' | 'unattributed'; + firstSeenEnvironments: number; + previousFirstSeenEnvironments: number | null; + buckets: EnvironmentActivityAnalyticsBucket[]; +}; +export type EnvironmentActivityBreakdown = { + series: EnvironmentActivityAnalyticsSeries[]; + repositoryLimit: number; + repositoryTotal: number; + groupedRepositories: number; +}; export type EnvironmentAnalytics = { range: ResolvedAnalyticsRange; scope: EnvironmentAnalyticsScope; @@ -83,6 +102,7 @@ export type EnvironmentAnalytics = { previousFirstSeenEnvironments: number | null; previousObservedPullRequests: number | null; }>; + activity?: EnvironmentActivityBreakdown; repositories: EnvironmentAnalyticsRepository[]; rankingTotal: number; rankingTruncated: boolean; @@ -227,8 +247,10 @@ type CountRow = AnalyticsRepositoryIdentity & { covered?: number | string; ambiguous?: number | string; }; +type EnvironmentBucketRow = { date: string; count: number | string; key?: string }; const BATCH_SIZE = 1000; const RANKING_LIMIT = 100; +const ACTIVITY_REPOSITORY_LIMIT = 5; const EXCEPTION_LIMIT = 20; const MANAGED_SERVICE_MAX_PAGE = 1_000_000; const MANAGED_SERVICE_TYPES: ManagedServiceType[] = [ @@ -321,10 +343,14 @@ export function parseEnvironmentAnalyticsQuery(params: URLSearchParams): Environ const rankBy = params.get('rankBy') ?? 'first_seen'; if (!['first_seen', 'observed_prs', 'pr_coverage'].includes(rankBy)) throw new BadRequestError('rankBy must be first_seen, observed_prs or pr_coverage.', 'invalid_query'); + const activityBreakdown = params.get('activityBreakdown') ?? 'none'; + if (activityBreakdown !== 'none' && activityBreakdown !== 'repositories') + throw new BadRequestError('activityBreakdown must be none or repositories.', 'invalid_query'); return { ...parseAnalyticsCalendarQuery(params), ...parseEnvironmentAnalyticsScope(params), rankBy: rankBy as EnvironmentAnalyticsQuery['rankBy'], + ...(activityBreakdown === 'repositories' ? { activityBreakdown } : {}), }; } @@ -676,7 +702,135 @@ export default class EnvironmentAnalyticsService { return result; } - private async period(knex: Knex, query: EnvironmentAnalyticsQuery, range: ResolvedAnalyticsRange, prior: boolean) { + private async environmentBuckets( + knex: Knex, + query: EnvironmentAnalyticsQuery, + range: ResolvedAnalyticsRange, + prior: boolean, + repositoryIds?: number[] + ): Promise { + const period = prior ? range.previous! : range; + const buckets = environmentBase(knex, query) + .where('e.createdAt', '>=', period.fromUtc) + .where('e.createdAt', '<', period.toUtc) + .select( + analyticsBucketExpression(knex, range, 'e.createdAt', prior ? 'previous' : 'current').wrap('', ' AS date') + ) + .count('e.id as count'); + if (repositoryIds) { + buckets + .select( + knex.raw( + `CASE WHEN e."repositoryId" IS NULL THEN 'unattributed' + WHEN e."repositoryId" = ANY (?::bigint[]) THEN 'repository:' || e."repositoryId"::text + ELSE 'other' END AS key`, + [repositoryIds] + ) + ) + .groupBy('key'); + } + return buckets.groupBy('date'); + } + + private async activityBreakdown( + knex: Knex, + query: EnvironmentAnalyticsQuery, + range: ResolvedAnalyticsRange, + currentRows: CountRow[], + previousRows: CountRow[] + ) { + const repositories = new Map(); + for (const [rows, period] of [ + [currentRows, 'current'], + [previousRows, 'previous'], + ] as const) { + for (const row of rows) { + if (row.repositoryId == null) continue; + const repositoryId = Number(row.repositoryId); + const item = repositories.get(repositoryId) ?? { ...identity(row), current: 0, previous: 0 }; + item[period] += count(row.count); + repositories.set(repositoryId, item); + } + } + const named = [...repositories.values()] + .sort( + (a, b) => + b.current - a.current || + b.previous - a.previous || + (a.fullName ?? '\uffff').localeCompare(b.fullName ?? '\uffff') || + a.repositoryId! - b.repositoryId! + ) + .slice(0, ACTIVITY_REPOSITORY_LIMIT); + const repositoryIds = named.map((row) => row.repositoryId!); + const currentBuckets = await this.environmentBuckets(knex, query, range, false, repositoryIds); + const previousBuckets = range.previous + ? await this.environmentBuckets(knex, query, range, true, repositoryIds) + : []; + const dates = analyticsBucketDates(range); + const dateIndexes = new Map(dates.map((date, index) => [date, index])); + const series = new Map(); + const addSeries = ( + key: string, + row: AnalyticsRepositoryIdentity, + kind: EnvironmentActivityAnalyticsSeries['kind'] + ) => { + const item: EnvironmentActivityAnalyticsSeries = { + ...identity(row), + key, + kind, + firstSeenEnvironments: 0, + previousFirstSeenEnvironments: range.previous ? 0 : null, + buckets: dates.map((date) => ({ + date, + firstSeenEnvironments: 0, + previousFirstSeenEnvironments: range.previous ? 0 : null, + })), + }; + series.set(key, item); + return item; + }; + for (const row of named) addSeries(`repository:${row.repositoryId}`, row, 'repository'); + for (const key of ['other', 'unattributed'] as const) { + if ([...currentBuckets, ...previousBuckets].some((row) => row.key === key)) + addSeries(key, { repositoryId: null, fullName: null, githubInstallationId: null }, key); + } + const collect = (rows: EnvironmentBucketRow[], previous: boolean) => { + const totals = new Map(); + for (const row of rows) { + const index = dateIndexes.get(row.date); + const item = series.get(row.key!); + if (index === undefined || !item) + throw new Error('Environment activity bucket does not match its range or repository.'); + const value = count(row.count); + totals.set(row.date, (totals.get(row.date) ?? 0) + value); + if (previous) { + item.previousFirstSeenEnvironments! += value; + item.buckets[index].previousFirstSeenEnvironments! += value; + } else { + item.firstSeenEnvironments += value; + item.buckets[index].firstSeenEnvironments += value; + } + } + return [...totals].map(([date, value]) => ({ date, count: value })); + }; + const envBuckets = collect(currentBuckets, false); + const previousEnvBuckets = collect(previousBuckets, true); + const activity: EnvironmentActivityBreakdown = { + repositoryLimit: ACTIVITY_REPOSITORY_LIMIT, + repositoryTotal: repositories.size, + groupedRepositories: Math.max(0, repositories.size - named.length), + series: [...series.values()], + }; + return { activity, envBuckets, previousEnvBuckets }; + } + + private async period( + knex: Knex, + query: EnvironmentAnalyticsQuery, + range: ResolvedAnalyticsRange, + prior: boolean, + includeEnvBuckets = true + ) { const period = prior ? range.previous! : range; const environments = environmentBase(knex, query) .where('e.createdAt', '>=', period.fromUtc) @@ -687,13 +841,7 @@ export default class EnvironmentAnalyticsService { .count('e.id as count') .select(knex.raw('count(*) FILTER (WHERE e."repositoryAmbiguous") AS ambiguous')) .groupBy('e.repositoryId', 'r.fullName', 'r.githubInstallationId')) as CountRow[]; - const envBuckets = (await environments - .clone() - .select( - analyticsBucketExpression(knex, range, 'e.createdAt', prior ? 'previous' : 'current').wrap('', ' AS date') - ) - .count('e.id as count') - .groupBy('date')) as { date: string; count: number | string }[]; + const envBuckets = includeEnvBuckets ? await this.environmentBuckets(knex, query, range, prior) : []; let prRows: CountRow[] = [], prBuckets: { date: string; count: number | string }[] = []; if (query.environmentType !== 'static') { @@ -737,8 +885,16 @@ export default class EnvironmentAnalyticsService { async getEnvironments(query: EnvironmentAnalyticsQuery): Promise { return this.transaction(async (knex) => { const range = await resolveAnalyticsRange(knex, query); - const current = await this.period(knex, query, range, false); - const previous = range.previous ? await this.period(knex, query, range, true) : null; + const withActivity = query.activityBreakdown === 'repositories'; + const current = await this.period(knex, query, range, false, !withActivity); + const previous = range.previous ? await this.period(knex, query, range, true, !withActivity) : null; + const breakdown = withActivity + ? await this.activityBreakdown(knex, query, range, current.envRows, previous?.envRows ?? []) + : null; + if (breakdown) { + current.envBuckets = breakdown.envBuckets; + if (previous) previous.envBuckets = breakdown.previousEnvBuckets; + } const inventory = await this.inventory(knex, query); const applicable = query.environmentType !== 'static'; const repositories = new Map(); @@ -810,6 +966,7 @@ export default class EnvironmentAnalyticsService { previousFirstSeenEnvironments: previous ? previousEnvs.get(date) ?? 0 : null, previousObservedPullRequests: previous && applicable ? previousPrs.get(date) ?? 0 : null, })), + ...(breakdown ? { activity: breakdown.activity } : {}), repositories: ranking.slice(0, RANKING_LIMIT), rankingTotal: ranking.length, rankingTruncated: ranking.length > RANKING_LIMIT, diff --git a/src/shared/environmentAnalyticsOpenApi.test.ts b/src/shared/environmentAnalyticsOpenApi.test.ts index ec47fec9..0b3a957b 100644 --- a/src/shared/environmentAnalyticsOpenApi.test.ts +++ b/src/shared/environmentAnalyticsOpenApi.test.ts @@ -44,6 +44,30 @@ describe('Admin environment analytics OpenAPI contract', () => { expect(schemas.EnvironmentAnalyticsPagination.properties.limit.maximum).toBe(100); }); + it('bounds activity series while preserving installation identity and complete comparison buckets', () => { + const activity = schemas.EnvironmentActivityBreakdown; + expect(activity.properties.repositoryLimit.enum).toEqual([5]); + expect(activity.properties.series.maxItems).toBe(7); + expect(activity.required).toEqual(expect.arrayContaining(['series', 'repositoryTotal', 'groupedRepositories'])); + expect(schemas.EnvironmentActivityAnalyticsSeries.properties.kind.enum).toEqual([ + 'repository', + 'other', + 'unattributed', + ]); + expect(schemas.EnvironmentActivityAnalyticsSeries.required).toEqual( + expect.arrayContaining(['key', 'repositoryId', 'githubInstallationId', 'buckets']) + ); + expect(schemas.EnvironmentActivityAnalyticsBucket.properties.previousFirstSeenEnvironments.nullable).toBe(true); + expect(schemas.EnvironmentAnalytics.required).not.toContain('activity'); + expect(schemas.EnvironmentAnalytics.properties.activity.$ref).toBe( + '#/components/schemas/EnvironmentActivityBreakdown' + ); + const operation = spec.paths['/api/v2/admin/analytics/environments'].get; + const breakdown = operation.parameters.find((param: any) => param.name === 'activityBreakdown'); + expect(breakdown.schema.enum).toEqual(['none', 'repositories']); + expect(breakdown.schema.default).toBe('none'); + }); + it('documents complete bounded lifetime points and preserves distinct current-age bands', () => { expect(schemas.EnvironmentLifetimeScatter.properties.pointLimit.enum).toEqual([5000]); expect(schemas.EnvironmentLifetimeScatter.properties.state.enum).toEqual(['ready', 'empty', 'over_limit']); diff --git a/src/shared/environmentAnalyticsOpenApi.ts b/src/shared/environmentAnalyticsOpenApi.ts index 1b716043..cc3fff91 100644 --- a/src/shared/environmentAnalyticsOpenApi.ts +++ b/src/shared/environmentAnalyticsOpenApi.ts @@ -21,10 +21,10 @@ const boolean = { type: 'boolean' }; const ref = (name: string) => ({ $ref: `#/components/schemas/${name}` }); const nullableRef = (name: string) => ({ allOf: [ref(name)], nullable: true }); const array = (items: object) => ({ type: 'array', items }); -const object = (properties: Record) => ({ +const object = (properties: Record, optional: string[] = []) => ({ type: 'object', properties, - required: Object.keys(properties), + required: Object.keys(properties).filter((name) => !optional.includes(name)), additionalProperties: false, }); const success = (name: string) => ({ @@ -99,6 +99,31 @@ export const environmentAnalyticsSchemas = { previousFirstSeenEnvironments: nullableInteger, previousObservedPullRequests: nullableInteger, }), + EnvironmentActivityAnalyticsBucket: object({ + date, + firstSeenEnvironments: integer, + previousFirstSeenEnvironments: nullableInteger, + }), + EnvironmentActivityAnalyticsSeries: object({ + ...repositoryProperties, + key: { ...text, description: 'Stable repository row identity or the other or unattributed group.' }, + kind: { type: 'string', enum: ['repository', 'other', 'unattributed'] }, + firstSeenEnvironments: integer, + previousFirstSeenEnvironments: nullableInteger, + buckets: { ...array(ref('EnvironmentActivityAnalyticsBucket')), maxItems: 365 }, + }), + EnvironmentActivityBreakdown: object({ + repositoryLimit: { type: 'integer', enum: [5] }, + repositoryTotal: { + ...integer, + description: 'Distinct attributed repository row identities with activity in either requested period.', + }, + groupedRepositories: { + ...integer, + description: 'Attributed repository identities included in Other repositories.', + }, + series: { ...array(ref('EnvironmentActivityAnalyticsSeries')), maxItems: 7 }, + }), EnvironmentAnalyticsRetention: object({ earliestRetainedEnvironmentAt: nullableInstant, earliestRetainedPullRequestAt: nullableInstant, @@ -111,19 +136,23 @@ export const environmentAnalyticsSchemas = { description: 'Unknown; no retention completeness boundary is established.', }, }), - EnvironmentAnalytics: object({ - range: ref('AnalyticsRange'), - scope: ref('EnvironmentAnalyticsScope'), - retention: ref('EnvironmentAnalyticsRetention'), - totals: ref('EnvironmentAnalyticsTotals'), - previousTotals: nullableRef('EnvironmentAnalyticsTotals'), - buckets: array(ref('EnvironmentAnalyticsBucket')), - repositories: array(ref('EnvironmentAnalyticsRepository')), - rankingTotal: integer, - rankingTruncated: boolean, - rankBy: { type: 'string', enum: ['first_seen', 'observed_prs', 'pr_coverage'] }, - caveats: array(text), - }), + EnvironmentAnalytics: object( + { + range: ref('AnalyticsRange'), + scope: ref('EnvironmentAnalyticsScope'), + retention: ref('EnvironmentAnalyticsRetention'), + totals: ref('EnvironmentAnalyticsTotals'), + previousTotals: nullableRef('EnvironmentAnalyticsTotals'), + buckets: array(ref('EnvironmentAnalyticsBucket')), + activity: ref('EnvironmentActivityBreakdown'), + repositories: array(ref('EnvironmentAnalyticsRepository')), + rankingTotal: integer, + rankingTruncated: boolean, + rankBy: { type: 'string', enum: ['first_seen', 'observed_prs', 'pr_coverage'] }, + caveats: array(text), + }, + ['activity'] + ), InventoryAnalyticsTotals: object(inventoryProperties), InventoryAnalyticsRepository: object({ ...repositoryProperties, ...inventoryProperties }), ManagedServiceTypeCounts: object({ From 6471f898d067266a53ada73d543371c2246104e1 Mon Sep 17 00:00:00 2001 From: vmelikyan Date: Sun, 4 Oct 2026 15:58:04 -0700 Subject: [PATCH 7/9] fix(analytics): return complete environment names Return complete names through the existing record and scatter responses. Describe record duration without implying deployed resources. Keep existing query counts and measurement limits. --- ...nvironmentLifetimeAnalyticsService.test.ts | 84 +++++++++++++++++++ .../EnvironmentLifetimeAnalyticsService.ts | 10 ++- .../environmentAnalyticsOpenApi.test.ts | 9 ++ .../environmentLifetimeAnalyticsOpenApi.ts | 1 + 4 files changed, 100 insertions(+), 4 deletions(-) diff --git a/src/server/services/analytics/EnvironmentLifetimeAnalyticsService.test.ts b/src/server/services/analytics/EnvironmentLifetimeAnalyticsService.test.ts index f41aa823..76fdf12a 100644 --- a/src/server/services/analytics/EnvironmentLifetimeAnalyticsService.test.ts +++ b/src/server/services/analytics/EnvironmentLifetimeAnalyticsService.test.ts @@ -277,6 +277,60 @@ describe('EnvironmentLifetimeAnalyticsService', () => { truncated: false, }); }); + it.each(['current', 'completed'] as const)( + 'preserves the full environment identity in %s lifetime records without an extra query', + async (cohort) => { + const record = { + id: 12, + uuid: 'lifetime-environment-with-a-full-retained-name-123456', + namespace: 'env-lifetime-environment-with-a-full-retained-name-123456', + status: cohort === 'completed' ? 'torn_down' : 'deployed', + group: 'pr', + resourceAvailable: cohort === 'current', + sampleState: 'valid', + durationHours: 24, + startedAt: '2026-03-08T12:00:00Z', + measuredUntilAt: '2026-03-09T12:00:00Z', + repositoryId: 4, + githubInstallationId: 8, + }; + const { service, raw } = serviceWithRows([[{ total: 1, records: [record] }]]); + const result = await service.getRecords(parseEnvironmentLifetimeRecordsQuery(params(`cohort=${cohort}`))); + expect(result.records[0]).toMatchObject({ + uuid: record.uuid, + namespace: record.namespace, + status: record.status, + resourceAvailable: record.resourceAvailable, + }); + const calls = raw.mock.calls.filter(([sql]) => sql.includes('lifetime_source AS')); + expect(calls).toHaveLength(1); + expect(calls[0][0]).toContain('b.namespace'); + expect(calls[0][0]).toContain("'namespace', namespace"); + } + ); + it('preserves a missing namespace as null without shortening the retained UUID', async () => { + const uuid = 'lifetime-environment-without-a-namespace-123456'; + const { service } = serviceWithRows([ + [ + { + total: 1, + records: [ + { + id: 12, + uuid, + namespace: null, + group: 'pr', + resourceAvailable: false, + repositoryId: null, + githubInstallationId: null, + }, + ], + }, + ], + ]); + const result = await service.getRecords(parseEnvironmentLifetimeRecordsQuery(params())); + expect(result.records[0]).toMatchObject({ uuid, namespace: null, resourceAvailable: false }); + }); it('stops at the supported page bound while reporting uncapped totals', async () => { const { service } = serviceWithRows([[{ total: 1000001, records: [] }]]); const result = await service.getRecords(parseEnvironmentLifetimeRecordsQuery(params('page=10000&limit=100'))); @@ -374,6 +428,36 @@ describe('EnvironmentLifetimeAnalyticsService', () => { expect(call[1]).toEqual(expect.arrayContaining([4, range.fromUtc, range.toUtc, 'api', 5000])); expect(call[1]).not.toContain(range.previous!.fromUtc); }); + it('preserves a retired environment name in scatter points through the existing projection', async () => { + const point = { + id: 12, + uuid: 'lifetime-environment-with-a-full-retained-name-123456', + namespace: 'env-lifetime-environment-with-a-full-retained-name-123456', + group: 'pr', + resourceAvailable: false, + status: 'torn_down', + sampleState: 'valid', + durationHours: 24, + startedAt: '2026-03-08T12:00:00Z', + measuredUntilAt: '2026-03-09T12:00:00Z', + repositoryId: 4, + githubInstallationId: 8, + }; + const { service, raw } = serviceWithRows([ + [{ total: 1, points: [point], coverage: [{ group_name: 'pr', eligible: 1, samples: 1 }] }], + ]); + const result = await service.getScatter(parseEnvironmentLifetimeScatterQuery(params('group=pr'))); + expect(result.points[0]).toMatchObject({ + uuid: point.uuid, + namespace: point.namespace, + resourceAvailable: false, + status: 'torn_down', + }); + const calls = raw.mock.calls.filter(([sql]) => sql.includes('lifetime_source AS')); + expect(calls).toHaveLength(1); + expect(calls[0][0]).toContain('SELECT id, uuid, namespace, status'); + expect(calls[0][0]).toContain("'namespace', namespace"); + }); it.each([0, 5000, 5001])('scatter returns all or none at a total of %i, without a biased sample', async (total) => { const points = total <= 5000 diff --git a/src/server/services/analytics/EnvironmentLifetimeAnalyticsService.ts b/src/server/services/analytics/EnvironmentLifetimeAnalyticsService.ts index d7beaee6..657ac66f 100644 --- a/src/server/services/analytics/EnvironmentLifetimeAnalyticsService.ts +++ b/src/server/services/analytics/EnvironmentLifetimeAnalyticsService.ts @@ -104,6 +104,7 @@ export type EnvironmentLifetimeRecordsQuery = EnvironmentAnalyticsScope & { export type EnvironmentLifetimeRecord = AnalyticsRepositoryIdentity & { id: number; uuid: string | null; + namespace: string | null; status: string | null; isStatic: boolean; author: string | null; @@ -158,8 +159,8 @@ export type EnvironmentLifetimeScatter = { const MAX_PAGE = 10_000; const CAVEATS = [ - 'API lifetime is the recorded time from first creation to identity release, not ready time or live resource uptime.', - 'PR lifetime is an estimate from first creation to the latest update of a currently torn-down record. Later edits and reused records can include inactive gaps.', + 'API record duration measures creation to recorded deletion. It includes records that never deployed and does not measure runtime lifetime.', + 'PR record duration estimates creation to the latest update of a torn-down record. It includes records that never deployed. Waiting time, later edits and reuse gaps can increase it.', 'Current age is time since the first retained record. It includes queued, paused and failed time and can include earlier PR episodes.', 'Retirement dates select completed samples. Missing or nonfinite end dates cannot be placed in a window; unplaced counts cover the entire selected scope.', 'Counts cover retained environment records, not deployment attempts. Collection start and history completeness are unknown.', @@ -332,7 +333,7 @@ function aggregateSql(cohort: 'completed' | 'current'): string { } const RECORD_JSON_SQL = `jsonb_build_object( - 'id', id, 'uuid', uuid, 'status', status, 'isStatic', COALESCE("isStatic", false), + 'id', id, 'uuid', uuid, 'namespace', namespace, 'status', status, 'isStatic', COALESCE("isStatic", false), 'author', author, 'repositoryId', "repositoryId", 'fullName', "fullName", 'githubInstallationId', "githubInstallationId", 'repositoryAmbiguous', "repositoryAmbiguous", 'resourceAvailable', ("deletedAt" IS NULL AND (status IS NULL OR status <> ?) AND NULLIF(uuid, '') IS NOT NULL), @@ -351,6 +352,7 @@ function serializeRecord( ...record, ...method(record.group, cohort), id: count(record.id), + namespace: record.namespace ?? null, repositoryId: record.repositoryId == null ? null : count(record.repositoryId), githubInstallationId: record.githubInstallationId == null ? null : count(record.githubInstallationId), durationHours: nullableNumber(record.durationHours), @@ -465,7 +467,7 @@ export default class EnvironmentLifetimeAnalyticsService { const groupPredicate = query.group === 'all' ? '' : 'AND group_name = ?'; const result = await trx.raw( `WITH ${facts.sql}, windowed AS MATERIALIZED ( - SELECT id, uuid, status, "isStatic", author, "repositoryId", "fullName", "githubInstallationId", + SELECT id, uuid, namespace, status, "isStatic", author, "repositoryId", "fullName", "githubInstallationId", "repositoryAmbiguous", "deletedAt", "pullRequestNumber", "prTitle", "prAuthor", group_name, start_at, end_at, sample_state, duration_hours FROM facts WHERE group_name IN ('api','pr') AND isfinite(end_at) diff --git a/src/shared/environmentAnalyticsOpenApi.test.ts b/src/shared/environmentAnalyticsOpenApi.test.ts index 0b3a957b..cefcdf96 100644 --- a/src/shared/environmentAnalyticsOpenApi.test.ts +++ b/src/shared/environmentAnalyticsOpenApi.test.ts @@ -87,6 +87,15 @@ describe('Admin environment analytics OpenAPI contract', () => { ); }); + it.each(['EnvironmentLifetimeRecord', 'EnvironmentLifetimeScatterPoint'])( + 'includes the nullable full environment namespace in %s', + (name) => { + expect(schemas[name].properties.namespace).toEqual({ type: 'string', nullable: true }); + expect(schemas[name].required).toContain('namespace'); + expect(schemas[name].properties.uuid).toEqual({ type: 'string', nullable: true }); + } + ); + it('documents exact session drilldown and session totals before bounded paging', () => { const sessions = spec.paths['/api/v2/admin/analytics/agents/sessions'].get; expect(sessions.operationId).toBe('getAgentAnalyticsSessions'); diff --git a/src/shared/environmentLifetimeAnalyticsOpenApi.ts b/src/shared/environmentLifetimeAnalyticsOpenApi.ts index 06fc627f..4b54d35d 100644 --- a/src/shared/environmentLifetimeAnalyticsOpenApi.ts +++ b/src/shared/environmentLifetimeAnalyticsOpenApi.ts @@ -113,6 +113,7 @@ export const environmentLifetimeAnalyticsSchemas = { EnvironmentLifetimeRecord: object({ id: integer, uuid: nullableText, + namespace: nullableText, status: nullableText, isStatic: boolean, author: nullableText, From 782af5aaaee8a86798ab8f8c7ddad06d66e5fcba Mon Sep 17 00:00:00 2001 From: vmelikyan Date: Sun, 4 Oct 2026 23:56:46 -0700 Subject: [PATCH 8/9] fix(analytics): keep inventory usable with invalid dates Return null for invalid retained dates instead of failing inventory. Keep valid records and current phase counts available. --- .../EnvironmentAnalyticsService.test.ts | 22 +++++++++++++++++++ .../analytics/EnvironmentAnalyticsService.ts | 6 ++++- 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/src/server/services/analytics/EnvironmentAnalyticsService.test.ts b/src/server/services/analytics/EnvironmentAnalyticsService.test.ts index 38656139..2af616f7 100644 --- a/src/server/services/analytics/EnvironmentAnalyticsService.test.ts +++ b/src/server/services/analytics/EnvironmentAnalyticsService.test.ts @@ -213,6 +213,28 @@ describe('EnvironmentAnalyticsService contracts', () => { expect(analyticsEnvironmentRecord(row(10, { uuid: null }) as any).resourceAvailable).toBe(false); }); + it('keeps inventory usable when a retained record has a nonfinite timestamp', async () => { + const { service } = serviceWithExecutor((query) => { + if (query.sql.startsWith('SET ')) return { rows: [] }; + if (query.sql.includes('CURRENT_TIMESTAMP')) return [{ asOf: range.asOf }]; + if (query.sql.includes(' AS deploys')) + return [ + row(1), + row(2, { + status: 'error', + createdAt: Number.POSITIVE_INFINITY, + updatedAt: Number.NEGATIVE_INFINITY, + expiresAt: 'not-a-date', + }), + ]; + return []; + }); + const result = await service.getInventory(scope); + expect(result.totals).toMatchObject({ current: 2, ready: 1, failed: 1 }); + expect(result.exceptions[0]).toMatchObject({ id: 2, createdAt: null, updatedAt: null, expiresAt: null }); + expect(analyticsEnvironmentRecord(row(1) as any).createdAt).toBe('2026-09-30T10:00:00.000Z'); + }); + it('processes all inventory batches while bounding exception output', async () => { const rows = Array.from({ length: 1005 }, (_, index) => row(index + 1, { diff --git a/src/server/services/analytics/EnvironmentAnalyticsService.ts b/src/server/services/analytics/EnvironmentAnalyticsService.ts index e5815668..9c289286 100644 --- a/src/server/services/analytics/EnvironmentAnalyticsService.ts +++ b/src/server/services/analytics/EnvironmentAnalyticsService.ts @@ -277,7 +277,11 @@ const CAVEATS = [ 'Historical counts cover retained first-seen records, not deployment attempts. Collection and retention start are unknown.', 'API-created environments have no installation binding; duplicate active repository matches are unattributed and counted as ambiguous.', ]; -const timestamp = (value: Date | string | null) => (value == null ? null : new Date(value).toISOString()); +const timestamp = (value: Date | string | null) => { + if (value == null) return null; + const date = new Date(value); + return Number.isFinite(date.getTime()) ? date.toISOString() : null; +}; const count = (value: unknown) => Number(value) || 0; const identityKey = (value: number | null) => (value == null ? 'unattributed' : String(value)); const identity = (row: AnalyticsRepositoryIdentity): AnalyticsRepositoryIdentity => ({ From f5ea0fb1b576dcf21e7a6363179f836a9d522caf Mon Sep 17 00:00:00 2001 From: vmelikyan Date: Mon, 5 Oct 2026 00:16:42 -0700 Subject: [PATCH 9/9] fix(analytics): return invalid agent dates as null Return null for invalid queue and earliest run dates. Permit a null queue date without changing submission dates or counts. --- .../analytics/AgentAnalyticsService.test.ts | 49 +++++++++++++++++++ .../analytics/AgentAnalyticsService.ts | 7 +-- src/shared/agentAnalyticsOpenApi.ts | 1 + .../environmentAnalyticsOpenApi.test.ts | 19 +++++++ 4 files changed, 73 insertions(+), 3 deletions(-) diff --git a/src/server/services/analytics/AgentAnalyticsService.test.ts b/src/server/services/analytics/AgentAnalyticsService.test.ts index c5339ab1..72b8dd97 100644 --- a/src/server/services/analytics/AgentAnalyticsService.test.ts +++ b/src/server/services/analytics/AgentAnalyticsService.test.ts @@ -223,6 +223,55 @@ describe('AgentAnalyticsService', () => { expect(bindings).toEqual([range.fromUtc, range.toUtc, 'failed', 25, 25]); }); + it('keeps usage totals when the earliest retained run has a nonfinite date', async () => { + const { service } = serviceWithRows([ + [{ period: 'current', group_type: 'total', runs: 1, sessions: 1, total_tokens: 300, total_reported_runs: 1 }], + [{ period: 'current', count: 1 }], + [{ earliest: Number.NEGATIVE_INFINITY }], + ]); + const result = await service.getSummary({}); + expect(result.earliestRunAt).toBeNull(); + expect(result.asOf).toBe(range.asOf); + expect(result.totals).toMatchObject({ runs: 1, sessions: 1, tokens: { total: 300, reportedRuns: 1 } }); + expect(result.sessionsCreated.current).toBe(1); + }); + + it('returns missing queue dates while preserving valid dates and run totals', async () => { + const validString = '2026-03-08T08:00:00+00:00'; + const queueDates = [ + 'infinity', + Number.NEGATIVE_INFINITY, + new Date(NaN), + 'not-a-date', + validString, + new Date(range.fromUtc), + ]; + const { service } = serviceWithRows([ + [ + { + total: queueDates.length, + runs: queueDates.map((queued_at, index) => ({ + run_uuid: `run-${index}`, + session_uuid: 'session', + thread_uuid: 'thread', + submitted_at: range.fromUtc, + queued_at, + status: 'completed', + provider: 'provider', + model: 'model', + owner_id: 'owner', + total_tokens: 100, + })), + }, + ], + ]); + const result = await service.listRuns({}); + expect(result.runs.map((run) => run.queuedAt)).toEqual([null, null, null, null, validString, range.fromUtc]); + expect(result.runs.every((run) => run.submittedAt === range.fromUtc && run.tokens.total === 100)).toBe(true); + expect(result.pagination).toEqual({ page: 1, limit: 25, total: queueDates.length, hasMore: false }); + expect(result.asOf).toBe(range.asOf); + }); + it('returns only run metadata while preserving unavailable usage and cost', async () => { const { service } = serviceWithRows([ [ diff --git a/src/server/services/analytics/AgentAnalyticsService.ts b/src/server/services/analytics/AgentAnalyticsService.ts index 091c2565..8601b6e7 100644 --- a/src/server/services/analytics/AgentAnalyticsService.ts +++ b/src/server/services/analytics/AgentAnalyticsService.ts @@ -106,7 +106,7 @@ export interface AgentAnalyticsRun { sessionId: string; threadId: string; submittedAt: string; - queuedAt: string; + queuedAt: string | null; status: AgentAnalyticsStatus; repository: string | null; provider: string; @@ -248,7 +248,8 @@ function nullableNumber(value: unknown): number | null { function timestamp(value: unknown): string | null { if (value == null) return null; - return value instanceof Date ? value.toISOString() : String(value); + if (value instanceof Date) return Number.isFinite(value.getTime()) ? value.toISOString() : null; + return typeof value === 'string' && Number.isFinite(Date.parse(value)) ? value : null; } export function serializeAgentAnalyticsMetrics(row?: SqlRow): AgentAnalyticsMetrics { @@ -551,7 +552,7 @@ export default class AgentAnalyticsService { sessionId: String(run.session_uuid), threadId: String(run.thread_uuid), submittedAt: timestamp(run.submitted_at)!, - queuedAt: timestamp(run.queued_at)!, + queuedAt: timestamp(run.queued_at), status: run.status as AgentAnalyticsStatus, repository: run.repository == null ? null : String(run.repository), provider: String(run.provider), diff --git a/src/shared/agentAnalyticsOpenApi.ts b/src/shared/agentAnalyticsOpenApi.ts index c77627e9..f1e9b745 100644 --- a/src/shared/agentAnalyticsOpenApi.ts +++ b/src/shared/agentAnalyticsOpenApi.ts @@ -114,6 +114,7 @@ export const agentAnalyticsSchemas = { queuedAt: { type: 'string', format: 'date-time', + nullable: true, description: 'Latest queue time; can change when an approval resumes the run.', }, status: { type: 'string', enum: statuses }, diff --git a/src/shared/environmentAnalyticsOpenApi.test.ts b/src/shared/environmentAnalyticsOpenApi.test.ts index cefcdf96..8872e54d 100644 --- a/src/shared/environmentAnalyticsOpenApi.test.ts +++ b/src/shared/environmentAnalyticsOpenApi.test.ts @@ -122,6 +122,25 @@ describe('Admin environment analytics OpenAPI contract', () => { expect(runs.parameters.find((param: any) => param.name === 'sessionId').schema.format).toBe('uuid'); }); + it('permits missing queue dates while keeping submission dates required and nonnullable', () => { + expect(schemas.AgentAnalyticsRun.properties.queuedAt).toMatchObject({ + type: 'string', + format: 'date-time', + nullable: true, + }); + expect(schemas.AgentAnalyticsRun.required).toEqual(expect.arrayContaining(['submittedAt', 'queuedAt'])); + expect(schemas.AgentAnalyticsSummary.properties.earliestRunAt.nullable).toBe(true); + for (const [schema, field] of [ + ['AgentAnalyticsRun', 'submittedAt'], + ['AgentAnalyticsSession', 'firstSubmittedAt'], + ['AgentAnalyticsSession', 'lastSubmittedAt'], + ['AgentAnalyticsOptions', 'asOf'], + ]) { + expect(schemas[schema].required).toContain(field); + expect(schemas[schema].properties[field].nullable).not.toBe(true); + } + }); + it('models calendar bounds, optional comparison and inapplicable PR values honestly', () => { const operation = spec.paths['/api/v2/admin/analytics/environments'].get; expect(operation.parameters.find((param: any) => param.name === 'compare').schema.default).toBe(true);