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
package/lib/config.mjs
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import fs from 'node:fs'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
import { dataDir } from './db.mjs'
|
|
4
|
+
|
|
5
|
+
// Porta padrão deliberadamente fora das "manjadas" (8080/3000/8000/9090).
|
|
6
|
+
// 25519 é memorável (aceno à curva ed25519) e não registrada na IANA.
|
|
7
|
+
export const DEFAULT_PORT = 25519
|
|
8
|
+
|
|
9
|
+
// Protocolos de wire reconhecidos pelos extratores em adapters.mjs.
|
|
10
|
+
// Qualquer outro valor no target.protocol é marcado como _invalid em readConfig
|
|
11
|
+
// e recusado em runtime (400 no proxyRequest) — sem cair silenciosamente no
|
|
12
|
+
// extrator OpenAI default (que produziria 0 tokens sem erro visível).
|
|
13
|
+
export const KNOWN_PROTOCOLS = ['anthropic', 'openai', 'openai-responses', 'gemini']
|
|
14
|
+
|
|
15
|
+
// Targets padrão: o primeiro segmento do path escolhe o upstream.
|
|
16
|
+
// targetUrl = URL base real do provedor que o TUI chamaria sem o proxy.
|
|
17
|
+
const DEFAULT_TARGETS = {
|
|
18
|
+
anthropic: { targetUrl: 'https://api.anthropic.com', protocol: 'anthropic' },
|
|
19
|
+
openai: { targetUrl: 'https://api.openai.com', protocol: 'openai' },
|
|
20
|
+
gemini: { targetUrl: 'https://generativelanguage.googleapis.com', protocol: 'gemini' },
|
|
21
|
+
openrouter: { targetUrl: 'https://openrouter.ai/api', protocol: 'openai' },
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function configPath() {
|
|
25
|
+
return path.join(dataDir(), 'config.json')
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function readUserRaw() {
|
|
29
|
+
try {
|
|
30
|
+
return JSON.parse(fs.readFileSync(configPath(), 'utf8'))
|
|
31
|
+
} catch {
|
|
32
|
+
return {}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function writeUserRaw(user) {
|
|
37
|
+
// Escrita atômica (tmp+rename): evita config corrompida se o processo for
|
|
38
|
+
// morto no meio da escrita (ex: shutdown entre link/unlink). No Windows,
|
|
39
|
+
// rename sobre arquivo existente pode falhar (EPERM) — aí fallback.
|
|
40
|
+
const tmp = configPath() + '.tmp'
|
|
41
|
+
fs.writeFileSync(tmp, JSON.stringify(user, null, 2))
|
|
42
|
+
try {
|
|
43
|
+
fs.renameSync(tmp, configPath())
|
|
44
|
+
} catch {
|
|
45
|
+
try { fs.unlinkSync(configPath()) } catch {}
|
|
46
|
+
fs.renameSync(tmp, configPath())
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function readConfig() {
|
|
51
|
+
const user = readUserRaw()
|
|
52
|
+
const port = user.port ?? DEFAULT_PORT
|
|
53
|
+
const mergedTargets = { ...DEFAULT_TARGETS, ...(user.targets ?? {}) }
|
|
54
|
+
const targets = {}
|
|
55
|
+
for (const [name, t] of Object.entries(mergedTargets)) {
|
|
56
|
+
targets[name] = { ...t, _invalid: !KNOWN_PROTOCOLS.includes(t.protocol) }
|
|
57
|
+
}
|
|
58
|
+
return {
|
|
59
|
+
port,
|
|
60
|
+
proxyUrl: user.proxyUrl ?? `http://127.0.0.1:${port}`,
|
|
61
|
+
injectUsage: user.injectUsage ?? true,
|
|
62
|
+
retentionHours: user.retentionHours ?? 24,
|
|
63
|
+
storePayloads: user.storePayloads ?? true,
|
|
64
|
+
otel: user.otel ?? null,
|
|
65
|
+
targets,
|
|
66
|
+
links: user.links ?? {},
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function writeConfig(patch) {
|
|
71
|
+
const user = readUserRaw()
|
|
72
|
+
const next = { ...user, ...patch }
|
|
73
|
+
writeUserRaw(next)
|
|
74
|
+
return readConfig()
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Patch de UM único target sem clobberar os demais (writeConfig faz merge raso
|
|
78
|
+
// no nível top-level, então passar {targets: {...}} substituiria o mapa todo).
|
|
79
|
+
export function setTarget(name, spec) {
|
|
80
|
+
const user = readUserRaw()
|
|
81
|
+
user.targets = user.targets ?? {}
|
|
82
|
+
user.targets[name] = spec
|
|
83
|
+
writeUserRaw(user)
|
|
84
|
+
return readConfig()
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function removeTarget(name) {
|
|
88
|
+
const user = readUserRaw()
|
|
89
|
+
if (user.targets && user.targets[name]) {
|
|
90
|
+
delete user.targets[name]
|
|
91
|
+
writeUserRaw(user)
|
|
92
|
+
}
|
|
93
|
+
return readConfig()
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function setLink(tui, info) {
|
|
97
|
+
const user = readUserRaw()
|
|
98
|
+
user.links = user.links ?? {}
|
|
99
|
+
if (info === null) delete user.links[tui]
|
|
100
|
+
else user.links[tui] = info
|
|
101
|
+
writeUserRaw(user)
|
|
102
|
+
return readConfig()
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Validação de config para warnings em boot/CLI (NUNCA chamada por-request:
|
|
106
|
+
// readConfig já marca _invalid nos targets; isso só produz mensagens).
|
|
107
|
+
export function validateConfig(cfg) {
|
|
108
|
+
const warnings = []
|
|
109
|
+
const invalidTargets = []
|
|
110
|
+
for (const [name, t] of Object.entries(cfg.targets)) {
|
|
111
|
+
if (t._invalid) {
|
|
112
|
+
invalidTargets.push(name)
|
|
113
|
+
warnings.push(
|
|
114
|
+
`target "${name}" tem protocolo desconhecido "${t.protocol}" (válidos: ${KNOWN_PROTOCOLS.join(', ')}) — será recusado em runtime`,
|
|
115
|
+
)
|
|
116
|
+
}
|
|
117
|
+
if (t.targetUrl) {
|
|
118
|
+
try {
|
|
119
|
+
const u = new URL(t.targetUrl)
|
|
120
|
+
if (/\/v1$/.test(u.pathname) || /\/v1\//.test(u.pathname)) {
|
|
121
|
+
warnings.push(
|
|
122
|
+
`target "${name}" targetUrl "${t.targetUrl}" inclui sufixo de rota (/v1...) — pode dobrar o path no upstream (o ttm já encadeia o caminho do TUI)`,
|
|
123
|
+
)
|
|
124
|
+
}
|
|
125
|
+
} catch {
|
|
126
|
+
warnings.push(`target "${name}" targetUrl inválida: "${t.targetUrl}"`)
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
return { warnings, invalidTargets }
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export function pidFilePath() {
|
|
134
|
+
return path.join(dataDir(), 'daemon.pid')
|
|
135
|
+
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process'
|
|
2
|
+
import fs from 'node:fs'
|
|
3
|
+
import path from 'node:path'
|
|
4
|
+
import { fileURLToPath } from 'node:url'
|
|
5
|
+
import { readConfig, pidFilePath } from './config.mjs'
|
|
6
|
+
|
|
7
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
|
8
|
+
const LOCAL_VERSION = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'package.json'), 'utf8')).version
|
|
9
|
+
|
|
10
|
+
export async function fetchDaemon(pathname, { method = 'GET', body = null, timeoutMs = 3000 } = {}) {
|
|
11
|
+
const cfg = readConfig()
|
|
12
|
+
const res = await fetch(`http://127.0.0.1:${cfg.port}${pathname}`, {
|
|
13
|
+
method,
|
|
14
|
+
body: body ? JSON.stringify(body) : undefined,
|
|
15
|
+
headers: body ? { 'content-type': 'application/json' } : undefined,
|
|
16
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
17
|
+
})
|
|
18
|
+
const json = await res.json()
|
|
19
|
+
if (!res.ok) throw new Error(json.error || `HTTP ${res.status}`)
|
|
20
|
+
return json
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export async function daemonHealth() {
|
|
24
|
+
try {
|
|
25
|
+
return await fetchDaemon('/healthz', { timeoutMs: 1500 })
|
|
26
|
+
} catch {
|
|
27
|
+
return null
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async function spawnDaemon() {
|
|
32
|
+
const child = spawn(process.execPath, [path.join(__dirname, 'daemon.mjs')], {
|
|
33
|
+
detached: true,
|
|
34
|
+
stdio: 'ignore',
|
|
35
|
+
})
|
|
36
|
+
child.unref()
|
|
37
|
+
|
|
38
|
+
for (let i = 0; i < 20; i++) {
|
|
39
|
+
await new Promise(r => setTimeout(r, 150))
|
|
40
|
+
const h = await daemonHealth()
|
|
41
|
+
if (h) return h
|
|
42
|
+
}
|
|
43
|
+
throw new Error('daemon não respondeu ao /healthz após o start — verifique a porta com `ttm status`')
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export async function ensureDaemon() {
|
|
47
|
+
const health = await daemonHealth()
|
|
48
|
+
if (health && health.version === LOCAL_VERSION) return { started: false, health }
|
|
49
|
+
|
|
50
|
+
// Daemon de uma versão antiga ainda de pé após upgrade: template/dados
|
|
51
|
+
// ficariam inconsistentes — reinicia com o código novo automaticamente.
|
|
52
|
+
if (health) {
|
|
53
|
+
await stopDaemon()
|
|
54
|
+
const h = await spawnDaemon()
|
|
55
|
+
return { started: true, restartedFrom: health.version, health: h }
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return { started: true, health: await spawnDaemon() }
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// O pid do /healthz é a fonte de verdade (o próprio daemon responde). O pid
|
|
62
|
+
// file é só dica: pode ficar obsoleto após crash/SIGKILL e, com reuso de PID
|
|
63
|
+
// pelo SO, apontar para um processo alheio — por isso, antes de matar por pid
|
|
64
|
+
// file, confirmamos via /proc que o processo é mesmo o nosso daemon.
|
|
65
|
+
function pidLooksLikeDaemon(pid) {
|
|
66
|
+
try {
|
|
67
|
+
const cmdline = fs.readFileSync(`/proc/${pid}/cmdline`, 'utf8')
|
|
68
|
+
return cmdline.includes('daemon.mjs')
|
|
69
|
+
} catch {
|
|
70
|
+
return null // sem /proc (macOS etc.) — não dá pra confirmar
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export async function stopDaemon() {
|
|
75
|
+
const pidFile = pidFilePath()
|
|
76
|
+
const clearPidFile = () => {
|
|
77
|
+
try {
|
|
78
|
+
fs.unlinkSync(pidFile)
|
|
79
|
+
} catch {
|
|
80
|
+
/* já removido */
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// O daemon encerra graciosamente (espera streams em voo, até 15s) —
|
|
85
|
+
// aguardamos o processo sair de fato para liberar a porta antes de retornar.
|
|
86
|
+
const waitExit = async pid => {
|
|
87
|
+
for (let i = 0; i < 170; i++) {
|
|
88
|
+
try {
|
|
89
|
+
process.kill(pid, 0)
|
|
90
|
+
} catch {
|
|
91
|
+
return true // processo saiu
|
|
92
|
+
}
|
|
93
|
+
await new Promise(r => setTimeout(r, 100))
|
|
94
|
+
}
|
|
95
|
+
return false
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const h = await daemonHealth()
|
|
99
|
+
if (h?.pid) {
|
|
100
|
+
try {
|
|
101
|
+
process.kill(h.pid, 'SIGTERM')
|
|
102
|
+
} catch (err) {
|
|
103
|
+
return { stopped: false, reason: `não consegui matar pid ${h.pid}: ${err.message}` }
|
|
104
|
+
}
|
|
105
|
+
clearPidFile()
|
|
106
|
+
const exited = await waitExit(h.pid)
|
|
107
|
+
return { stopped: true, pid: h.pid, graceful: exited }
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Sem healthz: daemon travado ou morto. Só matamos por pid file se
|
|
111
|
+
// conseguirmos confirmar que o processo é o daemon.
|
|
112
|
+
let pid = null
|
|
113
|
+
try {
|
|
114
|
+
pid = parseInt(fs.readFileSync(pidFile, 'utf8').trim(), 10)
|
|
115
|
+
} catch {
|
|
116
|
+
/* sem pid file */
|
|
117
|
+
}
|
|
118
|
+
if (!pid) return { stopped: false, reason: 'daemon não está rodando' }
|
|
119
|
+
|
|
120
|
+
const confirmed = pidLooksLikeDaemon(pid)
|
|
121
|
+
if (confirmed === false) {
|
|
122
|
+
clearPidFile() // pid file obsoleto apontando pra outro processo
|
|
123
|
+
return { stopped: false, reason: `pid file obsoleto (pid ${pid} não é o daemon) — removido` }
|
|
124
|
+
}
|
|
125
|
+
if (confirmed === null) {
|
|
126
|
+
return {
|
|
127
|
+
stopped: false,
|
|
128
|
+
reason: `daemon não responde e não consigo confirmar o pid ${pid} nesta plataforma — mate manualmente se necessário`,
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
try {
|
|
132
|
+
process.kill(pid, 'SIGTERM')
|
|
133
|
+
} catch (err) {
|
|
134
|
+
clearPidFile()
|
|
135
|
+
return { stopped: false, reason: `não consegui matar pid ${pid}: ${err.message}` }
|
|
136
|
+
}
|
|
137
|
+
clearPidFile()
|
|
138
|
+
return { stopped: true, pid }
|
|
139
|
+
}
|