From 3b56f52e14cbe6e36158c56846675f6bac97d20c Mon Sep 17 00:00:00 2001 From: Matt Rubens <2600+mrubens@users.noreply.github.com> Date: Thu, 16 Jul 2026 12:15:45 -0400 Subject: [PATCH 1/2] optimize analytics overview queries --- .../analytics/Analytics.client.test.tsx | 38 +++++--- .../(authenticated)/analytics/Analytics.tsx | 19 +--- apps/web/src/hooks/analytics/index.ts | 4 +- .../hooks/analytics/useAnalyticsFilters.ts | 30 ------ ...lyticsChart.ts => useAnalyticsOverview.ts} | 4 +- .../lib/server/analytics/cost-rows.test.ts | 96 +++++++++++++++++-- .../web/src/lib/server/analytics/cost-rows.ts | 78 ++++++++------- apps/web/src/lib/server/analytics/index.ts | 42 ++++++++ apps/web/src/trpc/commands/analytics/index.ts | 1 + .../src/trpc/commands/analytics/overview.ts | 25 +++++ apps/web/src/trpc/routers/_app.ts | 8 ++ apps/web/src/types/analytics.ts | 9 ++ 12 files changed, 253 insertions(+), 101 deletions(-) delete mode 100644 apps/web/src/hooks/analytics/useAnalyticsFilters.ts rename apps/web/src/hooks/analytics/{useAnalyticsChart.ts => useAnalyticsOverview.ts} (88%) create mode 100644 apps/web/src/trpc/commands/analytics/overview.ts diff --git a/apps/web/src/app/(authenticated)/analytics/Analytics.client.test.tsx b/apps/web/src/app/(authenticated)/analytics/Analytics.client.test.tsx index f6b10cee9..53677afd4 100644 --- a/apps/web/src/app/(authenticated)/analytics/Analytics.client.test.tsx +++ b/apps/web/src/app/(authenticated)/analytics/Analytics.client.test.tsx @@ -6,6 +6,9 @@ const state = vi.hoisted(() => ({ push: vi.fn(), replace: vi.fn(), })); +const hooks = vi.hoisted(() => ({ + useAnalyticsOverview: vi.fn(), +})); const EMPTY_CHART = { object: 'pullRequests' as const, @@ -45,23 +48,12 @@ vi.mock('@/hooks/useDelayedRefetchLoading', () => ({ })); vi.mock('@/hooks/analytics', () => ({ - useAnalyticsChart: () => ({ - data: EMPTY_CHART, - isLoading: false, - isFetching: false, - isError: false, - }), useAnalyticsDetails: () => ({ data: null, isLoading: false, isError: false, }), - useAnalyticsFilters: () => ({ - data: { filters: {} }, - isLoading: false, - isFetching: false, - isError: false, - }), + useAnalyticsOverview: hooks.useAnalyticsOverview, usePullRequestAnalyticsOverview: () => ({ data: { summary: null, @@ -136,6 +128,16 @@ describe('Analytics', () => { state.searchParams = new URLSearchParams(); state.push.mockReset(); state.replace.mockReset(); + hooks.useAnalyticsOverview.mockReset(); + hooks.useAnalyticsOverview.mockReturnValue({ + data: { + chart: EMPTY_CHART, + filterOptions: { filters: {} }, + }, + isLoading: false, + isFetching: false, + isError: false, + }); }); it('treats unknown analytics objects as invalid on the generic /analytics page', () => { @@ -155,4 +157,16 @@ describe('Analytics', () => { expect(state.push).toHaveBeenCalledWith('/analytics/costs'); expect(state.replace).not.toHaveBeenCalled(); }); + + it('loads costs through the combined analytics overview query', () => { + render(); + + expect(hooks.useAnalyticsOverview).toHaveBeenCalledWith( + expect.objectContaining({ + object: 'costs', + timePeriod: 7, + }), + { enabled: true }, + ); + }); }); diff --git a/apps/web/src/app/(authenticated)/analytics/Analytics.tsx b/apps/web/src/app/(authenticated)/analytics/Analytics.tsx index b4a7946c8..3d19207e3 100644 --- a/apps/web/src/app/(authenticated)/analytics/Analytics.tsx +++ b/apps/web/src/app/(authenticated)/analytics/Analytics.tsx @@ -25,9 +25,8 @@ import { parseTimePeriodParam, } from '@/types'; import { - useAnalyticsChart, useAnalyticsDetails, - useAnalyticsFilters, + useAnalyticsOverview, usePullRequestAnalyticsOverview, } from '@/hooks/analytics'; import { useDelayedRefetchLoading } from '@/hooks/useDelayedRefetchLoading'; @@ -128,7 +127,7 @@ export function Analytics({ const availableGranularities = getAvailableAnalyticsGranularities(timePeriod); const analyticsReloadKey = String(reloadRequestId); - const chartQuery = useAnalyticsChart( + const overviewQuery = useAnalyticsOverview( { object, viewBy, @@ -139,14 +138,6 @@ export function Analytics({ }, { enabled: object !== 'pullRequests' }, ); - const filtersQuery = useAnalyticsFilters( - { - object, - filters, - timePeriod, - }, - { enabled: object !== 'pullRequests' }, - ); const pullRequestOverviewQuery = usePullRequestAnalyticsOverview( { viewBy, @@ -176,13 +167,13 @@ export function Analytics({ const chart = object === 'pullRequests' ? pullRequestOverviewQuery.data?.chart - : chartQuery.data; + : overviewQuery.data?.chart; const filterOptions = object === 'pullRequests' ? (pullRequestOverviewQuery.data?.filterOptions.filters ?? {}) - : (filtersQuery.data?.filters ?? {}); + : (overviewQuery.data?.filterOptions.filters ?? {}); const activeChartQuery = - object === 'pullRequests' ? pullRequestOverviewQuery : chartQuery; + object === 'pullRequests' ? pullRequestOverviewQuery : overviewQuery; const showChartReloadLoading = useDelayedRefetchLoading({ loadingKey: analyticsReloadKey, isFetching: isParamsTransitionPending || activeChartQuery.isFetching, diff --git a/apps/web/src/hooks/analytics/index.ts b/apps/web/src/hooks/analytics/index.ts index 13329b574..f64cfba31 100644 --- a/apps/web/src/hooks/analytics/index.ts +++ b/apps/web/src/hooks/analytics/index.ts @@ -1,5 +1,3 @@ -export { useAnalyticsChart } from './useAnalyticsChart'; -export { useAnalyticsFilters } from './useAnalyticsFilters'; - export { useAnalyticsDetails } from './useAnalyticsDetails'; +export { useAnalyticsOverview } from './useAnalyticsOverview'; export { usePullRequestAnalyticsOverview } from './usePullRequestAnalyticsOverview'; diff --git a/apps/web/src/hooks/analytics/useAnalyticsFilters.ts b/apps/web/src/hooks/analytics/useAnalyticsFilters.ts deleted file mode 100644 index 0a47a747a..000000000 --- a/apps/web/src/hooks/analytics/useAnalyticsFilters.ts +++ /dev/null @@ -1,30 +0,0 @@ -'use client'; - -import { keepPreviousData, useQuery } from '@tanstack/react-query'; - -import type { - AnalyticsFilters, - AnalyticsObject, - TimePeriodFilter, -} from '@/types'; -import { useTRPC } from '@/trpc/client'; - -export function useAnalyticsFilters( - input: { - object: AnalyticsObject; - filters?: AnalyticsFilters; - timePeriod?: TimePeriodFilter; - }, - options?: { - enabled?: boolean; - }, -) { - const trpc = useTRPC(); - - return useQuery( - trpc.analytics.filters.queryOptions(input, { - placeholderData: keepPreviousData, - enabled: options?.enabled, - }), - ); -} diff --git a/apps/web/src/hooks/analytics/useAnalyticsChart.ts b/apps/web/src/hooks/analytics/useAnalyticsOverview.ts similarity index 88% rename from apps/web/src/hooks/analytics/useAnalyticsChart.ts rename to apps/web/src/hooks/analytics/useAnalyticsOverview.ts index 736eaa666..c8b987405 100644 --- a/apps/web/src/hooks/analytics/useAnalyticsChart.ts +++ b/apps/web/src/hooks/analytics/useAnalyticsOverview.ts @@ -12,7 +12,7 @@ import type { } from '@/types'; import { useTRPC } from '@/trpc/client'; -export function useAnalyticsChart( +export function useAnalyticsOverview( input: { object: AnalyticsObject; viewBy: AnalyticsDimension; @@ -28,7 +28,7 @@ export function useAnalyticsChart( const trpc = useTRPC(); return useQuery( - trpc.analytics.chart.queryOptions(input, { + trpc.analytics.overview.queryOptions(input, { placeholderData: keepPreviousData, enabled: options?.enabled, }), diff --git a/apps/web/src/lib/server/analytics/cost-rows.test.ts b/apps/web/src/lib/server/analytics/cost-rows.test.ts index dc6a3361d..422e44b27 100644 --- a/apps/web/src/lib/server/analytics/cost-rows.test.ts +++ b/apps/web/src/lib/server/analytics/cost-rows.test.ts @@ -1,4 +1,17 @@ -import { db, inArray, llmUsageEvents } from '@roomote/db/server'; +import { + db, + environmentFactory, + environments, + inArray, + llmUsageEvents, + runFactory, + taskFactory, + taskPullRequests, + tasks, + userFactory, + users, +} from '@roomote/db/server'; +import { TaskPayloadKind } from '@roomote/types'; import type { UserAuthSuccess } from '@/types'; @@ -6,16 +19,31 @@ import { getCostAnalyticsRows } from './cost-rows'; describe('getCostAnalyticsRows', () => { const usageEventIds: string[] = []; + const taskIds: string[] = []; + const environmentIds: string[] = []; + const userIds: string[] = []; afterEach(async () => { - if (usageEventIds.length === 0) { - return; + if (usageEventIds.length > 0) { + await db + .delete(llmUsageEvents) + .where(inArray(llmUsageEvents.id, usageEventIds)); + usageEventIds.length = 0; + } + if (taskIds.length > 0) { + await db.delete(tasks).where(inArray(tasks.id, taskIds)); + taskIds.length = 0; + } + if (environmentIds.length > 0) { + await db + .delete(environments) + .where(inArray(environments.id, environmentIds)); + environmentIds.length = 0; + } + if (userIds.length > 0) { + await db.delete(users).where(inArray(users.id, userIds)); + userIds.length = 0; } - - await db - .delete(llmUsageEvents) - .where(inArray(llmUsageEvents.id, usageEventIds)); - usageEventIds.length = 0; }); it('encodes finite time-period cutoffs and excludes older usage', async () => { @@ -48,4 +76,56 @@ describe('getCostAnalyticsRows', () => { expect(rowIds.has(recentEvent.id)).toBe(true); expect(rowIds.has(oldEvent.id)).toBe(false); }); + + it('uses the run environment fallback and attributes PRs by distinct task', async () => { + const user = await userFactory.create(); + userIds.push(user.id); + const environment = await environmentFactory.create({ + userId: user.id, + createdByUserId: user.id, + name: `Cost analytics ${crypto.randomUUID()}`, + }); + environmentIds.push(environment.id); + const task = await taskFactory.create({ initiatorUserId: user.id }); + taskIds.push(task.id); + const run = await runFactory.create({ + taskId: task.id, + actingUserId: user.id, + payloadKind: TaskPayloadKind.StandardTask, + payload: { + environmentId: environment.id, + description: 'Test cost analytics attribution', + }, + }); + await db.insert(taskPullRequests).values({ + taskId: task.id, + prUrl: 'https://github.com/roomote/test/pull/42', + prNumber: 42, + repository: 'roomote/test', + sourceControlProvider: 'github', + host: 'github.com', + }); + const [usageEvent] = await db + .insert(llmUsageEvents) + .values({ + eventKey: `cost-analytics-task-${crypto.randomUUID()}`, + costSource: 'missing', + taskId: task.id, + runId: run.id, + costMicroUsd: 1_000_000, + messageCompletedAt: new Date('2026-07-15T12:00:00.000Z'), + }) + .returning({ id: llmUsageEvents.id }); + usageEventIds.push(usageEvent!.id); + + const rows = await getCostAnalyticsRows( + {} as UserAuthSuccess, + 7, + new Date('2026-07-16T16:00:00.000Z'), + ); + const row = rows.find((candidate) => candidate.id === usageEvent!.id); + + expect(row?.dimensions.project?.label).toBe(environment.name); + expect(row?.meta?.prKeys).toEqual(['github:github.com:roomote/test#42']); + }); }); diff --git a/apps/web/src/lib/server/analytics/cost-rows.ts b/apps/web/src/lib/server/analytics/cost-rows.ts index f1e5cbf2c..70feb11a0 100644 --- a/apps/web/src/lib/server/analytics/cost-rows.ts +++ b/apps/web/src/lib/server/analytics/cost-rows.ts @@ -7,6 +7,7 @@ import { llmUsageEvents, and, eq, + inArray, isNull, sql, } from '@roomote/db/server'; @@ -61,7 +62,9 @@ export async function getCostAnalyticsRows( eventUserEmail: usageUsers.email, taskUserName: taskInitiatorUsers.name, taskUserEmail: taskInitiatorUsers.email, - runPayload: taskRuns.payload, + runEnvironmentId: sql< + string | null + >`${taskRuns.payload} ->> 'environmentId'`, }) .from(llmUsageEvents) .leftJoin(tasks, eq(tasks.id, llmUsageEvents.taskId)) @@ -74,31 +77,49 @@ export async function getCostAnalyticsRows( .leftJoin(environments, eq(environments.id, llmUsageEvents.environmentId)) .where(and(isNull(tasks.deletedAt), usageCutoffCondition)); - const environmentRows = await db - .select({ id: environments.id, name: environments.name }) - .from(environments) - .where(eq(environments.isEval, false)); + const fallbackEnvironmentIds = [ + ...new Set( + usageRows + .filter((row) => !row.environmentName) + .map((row) => row.runEnvironmentId) + .filter((id): id is string => Boolean(id)), + ), + ]; + const environmentRows = + fallbackEnvironmentIds.length === 0 + ? [] + : await db + .select({ id: environments.id, name: environments.name }) + .from(environments) + .where( + and( + eq(environments.isEval, false), + inArray(environments.id, fallbackEnvironmentIds), + ), + ); const environmentNameById = new Map( environmentRows.map((environment) => [environment.id, environment.name]), ); - // Fetch PR attribution with a relational join instead of expanding one bind - // parameter per usage event. A task can have many usage rows, so an inArray - // built from usageRows eventually exceeds PostgreSQL's parameter limit. - const pullRequestRows = await db - .selectDistinct({ - taskId: taskPullRequests.taskId, - repository: taskPullRequests.repository, - prNumber: taskPullRequests.prNumber, - sourceControlProvider: taskPullRequests.sourceControlProvider, - host: taskPullRequests.host, - }) - .from(taskPullRequests) - .innerJoin( - llmUsageEvents, - eq(llmUsageEvents.taskId, taskPullRequests.taskId), - ) - .leftJoin(tasks, eq(tasks.id, llmUsageEvents.taskId)) - .where(and(isNull(tasks.deletedAt), usageCutoffCondition)); + const taskIds = [ + ...new Set( + usageRows + .map((row) => row.taskId) + .filter((taskId): taskId is string => Boolean(taskId)), + ), + ]; + const pullRequestRows = + taskIds.length === 0 + ? [] + : await db + .select({ + taskId: taskPullRequests.taskId, + repository: taskPullRequests.repository, + prNumber: taskPullRequests.prNumber, + sourceControlProvider: taskPullRequests.sourceControlProvider, + host: taskPullRequests.host, + }) + .from(taskPullRequests) + .where(inArray(taskPullRequests.taskId, taskIds)); const prKeysByTaskId = new Map>(); for (const pullRequest of pullRequestRows) { if (pullRequest.prNumber === null || !pullRequest.repository) { @@ -140,17 +161,10 @@ export async function getCostAnalyticsRows( const cost = Number(row.costMicroUsd ?? 0) / 1_000_000; const provider = row.providerId ?? 'Unknown provider'; const model = row.modelId ?? 'Unknown model'; - const runEnvironmentId = - row.runPayload && - typeof row.runPayload === 'object' && - 'environmentId' in row.runPayload && - typeof row.runPayload.environmentId === 'string' - ? row.runPayload.environmentId - : null; const project = row.environmentName ?? - (runEnvironmentId - ? (environmentNameById.get(runEnvironmentId) ?? NO_PROJECT_LABEL) + (row.runEnvironmentId + ? (environmentNameById.get(row.runEnvironmentId) ?? NO_PROJECT_LABEL) : NO_PROJECT_LABEL); return { diff --git a/apps/web/src/lib/server/analytics/index.ts b/apps/web/src/lib/server/analytics/index.ts index 201e681ae..7778e384e 100644 --- a/apps/web/src/lib/server/analytics/index.ts +++ b/apps/web/src/lib/server/analytics/index.ts @@ -9,6 +9,7 @@ import { type AnalyticsGranularity, type AnalyticsMetric, type AnalyticsObject, + type AnalyticsOverviewResponse, type PullRequestAnalyticsOverviewResponse, type TimePeriodFilter, getDefaultAnalyticsMetric, @@ -130,6 +131,47 @@ export async function getAnalyticsChartData( ); } +export async function getAnalyticsOverview( + auth: UserAuthSuccess, + input: { + object: AnalyticsObject; + viewBy: AnalyticsDimension; + metric?: AnalyticsMetric; + filters?: AnalyticsFilters; + timePeriod?: TimePeriodFilter; + granularity?: AnalyticsGranularity; + }, + now: Date = new Date(), +): Promise { + const metric = resolveAnalyticsMetric(input.object, input.metric); + const rows = await getAnalyticsRows( + auth, + input.object, + input.timePeriod, + now, + metric, + ); + const filters = input.filters ?? {}; + const filteredRows = applyDimensionFilters(rows, filters); + const granularity = getResolvedGranularity( + input.timePeriod, + input.granularity, + ); + + return { + chart: buildChartData( + filteredRows, + input.object, + input.viewBy, + metric, + input.timePeriod, + granularity, + now, + ), + filterOptions: buildFilterOptions(rows, input.object, filters), + }; +} + export async function getPullRequestAnalyticsOverview( auth: UserAuthSuccess, input: { diff --git a/apps/web/src/trpc/commands/analytics/index.ts b/apps/web/src/trpc/commands/analytics/index.ts index 512e27d43..da8769918 100644 --- a/apps/web/src/trpc/commands/analytics/index.ts +++ b/apps/web/src/trpc/commands/analytics/index.ts @@ -2,4 +2,5 @@ export { getAnalyticsChartCommand } from './chart'; export { getAnalyticsFiltersCommand } from './filters'; export { getAnalyticsDetailsCommand } from './details'; export { exportAnalyticsCommand } from './export'; +export { getAnalyticsOverviewCommand } from './overview'; export { getPullRequestAnalyticsOverviewCommand } from './pull-request-overview'; diff --git a/apps/web/src/trpc/commands/analytics/overview.ts b/apps/web/src/trpc/commands/analytics/overview.ts new file mode 100644 index 000000000..297091b45 --- /dev/null +++ b/apps/web/src/trpc/commands/analytics/overview.ts @@ -0,0 +1,25 @@ +import type { UserAuthSuccess } from '@/types'; +import { getAnalyticsOverview } from '@/lib/server'; +import type { + AnalyticsDimension, + AnalyticsFilters, + AnalyticsGranularity, + AnalyticsMetric, + AnalyticsObject, + AnalyticsOverviewResponse, + TimePeriodFilter, +} from '@/types'; + +export async function getAnalyticsOverviewCommand( + auth: UserAuthSuccess, + input: { + object: AnalyticsObject; + viewBy: AnalyticsDimension; + metric?: AnalyticsMetric; + filters?: AnalyticsFilters; + timePeriod?: TimePeriodFilter; + granularity?: AnalyticsGranularity; + }, +): Promise { + return getAnalyticsOverview(auth, input); +} diff --git a/apps/web/src/trpc/routers/_app.ts b/apps/web/src/trpc/routers/_app.ts index 163cf250a..642842ff8 100644 --- a/apps/web/src/trpc/routers/_app.ts +++ b/apps/web/src/trpc/routers/_app.ts @@ -30,6 +30,7 @@ import { analyticsDetailsInputSchema, analyticsExportInputSchema, analyticsFilterOptionsInputSchema, + analyticsOverviewInputSchema, pullRequestAnalyticsOverviewInputSchema, filterSchema, saveAsanaConnectionSchema, @@ -323,6 +324,7 @@ import { getAnalyticsDetailsCommand, exportAnalyticsCommand, getAnalyticsFiltersCommand, + getAnalyticsOverviewCommand, getPullRequestAnalyticsOverviewCommand, } from '../commands/analytics'; import { @@ -549,6 +551,12 @@ const automationsRouter = createRouter({ export const appRouter = createRouter({ analytics: createRouter({ + overview: protectedProcedure + .input(analyticsOverviewInputSchema) + .query(({ ctx: { auth }, input }) => + getAnalyticsOverviewCommand(auth, input), + ), + pullRequestOverview: protectedProcedure .input(pullRequestAnalyticsOverviewInputSchema) .query(({ ctx: { auth }, input }) => diff --git a/apps/web/src/types/analytics.ts b/apps/web/src/types/analytics.ts index 48020edf2..80996d97c 100644 --- a/apps/web/src/types/analytics.ts +++ b/apps/web/src/types/analytics.ts @@ -55,6 +55,10 @@ export const analyticsChartInputSchema = z.object({ granularity: analyticsGranularitySchema.optional(), }); +export const analyticsOverviewInputSchema = analyticsChartInputSchema.extend( + {}, +); + export const analyticsFilterOptionsInputSchema = z.object({ object: analyticsObjectSchema, filters: analyticsFiltersSchema.optional(), @@ -143,6 +147,11 @@ export type AnalyticsFilterOptionsResponse = { availableViewBy: AnalyticsDimension[]; }; +export type AnalyticsOverviewResponse = { + chart: AnalyticsChartResponse; + filterOptions: AnalyticsFilterOptionsResponse; +}; + export type AnalyticsDetailsColumn = { key: string; label: string; From b2386af0e9e87d8c72dfc004d2adf6773ab0062b Mon Sep 17 00:00:00 2001 From: Matt Rubens <2600+mrubens@users.noreply.github.com> Date: Thu, 16 Jul 2026 12:45:37 -0400 Subject: [PATCH 2/2] avoid unbounded analytics task parameters --- .../lib/server/analytics/cost-rows.test.ts | 34 +++++++++------ .../web/src/lib/server/analytics/cost-rows.ts | 43 ++++++++++--------- 2 files changed, 45 insertions(+), 32 deletions(-) diff --git a/apps/web/src/lib/server/analytics/cost-rows.test.ts b/apps/web/src/lib/server/analytics/cost-rows.test.ts index 422e44b27..02c5210af 100644 --- a/apps/web/src/lib/server/analytics/cost-rows.test.ts +++ b/apps/web/src/lib/server/analytics/cost-rows.test.ts @@ -105,25 +105,35 @@ describe('getCostAnalyticsRows', () => { sourceControlProvider: 'github', host: 'github.com', }); - const [usageEvent] = await db + const usageEvents = await db .insert(llmUsageEvents) - .values({ - eventKey: `cost-analytics-task-${crypto.randomUUID()}`, - costSource: 'missing', - taskId: task.id, - runId: run.id, - costMicroUsd: 1_000_000, - messageCompletedAt: new Date('2026-07-15T12:00:00.000Z'), - }) + .values([ + { + eventKey: `cost-analytics-task-${crypto.randomUUID()}`, + costSource: 'missing', + taskId: task.id, + runId: run.id, + costMicroUsd: 1_000_000, + messageCompletedAt: new Date('2026-07-15T12:00:00.000Z'), + }, + { + eventKey: `cost-analytics-task-${crypto.randomUUID()}`, + costSource: 'missing', + taskId: task.id, + runId: run.id, + costMicroUsd: 500_000, + messageCompletedAt: new Date('2026-07-14T12:00:00.000Z'), + }, + ]) .returning({ id: llmUsageEvents.id }); - usageEventIds.push(usageEvent!.id); + usageEventIds.push(...usageEvents.map((event) => event.id)); const rows = await getCostAnalyticsRows( {} as UserAuthSuccess, - 7, + 'all', new Date('2026-07-16T16:00:00.000Z'), ); - const row = rows.find((candidate) => candidate.id === usageEvent!.id); + const row = rows.find((candidate) => candidate.id === usageEvents[0]!.id); expect(row?.dimensions.project?.label).toBe(environment.name); expect(row?.meta?.prKeys).toEqual(['github:github.com:roomote/test#42']); diff --git a/apps/web/src/lib/server/analytics/cost-rows.ts b/apps/web/src/lib/server/analytics/cost-rows.ts index 70feb11a0..b51b03c96 100644 --- a/apps/web/src/lib/server/analytics/cost-rows.ts +++ b/apps/web/src/lib/server/analytics/cost-rows.ts @@ -100,26 +100,29 @@ export async function getCostAnalyticsRows( const environmentNameById = new Map( environmentRows.map((environment) => [environment.id, environment.name]), ); - const taskIds = [ - ...new Set( - usageRows - .map((row) => row.taskId) - .filter((taskId): taskId is string => Boolean(taskId)), - ), - ]; - const pullRequestRows = - taskIds.length === 0 - ? [] - : await db - .select({ - taskId: taskPullRequests.taskId, - repository: taskPullRequests.repository, - prNumber: taskPullRequests.prNumber, - sourceControlProvider: taskPullRequests.sourceControlProvider, - host: taskPullRequests.host, - }) - .from(taskPullRequests) - .where(inArray(taskPullRequests.taskId, taskIds)); + const pullRequestRows = await db + .select({ + taskId: taskPullRequests.taskId, + repository: taskPullRequests.repository, + prNumber: taskPullRequests.prNumber, + sourceControlProvider: taskPullRequests.sourceControlProvider, + host: taskPullRequests.host, + }) + .from(taskPullRequests) + .innerJoin(tasks, eq(tasks.id, taskPullRequests.taskId)) + .where( + and( + isNull(tasks.deletedAt), + sql`exists ( + select 1 + from ${llmUsageEvents} + where ${and( + eq(llmUsageEvents.taskId, taskPullRequests.taskId), + usageCutoffCondition, + )} + )`, + ), + ); const prKeysByTaskId = new Map>(); for (const pullRequest of pullRequestRows) { if (pullRequest.prNumber === null || !pullRequest.repository) {