Skip to content
48 changes: 48 additions & 0 deletions src/app/api/v2/admin/analytics/agents/options/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
/**
* 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:
* '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:
* 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'] }
);
187 changes: 187 additions & 0 deletions src/app/api/v2/admin/analytics/agents/route.test.ts
Original file line number Diff line number Diff line change
@@ -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.
*/

import { NextRequest } from 'next/server';

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: {} }));
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,
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 {
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 } });
mockSessions.mockResolvedValue({ sessions: [], 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],
['sessions', sessions],
['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],
['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'] } });
mockIdentity.mockReturnValue({ userId: 'user', roles: ['user'] });
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();
req.headers.set('authorization', 'Bearer lfc_test');
expect((await handler(req)).status).toBe(403);
expect(mockSummary).not.toHaveBeenCalled();
expect(mockRuns).not.toHaveBeenCalled();
expect(mockSessions).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();
expect(mockSessions).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);
});
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();
});
});
68 changes: 68 additions & 0 deletions src/app/api/v2/admin/analytics/agents/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
/**
* 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:
* '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:
* 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'] }
);
79 changes: 79 additions & 0 deletions src/app/api/v2/admin/analytics/agents/runs/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
/**
* 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: 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}}
* 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:
* 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'] }
);
Loading
Loading