tokentrace 0.17.0 → 0.18.0
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 +52 -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 +1063 -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 +1104 -0
- package/dist/runtime/repair.mjs +1095 -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 +47 -0
- package/scripts/repair.ts +128 -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 +162 -0
- package/src/lib/cost-recalculation.ts +62 -14
- package/src/lib/mcp/tools.ts +78 -0
- package/src/lib/mcp-server.ts +102 -0
- package/src/lib/model-aliases/backfill.ts +130 -0
- package/src/lib/model-aliases/store.ts +156 -0
- package/src/lib/structured-query-cli.ts +172 -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/tsconfig.json +2 -1
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,58 @@ All notable changes to TokenTrace are documented here.
|
|
|
4
4
|
|
|
5
5
|
## Unreleased
|
|
6
6
|
|
|
7
|
+
## [0.18.0] - 2026-05-28
|
|
8
|
+
|
|
9
|
+
### Local intelligence
|
|
10
|
+
|
|
11
|
+
- **Anomaly detection (`tokentrace anomalies`).** A new modified-z-score (MAD)
|
|
12
|
+
detector scores the daily token and cost trend against a trailing window
|
|
13
|
+
(default 14 days) and surfaces deviations as `notable`, `high`, or `severe`.
|
|
14
|
+
Pure-stats; spends zero AI tokens. New CLI flags: `--window=N` (3..60),
|
|
15
|
+
`--metric=tokens|cost|all`, `--json`.
|
|
16
|
+
- **Structured query (`tokentrace query`).** A new deterministic
|
|
17
|
+
parameterized aggregation that lets agents ask precise local questions
|
|
18
|
+
without TokenTrace performing any NL parsing. Group by
|
|
19
|
+
`model|project|tool|session|day`, aggregate
|
|
20
|
+
`cost|totalTokens|interactions`, with optional preset/from/to range,
|
|
21
|
+
exact-match `model|project|tool` filters, and `--top` clamped to 200.
|
|
22
|
+
Routes through `prepareCached` for the warm-statement path.
|
|
23
|
+
- **Auto-classifier (`tokentrace repair auto-classify`).** Each unknown-cost
|
|
24
|
+
workbench group now carries a deterministic `classification` field
|
|
25
|
+
produced by three rules in confidence order: `exact-model` (0.95),
|
|
26
|
+
`family-fragment` (0.70, normalized via `modelNameCandidates`), and
|
|
27
|
+
`parser-source` (0.45, same source file as priced examples). The CLI
|
|
28
|
+
supports `--apply --min-confidence=N` (floor 0.85) which writes each
|
|
29
|
+
qualifying exact-model or family-fragment suggestion to a new
|
|
30
|
+
`model_aliases` table and backfills cost for the matching unknown-cost
|
|
31
|
+
interactions; `--dry-run` previews without writing. parser-source
|
|
32
|
+
matches are skipped from `--apply` because they have no
|
|
33
|
+
(provider, observed-model) pair to persist.
|
|
34
|
+
- **Persistent model aliases.** A new `model_aliases` table maps
|
|
35
|
+
`(provider_id, observed_model)` to a priced model. The cost
|
|
36
|
+
recalculation pass now LEFT JOINs the alias table so aliased costs
|
|
37
|
+
survive every re-seed, with metadata noting the rule and confidence.
|
|
38
|
+
- **Interactive Query page (`/query`).** A new dashboard route exposes the
|
|
39
|
+
structured-query surface with a server-rendered form (group-by, metric,
|
|
40
|
+
range preset / from / to, model/project/tool filters, top N). Results
|
|
41
|
+
render as a deterministic table. Same SQL path as the CLI and MCP tool.
|
|
42
|
+
- **Anomaly drill-down + chart markers.** Each anomaly date in the
|
|
43
|
+
overview Anomalies panel is now a link to `/?range=custom&from=DATE&to=DATE`
|
|
44
|
+
so clicking filters the entire overview to that day. The Trend chart
|
|
45
|
+
also renders colored Recharts `ReferenceDot` markers on flagged days
|
|
46
|
+
(red severe, amber high, gray notable) when the bucket is daily.
|
|
47
|
+
- **"Auto-classify" column in Repair Items.** The repair workbench table
|
|
48
|
+
now shows the suggested priced model, classification rule, and
|
|
49
|
+
confidence percentage alongside the existing legacy suggestion.
|
|
50
|
+
- **New MCP tools.** `get_anomalies`, `query_usage`, and
|
|
51
|
+
`get_classifications` are wired into `src/lib/mcp/tools.ts` with full
|
|
52
|
+
input schemas and `src/lib/mcp-server.ts` handlers that translate
|
|
53
|
+
structured arguments into CLI flags. Each handler is read-only and
|
|
54
|
+
spends zero AI tokens.
|
|
55
|
+
- **Agent discovery catalog.** `tokentrace agent --json` now exposes
|
|
56
|
+
`anomalies`, `query`, and `auto-classify` commands so MCP-capable
|
|
57
|
+
agents can discover the new local-intelligence surface.
|
|
58
|
+
|
|
7
59
|
## [0.17.0] - 2026-05-23
|
|
8
60
|
|
|
9
61
|
### 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>
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import Link from "next/link";
|
|
2
|
+
import { AlertTriangle, ArrowUpRight, CheckCircle2 } from "lucide-react";
|
|
3
|
+
import { detectAnomalies, type Anomaly, type AnomalySeverity } from "@/src/lib/anomaly-detection";
|
|
4
|
+
import type { TrendPoint } from "@/src/lib/analytics";
|
|
5
|
+
import { Badge } from "@/components/ui/badge";
|
|
6
|
+
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
|
7
|
+
import { formatTokens } from "@/src/lib/format";
|
|
8
|
+
|
|
9
|
+
function dayDrillHref(date: string) {
|
|
10
|
+
// Filter the overview to a single day using the existing custom-range params.
|
|
11
|
+
const params = new URLSearchParams({ range: "custom", from: date, to: date });
|
|
12
|
+
return `/?${params.toString()}`;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function severityVariant(severity: AnomalySeverity): "destructive" | "warning" | "secondary" {
|
|
16
|
+
if (severity === "severe") return "destructive";
|
|
17
|
+
if (severity === "high") return "warning";
|
|
18
|
+
return "secondary";
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function formatValue(anomaly: Anomaly) {
|
|
22
|
+
if (anomaly.metric === "tokens") return `${formatTokens(anomaly.value)} tokens`;
|
|
23
|
+
return `$${anomaly.value.toFixed(2)}`;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function formatBaseline(anomaly: Anomaly) {
|
|
27
|
+
if (anomaly.metric === "tokens") return `${formatTokens(anomaly.baseline)} median`;
|
|
28
|
+
return `$${anomaly.baseline.toFixed(2)} median`;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function formatRatio(anomaly: Anomaly) {
|
|
32
|
+
if (anomaly.ratio == null || !Number.isFinite(anomaly.ratio)) return "—";
|
|
33
|
+
return `${anomaly.ratio.toFixed(1)}× baseline`;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function OverviewAnomaliesPanel({ trends }: { trends: TrendPoint[] }) {
|
|
37
|
+
const report = detectAnomalies(trends);
|
|
38
|
+
const recent = report.anomalies.slice(-6).reverse();
|
|
39
|
+
|
|
40
|
+
return (
|
|
41
|
+
<Card>
|
|
42
|
+
<CardHeader className="flex flex-col gap-3 pb-3 lg:flex-row lg:items-start lg:justify-between">
|
|
43
|
+
<div>
|
|
44
|
+
<CardTitle>Anomalies</CardTitle>
|
|
45
|
+
<CardDescription>
|
|
46
|
+
Local modified-z-score (MAD) detector over the last {report.windowSize}-day window. Zero AI tokens spent.
|
|
47
|
+
</CardDescription>
|
|
48
|
+
</div>
|
|
49
|
+
<div className="flex flex-wrap gap-2">
|
|
50
|
+
{report.summary.bySeverity.severe > 0 ? (
|
|
51
|
+
<Badge variant="destructive">{report.summary.bySeverity.severe} severe</Badge>
|
|
52
|
+
) : null}
|
|
53
|
+
{report.summary.bySeverity.high > 0 ? (
|
|
54
|
+
<Badge variant="warning">{report.summary.bySeverity.high} high</Badge>
|
|
55
|
+
) : null}
|
|
56
|
+
{report.summary.bySeverity.notable > 0 ? (
|
|
57
|
+
<Badge variant="secondary">{report.summary.bySeverity.notable} notable</Badge>
|
|
58
|
+
) : null}
|
|
59
|
+
{report.summary.total === 0 ? <Badge variant="success">clear</Badge> : null}
|
|
60
|
+
</div>
|
|
61
|
+
</CardHeader>
|
|
62
|
+
<CardContent>
|
|
63
|
+
{report.summary.total === 0 ? (
|
|
64
|
+
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
|
65
|
+
<CheckCircle2 className="h-4 w-4 text-emerald-500" />
|
|
66
|
+
No unusual days detected in the visible trend. The detector needs at least 5 prior days of data.
|
|
67
|
+
</div>
|
|
68
|
+
) : (
|
|
69
|
+
<ul className="space-y-2 text-sm">
|
|
70
|
+
{recent.map((anomaly) => (
|
|
71
|
+
<li
|
|
72
|
+
key={`${anomaly.date}:${anomaly.metric}`}
|
|
73
|
+
className="flex items-start gap-3 rounded-md border bg-muted/20 px-3 py-2"
|
|
74
|
+
>
|
|
75
|
+
<AlertTriangle
|
|
76
|
+
className={
|
|
77
|
+
anomaly.severity === "severe"
|
|
78
|
+
? "h-4 w-4 mt-0.5 text-destructive"
|
|
79
|
+
: anomaly.severity === "high"
|
|
80
|
+
? "h-4 w-4 mt-0.5 text-amber-500"
|
|
81
|
+
: "h-4 w-4 mt-0.5 text-muted-foreground"
|
|
82
|
+
}
|
|
83
|
+
/>
|
|
84
|
+
<div className="flex-1 min-w-0">
|
|
85
|
+
<div className="flex flex-wrap items-center gap-2">
|
|
86
|
+
<Badge variant={severityVariant(anomaly.severity)}>{anomaly.severity}</Badge>
|
|
87
|
+
<Link
|
|
88
|
+
href={dayDrillHref(anomaly.date)}
|
|
89
|
+
className="inline-flex items-center gap-1 font-medium underline-offset-4 hover:underline"
|
|
90
|
+
title={`Filter the overview to ${anomaly.date}`}
|
|
91
|
+
>
|
|
92
|
+
{anomaly.date}
|
|
93
|
+
<ArrowUpRight className="h-3 w-3" />
|
|
94
|
+
</Link>
|
|
95
|
+
<span className="text-xs text-muted-foreground uppercase tracking-wide">
|
|
96
|
+
{anomaly.metric}
|
|
97
|
+
</span>
|
|
98
|
+
</div>
|
|
99
|
+
<div className="mt-1 text-xs text-muted-foreground">
|
|
100
|
+
{formatValue(anomaly)} — {formatRatio(anomaly)} ({formatBaseline(anomaly)})
|
|
101
|
+
</div>
|
|
102
|
+
</div>
|
|
103
|
+
</li>
|
|
104
|
+
))}
|
|
105
|
+
{report.summary.total > recent.length ? (
|
|
106
|
+
<li className="text-xs text-muted-foreground">
|
|
107
|
+
Showing {recent.length} most recent of {report.summary.total} anomalies. Run{" "}
|
|
108
|
+
<code>tokentrace anomalies --json</code> for the full report.
|
|
109
|
+
</li>
|
|
110
|
+
) : null}
|
|
111
|
+
</ul>
|
|
112
|
+
)}
|
|
113
|
+
</CardContent>
|
|
114
|
+
</Card>
|
|
115
|
+
);
|
|
116
|
+
}
|