feat(billing): add usage cost aggregation report
This commit is contained in:
@@ -0,0 +1,119 @@
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
import { requirePermission } from "@/lib/admin/auth";
|
||||
import { queryAdminRows } from "@/lib/admin/database";
|
||||
import { adminErrorResponse } from "@/lib/admin/http";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
const FEATURE_KEYS = [
|
||||
"chat.standard",
|
||||
"chat.premium",
|
||||
"rectification",
|
||||
"report.full",
|
||||
"report.export",
|
||||
"profile.extra",
|
||||
] as const;
|
||||
|
||||
type FeatureKey = (typeof FEATURE_KEYS)[number];
|
||||
type AggregateRow = {
|
||||
feature_key: FeatureKey;
|
||||
runs: string;
|
||||
avg_cost_microusd: string | null;
|
||||
p50_cost_microusd: string | null;
|
||||
p95_cost_microusd: string | null;
|
||||
max_cost_microusd: string | null;
|
||||
avg_input_tokens: string | null;
|
||||
p50_input_tokens: string | null;
|
||||
p95_input_tokens: string | null;
|
||||
max_input_tokens: string | null;
|
||||
avg_output_tokens: string | null;
|
||||
p50_output_tokens: string | null;
|
||||
p95_output_tokens: string | null;
|
||||
max_output_tokens: string | null;
|
||||
avg_duration_ms: string | null;
|
||||
p50_duration_ms: string | null;
|
||||
p95_duration_ms: string | null;
|
||||
max_duration_ms: string | null;
|
||||
};
|
||||
|
||||
type Metric = {
|
||||
runs: number;
|
||||
avg: number | null;
|
||||
p50: number | null;
|
||||
p95: number | null;
|
||||
max: number | null;
|
||||
};
|
||||
|
||||
function numberOrNull(value: string | null): number | null {
|
||||
return value === null ? null : Number(value);
|
||||
}
|
||||
|
||||
function metric(
|
||||
row: AggregateRow,
|
||||
avg: keyof AggregateRow,
|
||||
p50: keyof AggregateRow,
|
||||
p95: keyof AggregateRow,
|
||||
max: keyof AggregateRow,
|
||||
): Metric {
|
||||
return {
|
||||
runs: Number(row.runs),
|
||||
avg: numberOrNull(row[avg] as string | null),
|
||||
p50: numberOrNull(row[p50] as string | null),
|
||||
p95: numberOrNull(row[p95] as string | null),
|
||||
max: numberOrNull(row[max] as string | null),
|
||||
};
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
await requirePermission("billing.orders.read");
|
||||
const rows = await queryAdminRows<AggregateRow>(`
|
||||
with feature_keys(feature_key) as (
|
||||
select unnest($1::text[])
|
||||
), recent as (
|
||||
select feature_key, cost_microusd, input_tokens, output_tokens, duration_ms
|
||||
from public.usage_ledger
|
||||
where created_at >= now() - interval '30 days'
|
||||
)
|
||||
select f.feature_key,
|
||||
count(r.feature_key)::text as runs,
|
||||
avg(r.cost_microusd)::text as avg_cost_microusd,
|
||||
percentile_cont(0.50) within group(order by r.cost_microusd)::text as p50_cost_microusd,
|
||||
percentile_cont(0.95) within group(order by r.cost_microusd)::text as p95_cost_microusd,
|
||||
max(r.cost_microusd)::text as max_cost_microusd,
|
||||
avg(r.input_tokens)::text as avg_input_tokens,
|
||||
percentile_cont(0.50) within group(order by r.input_tokens)::text as p50_input_tokens,
|
||||
percentile_cont(0.95) within group(order by r.input_tokens)::text as p95_input_tokens,
|
||||
max(r.input_tokens)::text as max_input_tokens,
|
||||
avg(r.output_tokens)::text as avg_output_tokens,
|
||||
percentile_cont(0.50) within group(order by r.output_tokens)::text as p50_output_tokens,
|
||||
percentile_cont(0.95) within group(order by r.output_tokens)::text as p95_output_tokens,
|
||||
max(r.output_tokens)::text as max_output_tokens,
|
||||
avg(r.duration_ms)::text as avg_duration_ms,
|
||||
percentile_cont(0.50) within group(order by r.duration_ms)::text as p50_duration_ms,
|
||||
percentile_cont(0.95) within group(order by r.duration_ms)::text as p95_duration_ms,
|
||||
max(r.duration_ms)::text as max_duration_ms
|
||||
from feature_keys f
|
||||
left join recent r on r.feature_key=f.feature_key
|
||||
group by f.feature_key
|
||||
order by f.feature_key
|
||||
`, [FEATURE_KEYS]);
|
||||
|
||||
return NextResponse.json({
|
||||
window: { days: 30, since: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString() },
|
||||
data: rows.map((row) => ({
|
||||
featureKey: row.feature_key,
|
||||
hasData: Number(row.runs) > 0,
|
||||
metrics: {
|
||||
costMicrousd: metric(row, "avg_cost_microusd", "p50_cost_microusd", "p95_cost_microusd", "max_cost_microusd"),
|
||||
inputTokens: metric(row, "avg_input_tokens", "p50_input_tokens", "p95_input_tokens", "max_input_tokens"),
|
||||
outputTokens: metric(row, "avg_output_tokens", "p50_output_tokens", "p95_output_tokens", "max_output_tokens"),
|
||||
durationMs: metric(row, "avg_duration_ms", "p50_duration_ms", "p95_duration_ms", "max_duration_ms"),
|
||||
},
|
||||
})),
|
||||
});
|
||||
} catch (error) {
|
||||
return adminErrorResponse(error);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user