fix: derive costs by-project from run usage instead of cost events
Joins heartbeat runs to issues via activity log to attribute costs to projects. Shows project names instead of raw IDs in the UI. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
9ec8c54f41
commit
b459668009
3 changed files with 50 additions and 23 deletions
|
|
@ -1,6 +1,6 @@
|
||||||
import { and, desc, eq, gte, isNotNull, lte, sql } from "drizzle-orm";
|
import { and, desc, eq, gte, isNotNull, lte, sql } from "drizzle-orm";
|
||||||
import type { Db } from "@paperclip/db";
|
import type { Db } from "@paperclip/db";
|
||||||
import { agents, companies, costEvents } from "@paperclip/db";
|
import { activityLog, agents, companies, costEvents, heartbeatRuns, issues, projects } from "@paperclip/db";
|
||||||
import { notFound, unprocessable } from "../errors.js";
|
import { notFound, unprocessable } from "../errors.js";
|
||||||
|
|
||||||
export interface CostDateRange {
|
export interface CostDateRange {
|
||||||
|
|
@ -122,24 +122,51 @@ export function costService(db: Db) {
|
||||||
},
|
},
|
||||||
|
|
||||||
byProject: async (companyId: string, range?: CostDateRange) => {
|
byProject: async (companyId: string, range?: CostDateRange) => {
|
||||||
const conditions: ReturnType<typeof eq>[] = [
|
const issueIdAsText = sql<string>`${issues.id}::text`;
|
||||||
eq(costEvents.companyId, companyId),
|
const runProjectLinks = db
|
||||||
isNotNull(costEvents.projectId),
|
.selectDistinctOn([activityLog.runId, issues.projectId], {
|
||||||
];
|
runId: sql<string>`${activityLog.runId}`,
|
||||||
if (range?.from) conditions.push(gte(costEvents.occurredAt, range.from));
|
projectId: sql<string>`${issues.projectId}`,
|
||||||
if (range?.to) conditions.push(lte(costEvents.occurredAt, range.to));
|
})
|
||||||
|
.from(activityLog)
|
||||||
|
.innerJoin(
|
||||||
|
issues,
|
||||||
|
and(
|
||||||
|
eq(activityLog.entityType, "issue"),
|
||||||
|
eq(activityLog.entityId, issueIdAsText),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(activityLog.companyId, companyId),
|
||||||
|
eq(issues.companyId, companyId),
|
||||||
|
isNotNull(activityLog.runId),
|
||||||
|
isNotNull(issues.projectId),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.orderBy(activityLog.runId, issues.projectId, desc(activityLog.createdAt))
|
||||||
|
.as("run_project_links");
|
||||||
|
|
||||||
|
const conditions: ReturnType<typeof eq>[] = [eq(heartbeatRuns.companyId, companyId)];
|
||||||
|
if (range?.from) conditions.push(gte(heartbeatRuns.finishedAt, range.from));
|
||||||
|
if (range?.to) conditions.push(lte(heartbeatRuns.finishedAt, range.to));
|
||||||
|
|
||||||
|
const costCentsExpr = sql<number>`coalesce(sum(round(coalesce((${heartbeatRuns.usageJson} ->> 'costUsd')::numeric, 0) * 100)), 0)::int`;
|
||||||
|
|
||||||
return db
|
return db
|
||||||
.select({
|
.select({
|
||||||
projectId: costEvents.projectId,
|
projectId: runProjectLinks.projectId,
|
||||||
costCents: sql<number>`coalesce(sum(${costEvents.costCents}), 0)::int`,
|
projectName: projects.name,
|
||||||
inputTokens: sql<number>`coalesce(sum(${costEvents.inputTokens}), 0)::int`,
|
costCents: costCentsExpr,
|
||||||
outputTokens: sql<number>`coalesce(sum(${costEvents.outputTokens}), 0)::int`,
|
inputTokens: sql<number>`coalesce(sum(coalesce((${heartbeatRuns.usageJson} ->> 'inputTokens')::int, 0)), 0)::int`,
|
||||||
|
outputTokens: sql<number>`coalesce(sum(coalesce((${heartbeatRuns.usageJson} ->> 'outputTokens')::int, 0)), 0)::int`,
|
||||||
})
|
})
|
||||||
.from(costEvents)
|
.from(runProjectLinks)
|
||||||
|
.innerJoin(heartbeatRuns, eq(runProjectLinks.runId, heartbeatRuns.id))
|
||||||
|
.innerJoin(projects, eq(runProjectLinks.projectId, projects.id))
|
||||||
.where(and(...conditions))
|
.where(and(...conditions))
|
||||||
.groupBy(costEvents.projectId)
|
.groupBy(runProjectLinks.projectId, projects.name)
|
||||||
.orderBy(desc(sql`coalesce(sum(${costEvents.costCents}), 0)::int`));
|
.orderBy(desc(costCentsExpr));
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,9 @@
|
||||||
import type { CostSummary, CostByAgent } from "@paperclip/shared";
|
import type { CostSummary, CostByAgent } from "@paperclip/shared";
|
||||||
import { api } from "./client";
|
import { api } from "./client";
|
||||||
|
|
||||||
export interface CostByEntity {
|
export interface CostByProject {
|
||||||
agentId?: string | null;
|
projectId: string | null;
|
||||||
projectId?: string | null;
|
projectName: string | null;
|
||||||
costCents: number;
|
costCents: number;
|
||||||
inputTokens: number;
|
inputTokens: number;
|
||||||
outputTokens: number;
|
outputTokens: number;
|
||||||
|
|
@ -23,5 +23,5 @@ export const costsApi = {
|
||||||
byAgent: (companyId: string, from?: string, to?: string) =>
|
byAgent: (companyId: string, from?: string, to?: string) =>
|
||||||
api.get<CostByAgent[]>(`/companies/${companyId}/costs/by-agent${dateParams(from, to)}`),
|
api.get<CostByAgent[]>(`/companies/${companyId}/costs/by-agent${dateParams(from, to)}`),
|
||||||
byProject: (companyId: string, from?: string, to?: string) =>
|
byProject: (companyId: string, from?: string, to?: string) =>
|
||||||
api.get<CostByEntity[]>(`/companies/${companyId}/costs/by-project${dateParams(from, to)}`),
|
api.get<CostByProject[]>(`/companies/${companyId}/costs/by-project${dateParams(from, to)}`),
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -202,16 +202,16 @@ export function Costs() {
|
||||||
<CardContent className="p-4">
|
<CardContent className="p-4">
|
||||||
<h3 className="text-sm font-semibold mb-3">By Project</h3>
|
<h3 className="text-sm font-semibold mb-3">By Project</h3>
|
||||||
{data.byProject.length === 0 ? (
|
{data.byProject.length === 0 ? (
|
||||||
<p className="text-sm text-muted-foreground">No project-attributed costs yet.</p>
|
<p className="text-sm text-muted-foreground">No project-attributed run costs yet.</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{data.byProject.map((row, idx) => (
|
{data.byProject.map((row) => (
|
||||||
<div
|
<div
|
||||||
key={`${row.projectId ?? "na"}-${idx}`}
|
key={row.projectId ?? "na"}
|
||||||
className="flex items-center justify-between text-sm"
|
className="flex items-center justify-between text-sm"
|
||||||
>
|
>
|
||||||
<span className="font-mono text-xs truncate">
|
<span className="truncate">
|
||||||
{row.projectId ?? "Unattributed"}
|
{row.projectName ?? row.projectId ?? "Unattributed"}
|
||||||
</span>
|
</span>
|
||||||
<span className="font-medium">{formatCents(row.costCents)}</span>
|
<span className="font-medium">{formatCents(row.costCents)}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue