token-trace-manager 0.7.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/.claude-plugin/marketplace.json +19 -0
- package/.claude-plugin/plugin.json +18 -0
- package/.mcp.json +8 -0
- package/CHANGELOG.md +29 -0
- package/LICENSE +21 -0
- package/README.md +380 -0
- package/bin/ttm.mjs +343 -0
- package/commands/config.md +13 -0
- package/commands/dashboard.md +7 -0
- package/commands/link.md +12 -0
- package/commands/setup.md +19 -0
- package/commands/status.md +13 -0
- package/lib/adapters.mjs +428 -0
- package/lib/audit.mjs +238 -0
- package/lib/config.mjs +135 -0
- package/lib/daemon-client.mjs +139 -0
- package/lib/daemon.mjs +614 -0
- package/lib/db.mjs +444 -0
- package/lib/link.mjs +152 -0
- package/lib/mcp-server.mjs +275 -0
- package/lib/otel-export.mjs +122 -0
- package/lib/pricing.mjs +137 -0
- package/lib/report-data.mjs +171 -0
- package/lib/setup-snippets.mjs +201 -0
- package/lib/traces-data.mjs +155 -0
- package/lib/tui-config.mjs +429 -0
- package/package.json +56 -0
- package/templates/config.html +208 -0
- package/templates/dashboard.html +629 -0
- package/templates/traces.html +407 -0
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import { listUsageEvents, listRollups } from './db.mjs'
|
|
2
|
+
|
|
3
|
+
function sinceToIso(since) {
|
|
4
|
+
if (!since || since === 'all') return null
|
|
5
|
+
if (/^\d{4}-\d{2}-\d{2}/.test(since)) return since
|
|
6
|
+
const m = /^(\d+)([dh])$/.exec(since)
|
|
7
|
+
if (!m) return null
|
|
8
|
+
const ms = m[2] === 'd' ? 86400000 : 3600000
|
|
9
|
+
return new Date(Date.now() - parseInt(m[1], 10) * ms).toISOString()
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export async function buildReport({ since = '30d', label = null } = {}) {
|
|
13
|
+
if (label) label = label.toUpperCase() // labels são armazenados em maiúsculas
|
|
14
|
+
const sinceIso = sinceToIso(since)
|
|
15
|
+
const rows = await listUsageEvents({ since: sinceIso, label })
|
|
16
|
+
|
|
17
|
+
// Histórico agregado (traces já expirados pela retenção) entra como
|
|
18
|
+
// pseudo-eventos diários — sem risco de dupla contagem: um rollup só
|
|
19
|
+
// existe para eventos que JÁ foram apagados.
|
|
20
|
+
const rollups = await listRollups({
|
|
21
|
+
sinceDay: sinceIso ? sinceIso.slice(0, 10) : null,
|
|
22
|
+
label: label ?? undefined,
|
|
23
|
+
})
|
|
24
|
+
for (const r of rollups) {
|
|
25
|
+
rows.push({
|
|
26
|
+
ts: r.day + 'T12:00:00.000Z',
|
|
27
|
+
label: r.label || null,
|
|
28
|
+
target: r.target,
|
|
29
|
+
model: r.model || null,
|
|
30
|
+
client: r.client || null,
|
|
31
|
+
input_tokens: r.input_tokens,
|
|
32
|
+
cache_create_tokens: r.cache_create_tokens,
|
|
33
|
+
cache_create_1h_tokens: r.cache_create_1h_tokens,
|
|
34
|
+
cache_read_tokens: r.cache_read_tokens,
|
|
35
|
+
output_tokens: r.output_tokens,
|
|
36
|
+
reasoning_tokens: r.reasoning_tokens,
|
|
37
|
+
cost_usd: r.cost_known ? r.cost_usd : null,
|
|
38
|
+
_rollup: true,
|
|
39
|
+
_count: r.events,
|
|
40
|
+
})
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
let totalCost = 0
|
|
44
|
+
let totalTokens = 0
|
|
45
|
+
let tokensIn = 0 // "lidos": tudo que o modelo leu (entrada + cache lido + cache escrito)
|
|
46
|
+
let tokensOut = 0 // "escritos": tudo que o modelo gerou (inclui reasoning)
|
|
47
|
+
let costKnown = true
|
|
48
|
+
const byLabel = new Map()
|
|
49
|
+
const byTarget = new Map()
|
|
50
|
+
const byModel = new Map()
|
|
51
|
+
const byDay = new Map()
|
|
52
|
+
const byClient = new Map()
|
|
53
|
+
const tasks = new Map() // visão rica por tarefa — a razão do projeto existir
|
|
54
|
+
|
|
55
|
+
const rowTokens = r =>
|
|
56
|
+
(r.input_tokens || 0) + (r.cache_create_tokens || 0) + (r.cache_read_tokens || 0) + (r.output_tokens || 0)
|
|
57
|
+
|
|
58
|
+
const bump = (map, key, row) => {
|
|
59
|
+
const cur = map.get(key) || { cost: 0, tokens: 0, events: 0 }
|
|
60
|
+
cur.cost += row.cost_usd || 0
|
|
61
|
+
cur.tokens += rowTokens(row)
|
|
62
|
+
cur.events += row._count ?? 1
|
|
63
|
+
map.set(key, cur)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
for (const r of rows) {
|
|
67
|
+
totalCost += r.cost_usd || 0
|
|
68
|
+
totalTokens += rowTokens(r)
|
|
69
|
+
tokensIn += (r.input_tokens || 0) + (r.cache_create_tokens || 0) + (r.cache_read_tokens || 0)
|
|
70
|
+
tokensOut += r.output_tokens || 0
|
|
71
|
+
if (r.cost_usd === null || r.cost_usd === undefined) costKnown = false
|
|
72
|
+
|
|
73
|
+
const labelKey = r.label || 'Sem label'
|
|
74
|
+
bump(byLabel, labelKey, r)
|
|
75
|
+
bump(byTarget, r.target, r)
|
|
76
|
+
bump(byModel, r.model || '(desconhecido)', r)
|
|
77
|
+
bump(byClient, (r.client || '(desconhecido)').split('/')[0].split(' ')[0], r)
|
|
78
|
+
bump(byDay, (r.ts || '').slice(0, 10), r)
|
|
79
|
+
|
|
80
|
+
let t = tasks.get(labelKey)
|
|
81
|
+
if (!t) {
|
|
82
|
+
t = {
|
|
83
|
+
key: labelKey,
|
|
84
|
+
tokens: 0,
|
|
85
|
+
input: 0,
|
|
86
|
+
output: 0,
|
|
87
|
+
cacheRead: 0,
|
|
88
|
+
cacheCreate: 0,
|
|
89
|
+
reasoning: 0,
|
|
90
|
+
cost: 0,
|
|
91
|
+
events: 0,
|
|
92
|
+
firstTs: null,
|
|
93
|
+
lastTs: null,
|
|
94
|
+
byDay: new Map(),
|
|
95
|
+
}
|
|
96
|
+
tasks.set(labelKey, t)
|
|
97
|
+
}
|
|
98
|
+
t.tokens += rowTokens(r)
|
|
99
|
+
t.input += r.input_tokens || 0
|
|
100
|
+
t.output += r.output_tokens || 0
|
|
101
|
+
t.cacheRead += r.cache_read_tokens || 0
|
|
102
|
+
t.cacheCreate += r.cache_create_tokens || 0
|
|
103
|
+
t.reasoning += r.reasoning_tokens || 0
|
|
104
|
+
t.cost += r.cost_usd || 0
|
|
105
|
+
t.events += r._count ?? 1
|
|
106
|
+
if (r.ts) {
|
|
107
|
+
if (!t.firstTs || r.ts < t.firstTs) t.firstTs = r.ts
|
|
108
|
+
if (!t.lastTs || r.ts > t.lastTs) t.lastTs = r.ts
|
|
109
|
+
const day = r.ts.slice(0, 10)
|
|
110
|
+
t.byDay.set(day, (t.byDay.get(day) || 0) + rowTokens(r))
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const toSorted = m =>
|
|
115
|
+
[...m.entries()]
|
|
116
|
+
.sort((a, b) => b[1].cost - a[1].cost || b[1].tokens - a[1].tokens)
|
|
117
|
+
.map(([key, v]) => ({ key, ...v }))
|
|
118
|
+
|
|
119
|
+
return {
|
|
120
|
+
generatedAt: new Date().toISOString(),
|
|
121
|
+
since,
|
|
122
|
+
label,
|
|
123
|
+
totalCost,
|
|
124
|
+
totalTokens,
|
|
125
|
+
tokensIn,
|
|
126
|
+
tokensOut,
|
|
127
|
+
costKnown,
|
|
128
|
+
eventCount: rows.reduce((n, r) => n + (r._count ?? 1), 0),
|
|
129
|
+
taskCount: tasks.size,
|
|
130
|
+
// tarefas ordenadas por TOKENS (a métrica que vai pro campo do Jira), não por custo
|
|
131
|
+
tasks: [...tasks.values()]
|
|
132
|
+
.sort((a, b) => b.tokens - a.tokens)
|
|
133
|
+
.map(t => ({
|
|
134
|
+
...t,
|
|
135
|
+
tokensIn: t.input + t.cacheRead + t.cacheCreate,
|
|
136
|
+
tokensOut: t.output,
|
|
137
|
+
byDay: [...t.byDay.entries()].sort((a, b) => a[0].localeCompare(b[0])).map(([day, tokens]) => ({ day, tokens })),
|
|
138
|
+
})),
|
|
139
|
+
byLabel: toSorted(byLabel),
|
|
140
|
+
byTarget: toSorted(byTarget),
|
|
141
|
+
byModel: toSorted(byModel),
|
|
142
|
+
byClient: toSorted(byClient),
|
|
143
|
+
byDay: [...byDay.entries()].sort((a, b) => a[0].localeCompare(b[0])).map(([day, v]) => ({ day, ...v })),
|
|
144
|
+
// traces individuais (auditoria) — rows já vêm ordenadas por ts DESC
|
|
145
|
+
recentCalls: rows.filter(r => !r._rollup).slice(0, 50).map(r => {
|
|
146
|
+
let rates = null
|
|
147
|
+
try {
|
|
148
|
+
rates = r.rates ? JSON.parse(r.rates) : null
|
|
149
|
+
} catch {
|
|
150
|
+
/* evento antigo */
|
|
151
|
+
}
|
|
152
|
+
return {
|
|
153
|
+
ts: r.ts,
|
|
154
|
+
label: r.label,
|
|
155
|
+
target: r.target,
|
|
156
|
+
model: r.model,
|
|
157
|
+
providerId: r.provider_id ?? null,
|
|
158
|
+
input: r.input_tokens || 0,
|
|
159
|
+
cacheCreate: r.cache_create_tokens || 0,
|
|
160
|
+
cacheCreate1h: r.cache_create_1h_tokens || 0,
|
|
161
|
+
cacheRead: r.cache_read_tokens || 0,
|
|
162
|
+
output: r.output_tokens || 0,
|
|
163
|
+
cost: r.cost_usd,
|
|
164
|
+
rates,
|
|
165
|
+
durationMs: r.duration_ms,
|
|
166
|
+
client: r.client,
|
|
167
|
+
status: r.status,
|
|
168
|
+
}
|
|
169
|
+
}),
|
|
170
|
+
}
|
|
171
|
+
}
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
// Snippets de configuração por TUI, confirmados nas docs oficiais de cada
|
|
2
|
+
// ferramenta (pesquisa 2026-07-06). Fonte da lista: agentes suportados no
|
|
3
|
+
// skills.sh + principais TUIs de mercado.
|
|
4
|
+
|
|
5
|
+
export function setupSnippet(tui, cfg) {
|
|
6
|
+
const P = `http://127.0.0.1:${cfg.port}`
|
|
7
|
+
const snippets = {
|
|
8
|
+
'claude-code': `Claude Code — caminho primário: auto-link (descobre seu redirect e se insere no meio):
|
|
9
|
+
|
|
10
|
+
ttm link claude-code [--label PROJ-123]
|
|
11
|
+
# depois reinicie o Claude Code (ele relê ANTHROPIC_BASE_URL no boot)
|
|
12
|
+
|
|
13
|
+
O que ele faz: lê sua base URL atual (env ANTHROPIC_BASE_URL,
|
|
14
|
+
~/.claude/settings.json ou <repo>/.claude/settings.json), vira upstream
|
|
15
|
+
dela, e reescreve a config do Claude Code pra apontar pro ttm.
|
|
16
|
+
- Se você JÁ redireciona pra um compatível-anthropic (z.ai/GLM/...), o
|
|
17
|
+
ttm PRESERVA o redirect — continua batendo lá, com contagem de tokens.
|
|
18
|
+
- Se não tem redirect, o ttm usa o vendor real (api.anthropic.com).
|
|
19
|
+
- --repo escreve em <repo>/.claude/settings.json (útil p/ multi-ticket).
|
|
20
|
+
- --label seta o label padrão (atribuição de tarefa).
|
|
21
|
+
Para desfazer: ttm unlink claude-code
|
|
22
|
+
|
|
23
|
+
Manual (sem auto-link): setar ANTHROPIC_BASE_URL=${P}/anthropic
|
|
24
|
+
(ou ${P}/anthropic/PROJ-123 p/ ticket embutido). Apontar pra host !=
|
|
25
|
+
api.anthropic.com desativa o Remote Control do Claude Code (v2.1.196+).
|
|
26
|
+
Auth continua passando intacta.`,
|
|
27
|
+
|
|
28
|
+
codex: `Codex (OpenAI) — caminho primário: auto-link:
|
|
29
|
+
|
|
30
|
+
ttm link codex [--label TICKET]
|
|
31
|
+
# reinicie o Codex depois
|
|
32
|
+
|
|
33
|
+
Lê ~/.codex/config.toml (model_providers.<ativo>.base_url), vira upstream,
|
|
34
|
+
reescreve o base_url pra apontar pro ttm. Preserva redirect prévio.
|
|
35
|
+
|
|
36
|
+
Manual: ~/.codex/config.toml:
|
|
37
|
+
model_provider = "ttm"
|
|
38
|
+
[model_providers.ttm]
|
|
39
|
+
name = "OpenAI via ttm"
|
|
40
|
+
base_url = "${P}/openai/v1"
|
|
41
|
+
wire_api = "responses"
|
|
42
|
+
env_key = "OPENAI_API_KEY"
|
|
43
|
+
(com ticket: base_url = "${P}/openai/PROJ-123/v1")
|
|
44
|
+
Prefira model_providers a openai_base_url/OPENAI_BASE_URL — há releases do
|
|
45
|
+
Codex com bug que ignora essas formas (issue #16719).`,
|
|
46
|
+
|
|
47
|
+
opencode: `OpenCode (sst) — caminho primário: auto-link:
|
|
48
|
+
|
|
49
|
+
ttm link opencode [--label TICKET]
|
|
50
|
+
# reinicie o OpenCode depois
|
|
51
|
+
|
|
52
|
+
Lê opencode.json (projeto) ou ~/.config/opencode/opencode.json, acha o
|
|
53
|
+
provider com baseURL, vira upstream e reescreve. Preserva redirect prévio.
|
|
54
|
+
|
|
55
|
+
Manual: opencode.json (projeto ou global):
|
|
56
|
+
{
|
|
57
|
+
"provider": {
|
|
58
|
+
"anthropic": { "options": { "baseURL": "${P}/anthropic" } },
|
|
59
|
+
"openai": { "options": { "baseURL": "${P}/openai/v1" } }
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
(com ticket: "baseURL": "${P}/anthropic/PROJ-123")`,
|
|
63
|
+
|
|
64
|
+
'gemini-cli': `Gemini CLI (Google) — caminho primário: auto-link:
|
|
65
|
+
|
|
66
|
+
ttm link gemini-cli
|
|
67
|
+
# depois: export GOOGLE_GEMINI_BASE_URL=http://127.0.0.1:${cfg.port}/gemini
|
|
68
|
+
# (gemini-cli é só-env: o ttm imprime o export; rode no shell e reinicie)
|
|
69
|
+
|
|
70
|
+
Lê GOOGLE_GEMINI_BASE_URL (se setada), vira upstream, e instrui o export.
|
|
71
|
+
Preserva redirect prévio.
|
|
72
|
+
|
|
73
|
+
Manual: env vars (http:// permitido para 127.0.0.1):
|
|
74
|
+
export GEMINI_API_KEY=sua-chave
|
|
75
|
+
export GOOGLE_GEMINI_BASE_URL="${P}/gemini"
|
|
76
|
+
(com ticket: export GOOGLE_GEMINI_BASE_URL="${P}/gemini/PROJ-123")
|
|
77
|
+
Limitação: só o modo API key é rastreável. No login OAuth pessoal o Gemini
|
|
78
|
+
CLI usa o backend interno Code Assist, que essa variável não afeta.`,
|
|
79
|
+
|
|
80
|
+
kilo: `Kilo Code — kilo.jsonc (ou na extensão VS Code: Settings > Providers >
|
|
81
|
+
Custom Provider > Base URL, com Provider API = OpenAI Compatible):
|
|
82
|
+
|
|
83
|
+
{
|
|
84
|
+
"provider": {
|
|
85
|
+
"openai-compatible": {
|
|
86
|
+
"options": {
|
|
87
|
+
"apiKey": "{env:OPENAI_API_KEY}",
|
|
88
|
+
"baseURL": "${P}/openai/v1"
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}`,
|
|
93
|
+
|
|
94
|
+
goose: `Goose (Block) — env vars (ou ~/.config/goose/config.yaml):
|
|
95
|
+
|
|
96
|
+
# provider OpenAI-compatible:
|
|
97
|
+
export OPENAI_HOST="${P}/openai"
|
|
98
|
+
# provider Anthropic:
|
|
99
|
+
export ANTHROPIC_HOST="${P}/anthropic"`,
|
|
100
|
+
|
|
101
|
+
crush: `Crush (Charm) — crush.json (ou ~/.config/crush/crush.json):
|
|
102
|
+
|
|
103
|
+
{
|
|
104
|
+
"providers": {
|
|
105
|
+
"ttm-anthropic": {
|
|
106
|
+
"type": "anthropic",
|
|
107
|
+
"base_url": "${P}/anthropic/v1",
|
|
108
|
+
"api_key": "$ANTHROPIC_API_KEY"
|
|
109
|
+
},
|
|
110
|
+
"ttm-openai": {
|
|
111
|
+
"type": "openai-compat",
|
|
112
|
+
"base_url": "${P}/openai/v1",
|
|
113
|
+
"api_key": "$OPENAI_API_KEY"
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}`,
|
|
117
|
+
|
|
118
|
+
aider: `Aider — env var ou flag:
|
|
119
|
+
|
|
120
|
+
export OPENAI_API_BASE="${P}/openai/v1"
|
|
121
|
+
# ou: aider --openai-api-base ${P}/openai/v1
|
|
122
|
+
# ou em .aider.conf.yml: openai-api-base: ${P}/openai/v1`,
|
|
123
|
+
|
|
124
|
+
cline: `Cline — só via interface (sem arquivo de config para isso):
|
|
125
|
+
Settings (engrenagem) > API Provider = "OpenAI Compatible"
|
|
126
|
+
> Base URL: ${P}/openai/v1`,
|
|
127
|
+
|
|
128
|
+
'roo-code': `Roo Code — só via interface:
|
|
129
|
+
Settings > Providers > API Provider = "OpenAI Compatible"
|
|
130
|
+
> Base URL: ${P}/openai/v1`,
|
|
131
|
+
|
|
132
|
+
droid: `Droid (Factory AI) — ~/.factory/settings.json:
|
|
133
|
+
|
|
134
|
+
{
|
|
135
|
+
"customModels": [{
|
|
136
|
+
"model": "claude-opus-4-8",
|
|
137
|
+
"provider": "anthropic",
|
|
138
|
+
"baseUrl": "${P}/anthropic",
|
|
139
|
+
"apiKey": "\${ANTHROPIC_API_KEY}"
|
|
140
|
+
}]
|
|
141
|
+
}`,
|
|
142
|
+
|
|
143
|
+
'copilot-cli': `GitHub Copilot CLI — modo BYOK via env vars:
|
|
144
|
+
|
|
145
|
+
export COPILOT_PROVIDER_BASE_URL="${P}/openai/v1"
|
|
146
|
+
export COPILOT_PROVIDER_TYPE=openai
|
|
147
|
+
export COPILOT_MODEL=gpt-5.5
|
|
148
|
+
export COPILOT_PROVIDER_API_KEY=sua-chave
|
|
149
|
+
|
|
150
|
+
Obs: o modo padrão (backend proprietário do Copilot) não é rastreável.`,
|
|
151
|
+
|
|
152
|
+
amp: `Amp (Sourcegraph) — NÃO suportado: o Amp roteia modelos pelo backend
|
|
153
|
+
proprietário (ampcode.com) e não documenta como redirecionar as chamadas
|
|
154
|
+
de modelo. HTTP_PROXY existe mas é só transporte, não muda o destino.`,
|
|
155
|
+
|
|
156
|
+
cursor: `Cursor CLI — NÃO suportado oficialmente: o cursor-agent fala com o
|
|
157
|
+
backend proprietário da Cursor e não há override de base URL documentado
|
|
158
|
+
para o CLI (o campo BYOK da IDE não se aplica ao agente headless).`,
|
|
159
|
+
|
|
160
|
+
kiro: `Kiro CLI (AWS) — NÃO suportado: não há mecanismo documentado de
|
|
161
|
+
override de base URL ou proxy para o backend de modelos.`,
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const aliases = {
|
|
165
|
+
claude: 'claude-code',
|
|
166
|
+
claudecode: 'claude-code',
|
|
167
|
+
gemini: 'gemini-cli',
|
|
168
|
+
'kilo-code': 'kilo',
|
|
169
|
+
kilocode: 'kilo',
|
|
170
|
+
roo: 'roo-code',
|
|
171
|
+
roocode: 'roo-code',
|
|
172
|
+
copilot: 'copilot-cli',
|
|
173
|
+
factory: 'droid',
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const key = aliases[tui] ?? tui
|
|
177
|
+
if (!key || !snippets[key]) {
|
|
178
|
+
return (
|
|
179
|
+
`TUIs suportados: ${Object.keys(snippets).join(', ')}\n\n` +
|
|
180
|
+
`Uso: ttm setup <tui>\n` +
|
|
181
|
+
`Padrão geral: aponte a base URL da ferramenta para ${P}/{target}[/{TICKET-123}]\n` +
|
|
182
|
+
`targets: ${Object.keys(cfg.targets).join(', ')}`
|
|
183
|
+
)
|
|
184
|
+
}
|
|
185
|
+
return snippets[key]
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
export const SUPPORTED_TUIS = [
|
|
189
|
+
'claude-code',
|
|
190
|
+
'codex',
|
|
191
|
+
'opencode',
|
|
192
|
+
'gemini-cli',
|
|
193
|
+
'kilo',
|
|
194
|
+
'goose',
|
|
195
|
+
'crush',
|
|
196
|
+
'aider',
|
|
197
|
+
'cline',
|
|
198
|
+
'roo-code',
|
|
199
|
+
'droid',
|
|
200
|
+
'copilot-cli',
|
|
201
|
+
]
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import { listUsageEvents } from './db.mjs'
|
|
2
|
+
|
|
3
|
+
// Sessionização agnóstica ao harness: o proxy não vê "sessão" no protocolo,
|
|
4
|
+
// então agrupa por (cliente, label) com corte por inatividade — mesma técnica
|
|
5
|
+
// de sessionização de analytics. Heurística honesta: duas janelas do MESMO
|
|
6
|
+
// TUI na MESMA tarefa ao mesmo tempo viram uma sessão só (documentado).
|
|
7
|
+
const DEFAULT_GAP_MIN = 30
|
|
8
|
+
|
|
9
|
+
const clientShortOf = c => (c || '(desconhecido)').split('/')[0].split(' ')[0]
|
|
10
|
+
|
|
11
|
+
function sinceToIso(since) {
|
|
12
|
+
if (!since || since === 'all') return null
|
|
13
|
+
if (/^\d{4}-\d{2}-\d{2}/.test(since)) return since
|
|
14
|
+
const m = /^(\d+)([dh])$/.exec(since)
|
|
15
|
+
if (!m) return null
|
|
16
|
+
const ms = m[2] === 'd' ? 86400000 : 3600000
|
|
17
|
+
return new Date(Date.now() - parseInt(m[1], 10) * ms).toISOString()
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function shapeCall(r) {
|
|
21
|
+
let rates = null
|
|
22
|
+
try {
|
|
23
|
+
rates = r.rates ? JSON.parse(r.rates) : null
|
|
24
|
+
} catch {
|
|
25
|
+
/* evento antigo */
|
|
26
|
+
}
|
|
27
|
+
return {
|
|
28
|
+
id: r.id,
|
|
29
|
+
ts: r.ts,
|
|
30
|
+
label: r.label,
|
|
31
|
+
target: r.target,
|
|
32
|
+
model: r.model,
|
|
33
|
+
providerId: r.provider_id ?? null,
|
|
34
|
+
input: r.input_tokens || 0,
|
|
35
|
+
cacheCreate: r.cache_create_tokens || 0,
|
|
36
|
+
cacheCreate1h: r.cache_create_1h_tokens || 0,
|
|
37
|
+
cacheRead: r.cache_read_tokens || 0,
|
|
38
|
+
output: r.output_tokens || 0,
|
|
39
|
+
reasoning: r.reasoning_tokens || 0,
|
|
40
|
+
cost: r.cost_usd,
|
|
41
|
+
rates,
|
|
42
|
+
durationMs: r.duration_ms,
|
|
43
|
+
client: r.client,
|
|
44
|
+
status: r.status,
|
|
45
|
+
repo: r.repo ?? null,
|
|
46
|
+
branch: r.branch ?? null,
|
|
47
|
+
payload: Boolean(r.payload),
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export async function buildTraces({
|
|
52
|
+
since = '7d',
|
|
53
|
+
label = null,
|
|
54
|
+
target = null,
|
|
55
|
+
model = null,
|
|
56
|
+
client = null,
|
|
57
|
+
gapMin = DEFAULT_GAP_MIN,
|
|
58
|
+
maxSessions = 200,
|
|
59
|
+
} = {}) {
|
|
60
|
+
let rows = await listUsageEvents({ since: sinceToIso(since), label: label ? label.toUpperCase() : label })
|
|
61
|
+
if (target) rows = rows.filter(r => r.target === target)
|
|
62
|
+
if (model) rows = rows.filter(r => r.model === model)
|
|
63
|
+
// casa exatamente com o MESMO valor exibido no filtro (clientShort),
|
|
64
|
+
// inclusive '(desconhecido)' para cliente nulo
|
|
65
|
+
if (client) rows = rows.filter(r => clientShortOf(r.client) === client)
|
|
66
|
+
|
|
67
|
+
// opções de filtro (sobre o conjunto já recortado por período/label)
|
|
68
|
+
const opt = key => [...new Set(rows.map(r => r[key]).filter(Boolean))].sort()
|
|
69
|
+
const clientShort = clientShortOf
|
|
70
|
+
const filterOptions = {
|
|
71
|
+
targets: opt('target'),
|
|
72
|
+
models: opt('model'),
|
|
73
|
+
clients: [...new Set(rows.map(r => clientShort(r.client)))].sort(),
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// sessioniza: ordena por (cliente, label, ts) e corta por gap de inatividade
|
|
77
|
+
const sorted = rows
|
|
78
|
+
.slice()
|
|
79
|
+
.sort((a, b) =>
|
|
80
|
+
(a.client || '').localeCompare(b.client || '') ||
|
|
81
|
+
(a.label || '').localeCompare(b.label || '') ||
|
|
82
|
+
a.ts.localeCompare(b.ts),
|
|
83
|
+
)
|
|
84
|
+
const gapMs = gapMin * 60000
|
|
85
|
+
const sessions = []
|
|
86
|
+
let cur = null
|
|
87
|
+
for (const r of sorted) {
|
|
88
|
+
const sameGroup =
|
|
89
|
+
cur &&
|
|
90
|
+
(cur.clientFull || '') === (r.client || '') &&
|
|
91
|
+
(cur.label || '') === (r.label || '') &&
|
|
92
|
+
Date.parse(r.ts) - Date.parse(cur.end) <= gapMs
|
|
93
|
+
if (!sameGroup) {
|
|
94
|
+
cur = {
|
|
95
|
+
label: r.label,
|
|
96
|
+
client: clientShort(r.client),
|
|
97
|
+
clientFull: r.client,
|
|
98
|
+
start: r.ts,
|
|
99
|
+
end: r.ts,
|
|
100
|
+
calls: [],
|
|
101
|
+
tokensIn: 0,
|
|
102
|
+
tokensOut: 0,
|
|
103
|
+
cost: 0,
|
|
104
|
+
costKnown: true,
|
|
105
|
+
targets: new Set(),
|
|
106
|
+
models: new Set(),
|
|
107
|
+
repo: null,
|
|
108
|
+
branch: null,
|
|
109
|
+
}
|
|
110
|
+
sessions.push(cur)
|
|
111
|
+
}
|
|
112
|
+
cur.end = r.ts
|
|
113
|
+
cur.calls.push(shapeCall(r))
|
|
114
|
+
cur.tokensIn += (r.input_tokens || 0) + (r.cache_create_tokens || 0) + (r.cache_read_tokens || 0)
|
|
115
|
+
cur.tokensOut += r.output_tokens || 0
|
|
116
|
+
cur.cost += r.cost_usd || 0
|
|
117
|
+
if (r.cost_usd === null || r.cost_usd === undefined) cur.costKnown = false
|
|
118
|
+
if (r.target) cur.targets.add(r.target)
|
|
119
|
+
if (r.model) cur.models.add(r.model)
|
|
120
|
+
if (r.repo && !cur.repo) cur.repo = r.repo
|
|
121
|
+
if (r.branch && !cur.branch) cur.branch = r.branch
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
sessions.sort((a, b) => b.end.localeCompare(a.end))
|
|
125
|
+
const truncated = sessions.length > maxSessions
|
|
126
|
+
return {
|
|
127
|
+
generatedAt: new Date().toISOString(),
|
|
128
|
+
since,
|
|
129
|
+
filters: { label, target, model, client },
|
|
130
|
+
filterOptions,
|
|
131
|
+
gapMin,
|
|
132
|
+
sessionCount: sessions.length,
|
|
133
|
+
callCount: rows.length,
|
|
134
|
+
callsShown: sessions.slice(0, maxSessions).reduce((n, s) => n + s.calls.length, 0),
|
|
135
|
+
truncated,
|
|
136
|
+
sessions: sessions.slice(0, maxSessions).map((s, i) => ({
|
|
137
|
+
id: `s${i + 1}`,
|
|
138
|
+
label: s.label,
|
|
139
|
+
client: s.client,
|
|
140
|
+
clientFull: s.clientFull,
|
|
141
|
+
start: s.start,
|
|
142
|
+
end: s.end,
|
|
143
|
+
durationMs: Date.parse(s.end) - Date.parse(s.start),
|
|
144
|
+
targets: [...s.targets],
|
|
145
|
+
models: [...s.models],
|
|
146
|
+
repo: s.repo,
|
|
147
|
+
branch: s.branch,
|
|
148
|
+
tokensIn: s.tokensIn,
|
|
149
|
+
tokensOut: s.tokensOut,
|
|
150
|
+
cost: s.cost,
|
|
151
|
+
costKnown: s.costKnown,
|
|
152
|
+
calls: s.calls,
|
|
153
|
+
})),
|
|
154
|
+
}
|
|
155
|
+
}
|