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..02c5210af 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,66 @@ 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 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'),
+ },
+ {
+ 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(...usageEvents.map((event) => event.id));
+
+ const rows = await getCostAnalyticsRows(
+ {} as UserAuthSuccess,
+ 'all',
+ new Date('2026-07-16T16:00:00.000Z'),
+ );
+ 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 f1e5cbf2c..b51b03c96 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,18 +77,31 @@ 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({
+ .select({
taskId: taskPullRequests.taskId,
repository: taskPullRequests.repository,
prNumber: taskPullRequests.prNumber,
@@ -93,12 +109,20 @@ export async function getCostAnalyticsRows(
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));
+ .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) {
@@ -140,17 +164,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;