tokentrace 0.17.0 → 0.18.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +87 -0
- package/TOKENTRACE_AGENT.md +29 -0
- package/app/page.tsx +17 -1
- package/app/query/page.tsx +292 -0
- package/components/charts/trend-chart.tsx +34 -2
- package/components/charts/trend-section.tsx +14 -2
- package/components/overview/anomalies-panel.tsx +116 -0
- package/components/repair/repair-items-table.tsx +52 -1
- package/components/sidebar.tsx +2 -0
- package/dist/runtime/agent.mjs +209 -3
- package/dist/runtime/anomalies.mjs +1064 -0
- package/dist/runtime/db-migrate.mjs +16 -0
- package/dist/runtime/db-seed.mjs +56 -12
- package/dist/runtime/digest.mjs +16 -0
- package/dist/runtime/doctor.mjs +16 -0
- package/dist/runtime/evidence.mjs +16 -0
- package/dist/runtime/insights.mjs +16 -0
- package/dist/runtime/mcp.mjs +173 -0
- package/dist/runtime/pricing-refresh.mjs +201 -15
- package/dist/runtime/query.mjs +1115 -0
- package/dist/runtime/repair.mjs +1117 -378
- package/dist/runtime/report.mjs +16 -0
- package/dist/runtime/reset.mjs +56 -12
- package/dist/runtime/review.mjs +16 -0
- package/dist/runtime/scan.mjs +201 -15
- package/dist/runtime/status.mjs +16 -0
- package/llms.txt +3 -0
- package/package.json +1 -1
- package/scripts/anomalies.ts +95 -0
- package/scripts/build-cli-runtime.mjs +2 -0
- package/scripts/query.ts +58 -0
- package/scripts/repair.ts +141 -0
- package/server.json +2 -2
- package/src/cli/commands.js +20 -0
- package/src/db/migrate-core.ts +16 -0
- package/src/lib/agent-discovery.ts +48 -0
- package/src/lib/anomalies-cli.ts +73 -0
- package/src/lib/anomaly-detection.ts +168 -0
- package/src/lib/cost-recalculation.ts +66 -14
- package/src/lib/mcp/tools.ts +78 -0
- package/src/lib/mcp-server.ts +102 -0
- package/src/lib/model-aliases/backfill.ts +136 -0
- package/src/lib/model-aliases/store.ts +156 -0
- package/src/lib/scheduled-scan.ts +18 -1
- package/src/lib/structured-query-cli.ts +176 -0
- package/src/lib/structured-query.ts +278 -0
- package/src/lib/unknown-cost-repair/auto-classify-cli.ts +238 -0
- package/src/lib/unknown-cost-repair/auto-classify.ts +175 -0
- package/src/lib/unknown-cost-repair/types.ts +2 -0
- package/src/lib/unknown-cost-repair/workbench.ts +21 -3
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,93 @@ All notable changes to TokenTrace are documented here.
|
|
|
4
4
|
|
|
5
5
|
## Unreleased
|
|
6
6
|
|
|
7
|
+
## [0.18.1] - 2026-05-28
|
|
8
|
+
|
|
9
|
+
### Fixed
|
|
10
|
+
|
|
11
|
+
- **Scheduled scans now coalesce concurrent runs.** `runDueScheduledScan`
|
|
12
|
+
fires on every overview page load; two overlapping loads (a second tab, a
|
|
13
|
+
quick refresh, a prefetch) previously both passed the due-check and both
|
|
14
|
+
called `runScan`, producing duplicate scan runs. A module-scoped in-flight
|
|
15
|
+
promise guard now coalesces concurrent callers into a single scan within
|
|
16
|
+
the server process.
|
|
17
|
+
- **`tokentrace repair auto-classify --apply` is now atomic.** The alias
|
|
18
|
+
writes and cost backfills for a batch run inside a single SQLite
|
|
19
|
+
transaction, and `backfillAlias` wraps its per-row updates in a transaction
|
|
20
|
+
too, so a failure partway can no longer leave the local database
|
|
21
|
+
half-applied with inaccurate reported counts.
|
|
22
|
+
- **`tokentrace query` surfaces range errors cleanly.** Range validation
|
|
23
|
+
(e.g. `--from` after `--to`, or a preset combined with explicit dates) now
|
|
24
|
+
prints the validation message plus usage and exits non-zero, instead of
|
|
25
|
+
letting a raw stack trace escape.
|
|
26
|
+
- **Anomaly z-scores survive JSON.** The flat-window (`mad === 0`) severe
|
|
27
|
+
branch reported `Infinity`, which `JSON.stringify` turned into `null` and
|
|
28
|
+
silently dropped the signal. It now reports a finite sentinel z-score.
|
|
29
|
+
- **Stricter query CLI parsing.** Empty `--flag=` values are rejected with a
|
|
30
|
+
clear "Missing value" message rather than flowing through as empty strings.
|
|
31
|
+
|
|
32
|
+
### Internal
|
|
33
|
+
|
|
34
|
+
- **Test suite no longer flakes under parallel load.** CLI/MCP integration
|
|
35
|
+
tests spawn `bin/tokentrace.js`, which itself spawns `db-migrate` and
|
|
36
|
+
`db-seed`, fanning out to ~3x the core count in node processes. The Vitest
|
|
37
|
+
fork pool is now capped at roughly half the available cores with one
|
|
38
|
+
generous global timeout, removing the scattered per-test timeout overrides.
|
|
39
|
+
- Removed the dead `tailwind.config.ts` left over from the Tailwind 4
|
|
40
|
+
(CSS-first) migration.
|
|
41
|
+
|
|
42
|
+
## [0.18.0] - 2026-05-28
|
|
43
|
+
|
|
44
|
+
### Local intelligence
|
|
45
|
+
|
|
46
|
+
- **Anomaly detection (`tokentrace anomalies`).** A new modified-z-score (MAD)
|
|
47
|
+
detector scores the daily token and cost trend against a trailing window
|
|
48
|
+
(default 14 days) and surfaces deviations as `notable`, `high`, or `severe`.
|
|
49
|
+
Pure-stats; spends zero AI tokens. New CLI flags: `--window=N` (3..60),
|
|
50
|
+
`--metric=tokens|cost|all`, `--json`.
|
|
51
|
+
- **Structured query (`tokentrace query`).** A new deterministic
|
|
52
|
+
parameterized aggregation that lets agents ask precise local questions
|
|
53
|
+
without TokenTrace performing any NL parsing. Group by
|
|
54
|
+
`model|project|tool|session|day`, aggregate
|
|
55
|
+
`cost|totalTokens|interactions`, with optional preset/from/to range,
|
|
56
|
+
exact-match `model|project|tool` filters, and `--top` clamped to 200.
|
|
57
|
+
Routes through `prepareCached` for the warm-statement path.
|
|
58
|
+
- **Auto-classifier (`tokentrace repair auto-classify`).** Each unknown-cost
|
|
59
|
+
workbench group now carries a deterministic `classification` field
|
|
60
|
+
produced by three rules in confidence order: `exact-model` (0.95),
|
|
61
|
+
`family-fragment` (0.70, normalized via `modelNameCandidates`), and
|
|
62
|
+
`parser-source` (0.45, same source file as priced examples). The CLI
|
|
63
|
+
supports `--apply --min-confidence=N` (floor 0.85) which writes each
|
|
64
|
+
qualifying exact-model or family-fragment suggestion to a new
|
|
65
|
+
`model_aliases` table and backfills cost for the matching unknown-cost
|
|
66
|
+
interactions; `--dry-run` previews without writing. parser-source
|
|
67
|
+
matches are skipped from `--apply` because they have no
|
|
68
|
+
(provider, observed-model) pair to persist.
|
|
69
|
+
- **Persistent model aliases.** A new `model_aliases` table maps
|
|
70
|
+
`(provider_id, observed_model)` to a priced model. The cost
|
|
71
|
+
recalculation pass now LEFT JOINs the alias table so aliased costs
|
|
72
|
+
survive every re-seed, with metadata noting the rule and confidence.
|
|
73
|
+
- **Interactive Query page (`/query`).** A new dashboard route exposes the
|
|
74
|
+
structured-query surface with a server-rendered form (group-by, metric,
|
|
75
|
+
range preset / from / to, model/project/tool filters, top N). Results
|
|
76
|
+
render as a deterministic table. Same SQL path as the CLI and MCP tool.
|
|
77
|
+
- **Anomaly drill-down + chart markers.** Each anomaly date in the
|
|
78
|
+
overview Anomalies panel is now a link to `/?range=custom&from=DATE&to=DATE`
|
|
79
|
+
so clicking filters the entire overview to that day. The Trend chart
|
|
80
|
+
also renders colored Recharts `ReferenceDot` markers on flagged days
|
|
81
|
+
(red severe, amber high, gray notable) when the bucket is daily.
|
|
82
|
+
- **"Auto-classify" column in Repair Items.** The repair workbench table
|
|
83
|
+
now shows the suggested priced model, classification rule, and
|
|
84
|
+
confidence percentage alongside the existing legacy suggestion.
|
|
85
|
+
- **New MCP tools.** `get_anomalies`, `query_usage`, and
|
|
86
|
+
`get_classifications` are wired into `src/lib/mcp/tools.ts` with full
|
|
87
|
+
input schemas and `src/lib/mcp-server.ts` handlers that translate
|
|
88
|
+
structured arguments into CLI flags. Each handler is read-only and
|
|
89
|
+
spends zero AI tokens.
|
|
90
|
+
- **Agent discovery catalog.** `tokentrace agent --json` now exposes
|
|
91
|
+
`anomalies`, `query`, and `auto-classify` commands so MCP-capable
|
|
92
|
+
agents can discover the new local-intelligence surface.
|
|
93
|
+
|
|
7
94
|
## [0.17.0] - 2026-05-23
|
|
8
95
|
|
|
9
96
|
### Performance
|
package/TOKENTRACE_AGENT.md
CHANGED
|
@@ -92,6 +92,35 @@ curl http://127.0.0.1:3030/api/roadmap
|
|
|
92
92
|
tokentrace evidence --json
|
|
93
93
|
```
|
|
94
94
|
|
|
95
|
+
## Local Intelligence (zero AI tokens)
|
|
96
|
+
|
|
97
|
+
TokenTrace ships deterministic, local-only analytical surfaces that spend
|
|
98
|
+
no AI tokens. The agent on the calling side handles any natural-language
|
|
99
|
+
parsing; TokenTrace executes pure SQL and statistics.
|
|
100
|
+
|
|
101
|
+
Spot unusual local usage days via a modified-z-score (MAD) detector:
|
|
102
|
+
|
|
103
|
+
```bash
|
|
104
|
+
tokentrace anomalies --json [--window=N] [--metric=tokens|cost|all]
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
Run a structured aggregation by model, project, tool, session, or day:
|
|
108
|
+
|
|
109
|
+
```bash
|
|
110
|
+
tokentrace query --group-by model --metric cost --range 7d --json
|
|
111
|
+
tokentrace query --group-by day --metric totalTokens --from 2026-05-01 --to 2026-05-15 --json
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
Get deterministic classification suggestions for the unknown-cost queue:
|
|
115
|
+
|
|
116
|
+
```bash
|
|
117
|
+
tokentrace repair auto-classify --json [--min-confidence=N]
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
The matching MCP tools are `get_anomalies`, `query_usage`, and
|
|
121
|
+
`get_classifications`. All three are read-only and require no
|
|
122
|
+
`confirmLocalScan` acknowledgement.
|
|
123
|
+
|
|
95
124
|
## Guardrails
|
|
96
125
|
|
|
97
126
|
- Do not run `tokentrace reset` unless the human explicitly asks to clear imported local data.
|
package/app/page.tsx
CHANGED
|
@@ -2,6 +2,9 @@ import { Suspense } from "react";
|
|
|
2
2
|
import Link from "next/link";
|
|
3
3
|
import { ArrowRight } from "lucide-react";
|
|
4
4
|
import { TrendSection } from "@/components/charts/trend-section-lazy";
|
|
5
|
+
import type { TrendAnomalyMarker } from "@/components/charts/trend-chart";
|
|
6
|
+
import { OverviewAnomaliesPanel } from "@/components/overview/anomalies-panel";
|
|
7
|
+
import { detectAnomalies } from "@/src/lib/anomaly-detection";
|
|
5
8
|
import { OverviewCurrentMixPanel } from "@/components/overview/current-mix-panel";
|
|
6
9
|
import { DataConfidenceStrip } from "@/components/overview/data-confidence-strip";
|
|
7
10
|
import { FirstRunPanel } from "@/components/overview/first-run-panel";
|
|
@@ -41,6 +44,13 @@ async function OverviewPrimarySection({ range }: { range: ResolvedDateRange }) {
|
|
|
41
44
|
rangeLinkParams
|
|
42
45
|
} = await getOverviewPrimaryData(range);
|
|
43
46
|
const repairFocusHref = mergeHrefParams("/repair", rangeLinkParams);
|
|
47
|
+
const anomalyReport = detectAnomalies(data.trends);
|
|
48
|
+
const tokenMarkers: TrendAnomalyMarker[] = anomalyReport.anomalies
|
|
49
|
+
.filter((entry) => entry.metric === "tokens")
|
|
50
|
+
.map((entry) => ({ date: entry.date, value: entry.value, severity: entry.severity }));
|
|
51
|
+
const costMarkers: TrendAnomalyMarker[] = anomalyReport.anomalies
|
|
52
|
+
.filter((entry) => entry.metric === "cost")
|
|
53
|
+
.map((entry) => ({ date: entry.date, value: entry.value, severity: entry.severity }));
|
|
44
54
|
|
|
45
55
|
return (
|
|
46
56
|
<div className="space-y-8">
|
|
@@ -63,7 +73,13 @@ async function OverviewPrimarySection({ range }: { range: ResolvedDateRange }) {
|
|
|
63
73
|
/>
|
|
64
74
|
</div>
|
|
65
75
|
<OverviewTrustFooter health={trust.health} pricedModelCount={trust.pricedModelCount} />
|
|
66
|
-
<TrendSection
|
|
76
|
+
<TrendSection
|
|
77
|
+
data={data.trends}
|
|
78
|
+
defaultWindow={trendDefaultWindow}
|
|
79
|
+
tokenMarkers={tokenMarkers}
|
|
80
|
+
costMarkers={costMarkers}
|
|
81
|
+
/>
|
|
82
|
+
<OverviewAnomaliesPanel trends={data.trends} />
|
|
67
83
|
<UsageGuardrailsPanel progress={data.usageGuardrails} />
|
|
68
84
|
<OverviewRecommendationsCard recommendations={data.recommendations} />
|
|
69
85
|
<OverviewCurrentMixPanel
|
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
import { PageHeader } from "@/components/ui/typography";
|
|
2
|
+
import { Badge } from "@/components/ui/badge";
|
|
3
|
+
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
|
4
|
+
import {
|
|
5
|
+
Table,
|
|
6
|
+
TableBody,
|
|
7
|
+
TableCell,
|
|
8
|
+
TableHead,
|
|
9
|
+
TableHeader,
|
|
10
|
+
TableRow
|
|
11
|
+
} from "@/components/ui/table";
|
|
12
|
+
import {
|
|
13
|
+
runStructuredQuery,
|
|
14
|
+
type StructuredQueryArgs,
|
|
15
|
+
type StructuredQueryGroupBy,
|
|
16
|
+
type StructuredQueryMetric,
|
|
17
|
+
type StructuredQueryRangePreset,
|
|
18
|
+
type StructuredQuerySort
|
|
19
|
+
} from "@/src/lib/structured-query";
|
|
20
|
+
import { formatTokens } from "@/src/lib/format";
|
|
21
|
+
|
|
22
|
+
export const dynamic = "force-dynamic";
|
|
23
|
+
|
|
24
|
+
type QueryPageProps = {
|
|
25
|
+
searchParams?: Promise<Record<string, string | string[] | undefined>>;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
function readString(value: string | string[] | undefined): string | undefined {
|
|
29
|
+
if (Array.isArray(value)) return value[0];
|
|
30
|
+
return typeof value === "string" && value.trim() ? value : undefined;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const GROUP_BY: StructuredQueryGroupBy[] = ["model", "project", "tool", "session", "day"];
|
|
34
|
+
const METRIC: StructuredQueryMetric[] = ["cost", "totalTokens", "interactions"];
|
|
35
|
+
const PRESETS: StructuredQueryRangePreset[] = ["today", "7d", "30d", "60d", "90d", "all"];
|
|
36
|
+
const SORTS: StructuredQuerySort[] = ["desc", "asc"];
|
|
37
|
+
|
|
38
|
+
function formatValue(value: number, metric: StructuredQueryMetric) {
|
|
39
|
+
if (metric === "cost") return `$${value.toFixed(2)}`;
|
|
40
|
+
if (metric === "totalTokens") return formatTokens(value);
|
|
41
|
+
return value.toLocaleString();
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function selectClass() {
|
|
45
|
+
return "w-full rounded-md border bg-background px-3 py-2 text-sm shadow-sm focus:outline-none focus:ring-2 focus:ring-ring";
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function inputClass() {
|
|
49
|
+
return "w-full rounded-md border bg-background px-3 py-2 text-sm shadow-sm focus:outline-none focus:ring-2 focus:ring-ring";
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function buttonClass() {
|
|
53
|
+
return "inline-flex items-center justify-center rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground shadow-sm hover:bg-primary/90";
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export default async function QueryPage({ searchParams }: QueryPageProps) {
|
|
57
|
+
const params = (await searchParams) ?? {};
|
|
58
|
+
const groupBy = (readString(params.groupBy) as StructuredQueryGroupBy | undefined) ?? "model";
|
|
59
|
+
const metric = (readString(params.metric) as StructuredQueryMetric | undefined) ?? "cost";
|
|
60
|
+
const preset = readString(params.range) as StructuredQueryRangePreset | undefined;
|
|
61
|
+
const from = readString(params.from);
|
|
62
|
+
const to = readString(params.to);
|
|
63
|
+
const model = readString(params.model);
|
|
64
|
+
const project = readString(params.project);
|
|
65
|
+
const tool = readString(params.tool);
|
|
66
|
+
const topRaw = readString(params.topN);
|
|
67
|
+
const sort = (readString(params.sort) as StructuredQuerySort | undefined) ?? "desc";
|
|
68
|
+
|
|
69
|
+
const submitted = Boolean(params.submit);
|
|
70
|
+
|
|
71
|
+
let error: string | null = null;
|
|
72
|
+
let result: ReturnType<typeof runStructuredQuery> | null = null;
|
|
73
|
+
|
|
74
|
+
if (submitted) {
|
|
75
|
+
const args: StructuredQueryArgs = {
|
|
76
|
+
groupBy: GROUP_BY.includes(groupBy) ? groupBy : "model",
|
|
77
|
+
metric: METRIC.includes(metric) ? metric : "cost",
|
|
78
|
+
sort,
|
|
79
|
+
filters: { model, project, tool },
|
|
80
|
+
range: preset || from || to ? { preset, from, to } : undefined,
|
|
81
|
+
topN: topRaw ? Number(topRaw) : undefined
|
|
82
|
+
};
|
|
83
|
+
try {
|
|
84
|
+
result = runStructuredQuery(args);
|
|
85
|
+
} catch (caught) {
|
|
86
|
+
error = caught instanceof Error ? caught.message : "Query failed.";
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return (
|
|
91
|
+
<div className="space-y-6">
|
|
92
|
+
<PageHeader
|
|
93
|
+
title="Query"
|
|
94
|
+
description="Deterministic local SQL aggregation. The form below runs entirely on your machine — no AI tokens are spent."
|
|
95
|
+
/>
|
|
96
|
+
|
|
97
|
+
<Card>
|
|
98
|
+
<CardHeader>
|
|
99
|
+
<CardTitle>Run a structured query</CardTitle>
|
|
100
|
+
<CardDescription>
|
|
101
|
+
Group local interactions by model, project, tool, session, or day and aggregate cost,
|
|
102
|
+
total tokens, or interaction count.
|
|
103
|
+
</CardDescription>
|
|
104
|
+
</CardHeader>
|
|
105
|
+
<CardContent>
|
|
106
|
+
<form method="get" className="grid gap-4 md:grid-cols-3">
|
|
107
|
+
<label className="space-y-1 text-sm">
|
|
108
|
+
<span className="font-medium">Group by</span>
|
|
109
|
+
<select name="groupBy" defaultValue={groupBy} className={selectClass()}>
|
|
110
|
+
{GROUP_BY.map((value) => (
|
|
111
|
+
<option key={value} value={value}>
|
|
112
|
+
{value}
|
|
113
|
+
</option>
|
|
114
|
+
))}
|
|
115
|
+
</select>
|
|
116
|
+
</label>
|
|
117
|
+
<label className="space-y-1 text-sm">
|
|
118
|
+
<span className="font-medium">Metric</span>
|
|
119
|
+
<select name="metric" defaultValue={metric} className={selectClass()}>
|
|
120
|
+
{METRIC.map((value) => (
|
|
121
|
+
<option key={value} value={value}>
|
|
122
|
+
{value}
|
|
123
|
+
</option>
|
|
124
|
+
))}
|
|
125
|
+
</select>
|
|
126
|
+
</label>
|
|
127
|
+
<label className="space-y-1 text-sm">
|
|
128
|
+
<span className="font-medium">Sort</span>
|
|
129
|
+
<select name="sort" defaultValue={sort} className={selectClass()}>
|
|
130
|
+
{SORTS.map((value) => (
|
|
131
|
+
<option key={value} value={value}>
|
|
132
|
+
{value}
|
|
133
|
+
</option>
|
|
134
|
+
))}
|
|
135
|
+
</select>
|
|
136
|
+
</label>
|
|
137
|
+
<label className="space-y-1 text-sm">
|
|
138
|
+
<span className="font-medium">Preset range</span>
|
|
139
|
+
<select name="range" defaultValue={preset ?? ""} className={selectClass()}>
|
|
140
|
+
<option value="">(none)</option>
|
|
141
|
+
{PRESETS.map((value) => (
|
|
142
|
+
<option key={value} value={value}>
|
|
143
|
+
{value}
|
|
144
|
+
</option>
|
|
145
|
+
))}
|
|
146
|
+
</select>
|
|
147
|
+
</label>
|
|
148
|
+
<label className="space-y-1 text-sm">
|
|
149
|
+
<span className="font-medium">From (YYYY-MM-DD)</span>
|
|
150
|
+
<input
|
|
151
|
+
type="text"
|
|
152
|
+
name="from"
|
|
153
|
+
defaultValue={from ?? ""}
|
|
154
|
+
placeholder="2026-05-01"
|
|
155
|
+
className={inputClass()}
|
|
156
|
+
/>
|
|
157
|
+
</label>
|
|
158
|
+
<label className="space-y-1 text-sm">
|
|
159
|
+
<span className="font-medium">To (YYYY-MM-DD, exclusive)</span>
|
|
160
|
+
<input
|
|
161
|
+
type="text"
|
|
162
|
+
name="to"
|
|
163
|
+
defaultValue={to ?? ""}
|
|
164
|
+
placeholder="2026-05-15"
|
|
165
|
+
className={inputClass()}
|
|
166
|
+
/>
|
|
167
|
+
</label>
|
|
168
|
+
<label className="space-y-1 text-sm">
|
|
169
|
+
<span className="font-medium">Model filter</span>
|
|
170
|
+
<input type="text" name="model" defaultValue={model ?? ""} className={inputClass()} />
|
|
171
|
+
</label>
|
|
172
|
+
<label className="space-y-1 text-sm">
|
|
173
|
+
<span className="font-medium">Project filter</span>
|
|
174
|
+
<input
|
|
175
|
+
type="text"
|
|
176
|
+
name="project"
|
|
177
|
+
defaultValue={project ?? ""}
|
|
178
|
+
className={inputClass()}
|
|
179
|
+
/>
|
|
180
|
+
</label>
|
|
181
|
+
<label className="space-y-1 text-sm">
|
|
182
|
+
<span className="font-medium">Tool filter</span>
|
|
183
|
+
<input type="text" name="tool" defaultValue={tool ?? ""} className={inputClass()} />
|
|
184
|
+
</label>
|
|
185
|
+
<label className="space-y-1 text-sm">
|
|
186
|
+
<span className="font-medium">Top N (1..200)</span>
|
|
187
|
+
<input
|
|
188
|
+
type="number"
|
|
189
|
+
name="topN"
|
|
190
|
+
min={1}
|
|
191
|
+
max={200}
|
|
192
|
+
defaultValue={topRaw ?? "20"}
|
|
193
|
+
className={inputClass()}
|
|
194
|
+
/>
|
|
195
|
+
</label>
|
|
196
|
+
<div className="md:col-span-3">
|
|
197
|
+
<button type="submit" name="submit" value="1" className={buttonClass()}>
|
|
198
|
+
Run query
|
|
199
|
+
</button>
|
|
200
|
+
</div>
|
|
201
|
+
</form>
|
|
202
|
+
</CardContent>
|
|
203
|
+
</Card>
|
|
204
|
+
|
|
205
|
+
{error ? (
|
|
206
|
+
<Card>
|
|
207
|
+
<CardHeader>
|
|
208
|
+
<CardTitle>Query error</CardTitle>
|
|
209
|
+
<CardDescription>The structured-query validator rejected the arguments.</CardDescription>
|
|
210
|
+
</CardHeader>
|
|
211
|
+
<CardContent>
|
|
212
|
+
<p className="text-sm text-destructive">{error}</p>
|
|
213
|
+
</CardContent>
|
|
214
|
+
</Card>
|
|
215
|
+
) : null}
|
|
216
|
+
|
|
217
|
+
{result ? (
|
|
218
|
+
<Card>
|
|
219
|
+
<CardHeader className="flex flex-col gap-3 pb-3 lg:flex-row lg:items-start lg:justify-between">
|
|
220
|
+
<div>
|
|
221
|
+
<CardTitle>
|
|
222
|
+
{result.rows.length === 0 ? "No rows" : `${result.rows.length} rows`}
|
|
223
|
+
</CardTitle>
|
|
224
|
+
<CardDescription>
|
|
225
|
+
Group by <code>{result.groupBy}</code>, metric <code>{result.metric}</code>, sort{" "}
|
|
226
|
+
<code>{result.sort}</code>. {result.range.preset ? `Range: ${result.range.preset}. ` : ""}
|
|
227
|
+
{result.range.from || result.range.to
|
|
228
|
+
? `Window: ${result.range.from ?? "—"} → ${result.range.to ?? "—"}. `
|
|
229
|
+
: ""}
|
|
230
|
+
Showing {result.rows.length} of {result.totalGroups} groups
|
|
231
|
+
{result.truncated ? " (truncated by topN)" : ""}.
|
|
232
|
+
</CardDescription>
|
|
233
|
+
</div>
|
|
234
|
+
<div className="flex gap-2">
|
|
235
|
+
{result.filters.model ? <Badge variant="secondary">model={result.filters.model}</Badge> : null}
|
|
236
|
+
{result.filters.project ? <Badge variant="secondary">project={result.filters.project}</Badge> : null}
|
|
237
|
+
{result.filters.tool ? <Badge variant="secondary">tool={result.filters.tool}</Badge> : null}
|
|
238
|
+
</div>
|
|
239
|
+
</CardHeader>
|
|
240
|
+
<CardContent>
|
|
241
|
+
{result.rows.length === 0 ? (
|
|
242
|
+
<p className="text-sm text-muted-foreground">
|
|
243
|
+
No groups matched. Try a different range or remove filters.
|
|
244
|
+
</p>
|
|
245
|
+
) : (
|
|
246
|
+
<div className="overflow-x-auto">
|
|
247
|
+
<Table>
|
|
248
|
+
<TableHeader>
|
|
249
|
+
<TableRow>
|
|
250
|
+
<TableHead>{result.groupBy}</TableHead>
|
|
251
|
+
<TableHead className="text-right">{result.metric}</TableHead>
|
|
252
|
+
<TableHead className="text-right">interactions</TableHead>
|
|
253
|
+
<TableHead className="text-right">totalTokens</TableHead>
|
|
254
|
+
<TableHead className="text-right">cost</TableHead>
|
|
255
|
+
</TableRow>
|
|
256
|
+
</TableHeader>
|
|
257
|
+
<TableBody>
|
|
258
|
+
{result.rows.map((row) => (
|
|
259
|
+
<TableRow key={row.group}>
|
|
260
|
+
<TableCell className="font-medium">{row.group}</TableCell>
|
|
261
|
+
<TableCell className="text-right">
|
|
262
|
+
{formatValue(row.value, result.metric)}
|
|
263
|
+
</TableCell>
|
|
264
|
+
<TableCell className="text-right">{row.interactions.toLocaleString()}</TableCell>
|
|
265
|
+
<TableCell className="text-right">{formatTokens(row.totalTokens)}</TableCell>
|
|
266
|
+
<TableCell className="text-right">${row.cost.toFixed(2)}</TableCell>
|
|
267
|
+
</TableRow>
|
|
268
|
+
))}
|
|
269
|
+
</TableBody>
|
|
270
|
+
</Table>
|
|
271
|
+
</div>
|
|
272
|
+
)}
|
|
273
|
+
</CardContent>
|
|
274
|
+
</Card>
|
|
275
|
+
) : null}
|
|
276
|
+
|
|
277
|
+
{!submitted && !error ? (
|
|
278
|
+
<Card>
|
|
279
|
+
<CardHeader>
|
|
280
|
+
<CardTitle>About this page</CardTitle>
|
|
281
|
+
<CardDescription>
|
|
282
|
+
The form submits a GET request with structured arguments. The same query is available
|
|
283
|
+
from the CLI as <code>tokentrace query --group-by ... --metric ... --json</code> and
|
|
284
|
+
through the MCP <code>query_usage</code> tool. All three paths execute the same
|
|
285
|
+
deterministic SQL — zero AI tokens are spent.
|
|
286
|
+
</CardDescription>
|
|
287
|
+
</CardHeader>
|
|
288
|
+
</Card>
|
|
289
|
+
) : null}
|
|
290
|
+
</div>
|
|
291
|
+
);
|
|
292
|
+
}
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import * as React from "react";
|
|
4
4
|
import { useMemo, useState } from "react";
|
|
5
|
-
import { Area, AreaChart, CartesianGrid, Tooltip, XAxis, YAxis } from "recharts";
|
|
5
|
+
import { Area, AreaChart, CartesianGrid, ReferenceDot, Tooltip, XAxis, YAxis } from "recharts";
|
|
6
6
|
import type { TrendPoint } from "@/src/lib/analytics";
|
|
7
7
|
import { formatCurrency, formatShortDate, formatTokens } from "@/src/lib/format";
|
|
8
8
|
import { Button } from "@/components/ui/button";
|
|
@@ -118,6 +118,18 @@ function bucketFor(date: string, period: TrendBucket) {
|
|
|
118
118
|
return date;
|
|
119
119
|
}
|
|
120
120
|
|
|
121
|
+
export type TrendAnomalyMarker = {
|
|
122
|
+
date: string;
|
|
123
|
+
value: number;
|
|
124
|
+
severity: "notable" | "high" | "severe";
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
function severityColor(severity: TrendAnomalyMarker["severity"]) {
|
|
128
|
+
if (severity === "severe") return "#dc2626";
|
|
129
|
+
if (severity === "high") return "#f59e0b";
|
|
130
|
+
return "#a3a3a3";
|
|
131
|
+
}
|
|
132
|
+
|
|
121
133
|
export function TrendChart({
|
|
122
134
|
data,
|
|
123
135
|
metric,
|
|
@@ -125,7 +137,8 @@ export function TrendChart({
|
|
|
125
137
|
defaultWindow = "60d",
|
|
126
138
|
period: controlledPeriod,
|
|
127
139
|
trendWindow: controlledTrendWindow,
|
|
128
|
-
showControls = true
|
|
140
|
+
showControls = true,
|
|
141
|
+
markers
|
|
129
142
|
}: {
|
|
130
143
|
data: TrendPoint[];
|
|
131
144
|
metric: "totalTokens" | "cost";
|
|
@@ -134,6 +147,7 @@ export function TrendChart({
|
|
|
134
147
|
period?: TrendBucket;
|
|
135
148
|
trendWindow?: TrendWindow;
|
|
136
149
|
showControls?: boolean;
|
|
150
|
+
markers?: TrendAnomalyMarker[];
|
|
137
151
|
}) {
|
|
138
152
|
const [internalPeriod, setInternalPeriod] = useState<TrendBucket>("daily");
|
|
139
153
|
const [internalTrendWindow, setInternalTrendWindow] = useState<TrendWindow>(defaultWindow);
|
|
@@ -228,6 +242,24 @@ export function TrendChart({
|
|
|
228
242
|
fill={`url(#trend-${metric})`}
|
|
229
243
|
strokeWidth={2}
|
|
230
244
|
/>
|
|
245
|
+
{period === "daily" && markers
|
|
246
|
+
? markers
|
|
247
|
+
.filter((marker) =>
|
|
248
|
+
chartData.some((point) => point.date === marker.date)
|
|
249
|
+
)
|
|
250
|
+
.map((marker) => (
|
|
251
|
+
<ReferenceDot
|
|
252
|
+
key={`${marker.date}:${metric}`}
|
|
253
|
+
x={marker.date}
|
|
254
|
+
y={marker.value}
|
|
255
|
+
r={5}
|
|
256
|
+
fill={severityColor(marker.severity)}
|
|
257
|
+
stroke="#ffffff"
|
|
258
|
+
strokeWidth={2}
|
|
259
|
+
ifOverflow="extendDomain"
|
|
260
|
+
/>
|
|
261
|
+
))
|
|
262
|
+
: null}
|
|
231
263
|
</AreaChart>
|
|
232
264
|
) : (
|
|
233
265
|
<ChartSkeleton />
|
|
@@ -5,7 +5,13 @@ import { useState } from "react";
|
|
|
5
5
|
import type { TrendPoint } from "@/src/lib/analytics";
|
|
6
6
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
|
7
7
|
import { Badge } from "@/components/ui/badge";
|
|
8
|
-
import {
|
|
8
|
+
import {
|
|
9
|
+
TrendChart,
|
|
10
|
+
TrendControls,
|
|
11
|
+
type TrendAnomalyMarker,
|
|
12
|
+
type TrendBucket,
|
|
13
|
+
type TrendWindow
|
|
14
|
+
} from "@/components/charts/trend-chart";
|
|
9
15
|
|
|
10
16
|
function trendWindowLabel(window: TrendWindow) {
|
|
11
17
|
if (window === "30d") return "Showing latest 30 days";
|
|
@@ -16,10 +22,14 @@ function trendWindowLabel(window: TrendWindow) {
|
|
|
16
22
|
|
|
17
23
|
export function TrendSection({
|
|
18
24
|
data,
|
|
19
|
-
defaultWindow
|
|
25
|
+
defaultWindow,
|
|
26
|
+
tokenMarkers,
|
|
27
|
+
costMarkers
|
|
20
28
|
}: {
|
|
21
29
|
data: TrendPoint[];
|
|
22
30
|
defaultWindow: TrendWindow;
|
|
31
|
+
tokenMarkers?: TrendAnomalyMarker[];
|
|
32
|
+
costMarkers?: TrendAnomalyMarker[];
|
|
23
33
|
}) {
|
|
24
34
|
const [period, setPeriod] = useState<TrendBucket>("daily");
|
|
25
35
|
const [trendWindow, setTrendWindow] = useState<TrendWindow>(defaultWindow);
|
|
@@ -65,6 +75,7 @@ export function TrendSection({
|
|
|
65
75
|
period={period}
|
|
66
76
|
trendWindow={trendWindow}
|
|
67
77
|
showControls={false}
|
|
78
|
+
markers={tokenMarkers}
|
|
68
79
|
/>
|
|
69
80
|
</CardContent>
|
|
70
81
|
</Card>
|
|
@@ -81,6 +92,7 @@ export function TrendSection({
|
|
|
81
92
|
period={period}
|
|
82
93
|
trendWindow={trendWindow}
|
|
83
94
|
showControls={false}
|
|
95
|
+
markers={costMarkers}
|
|
84
96
|
/>
|
|
85
97
|
</CardContent>
|
|
86
98
|
</Card>
|