UNPKG

@athenox/talos

Version:

Talos: a backend you describe instead of write. Declare a schema, write the rules in plain language, map URLs to sentences; a small built-in model turns them into query plans.

209 lines (208 loc) • 9.73 kB
import { randomUUID } from 'node:crypto'; import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs'; import { arch, availableParallelism, freemem, homedir, loadavg, platform, tmpdir, totalmem } from 'node:os'; import { join } from 'node:path'; import { monitorEventLoopDelay } from 'node:perf_hooks'; /** * Where every instance reports (`POST { events: [...] }`, see collector/server.ts). * Fixed: an app decides what is sent, never where — that is what makes an * instance id something the Talos team can look up. */ export const ENDPOINT = 'https://stats.talos.korollr.com/v1/events'; const PARTS = ['errors', 'operations', 'prompts', 'performance', 'config', 'schema']; const offByEnv = () => /^(0|false|off|no)$/i.test(process.env.TALOS_TELEMETRY ?? '') || /^(1|true)$/i.test(process.env.DO_NOT_TRACK ?? ''); // Every live instance, for the process-wide hooks below. const live = new Set(); const SPOOL = join(tmpdir(), 'talos-telemetry'); let hooked = false; let noticed = false; function hook() { if (hooked) return; hooked = true; // A monitor sees an uncaught exception without changing what happens next: // the process still crashes (or not) exactly as it would have. process.on('uncaughtExceptionMonitor', (err, origin) => { for (const t of live) t.error(err, origin === 'unhandledRejection' ? 'unhandled rejection' : 'uncaught exception'); }); // A dying process cannot wait for a POST: what is left is written down and // sent by the next instance that starts. process.on('exit', () => { for (const t of live) t.spool(); }); } export class Telemetry { instanceId; endpoint = ENDPOINT; on; window; queue = []; last; timers = []; period = { calls: 0, modelMs: [], runMs: [] }; constructor(opts) { const o = opts === false ? undefined : opts ?? {}; // Always Talos's own: an id the developer chose could be anyone's. this.instanceId = randomUUID(); const enabled = !!o && !offByEnv(); this.on = Object.fromEntries(PARTS.map(p => [p, enabled && o?.[p] !== false])); this.window = o?.errorWindowMs ?? 5000; if (!this.enabled) return; live.add(this); hook(); this.timers.push(setInterval(() => void this.flush(), 5000).unref()); if (this.on.performance) this.timers.push(this.sample(o?.loadEveryMs ?? 60_000)); this.resend(); if (!noticed) { noticed = true; console.info(`talos: telemetry on (instance ${this.instanceId}) → ${this.endpoint}. ` + 'Off: TALOS_TELEMETRY=0, or `telemetry: false` / `{ prompts: false, ... }` in defineApp.'); } } get enabled() { return PARTS.some(p => this.on[p]); } has(part) { return this.on[part]; } /** Queue an event if its part is on; `part` undefined means always (while enabled). */ emit(part, event) { if (!this.enabled || (part && !this.on[part])) return; this.queue.push({ ...event, instance: this.instanceId, at: new Date().toISOString() }); // Bounded: an unreachable collector must never grow the app's memory. if (this.queue.length > 1000) this.queue.splice(0, this.queue.length - 1000); if (this.queue.length >= 100) void this.flush(); } /** Talos just answered for this call: errors in the next few seconds follow it. */ answered(callId, modelMs, runMs) { this.last = { callId, at: Date.now() }; if (modelMs !== undefined) { this.period.calls++; this.period.modelMs.push(modelMs); } if (runMs !== undefined) this.period.runMs.push(runMs); } /** An error, kept only if it came within the window after an answer (or names its call). */ error(err, source, callId) { if (!this.on.errors) return; const since = this.last ? Date.now() - this.last.at : Infinity; if (!callId && since > this.window) return; const e = err; this.emit('errors', { type: 'error', source, callId: callId ?? this.last.callId, sinceAnswerMs: callId ? undefined : since, name: e?.name, message: String(e?.message ?? err), code: e?.code, // The home directory names the machine's user: it becomes `~`. stack: e?.stack?.split('\n').slice(0, 15).join('\n').replaceAll(homedir(), '~').replaceAll(homedir().replaceAll('\\', '/'), '~'), }); } async flush() { if (!this.queue.length) return; const events = this.queue.splice(0); try { const r = await fetch(this.endpoint, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ events }), signal: AbortSignal.timeout(5000), }); if (!r.ok && r.status >= 500) throw new Error(String(r.status)); } catch { // Telemetry never breaks the app: kept for the next try. this.queue.unshift(...events.slice(-500)); } } spool() { if (!this.queue.length) return; try { mkdirSync(SPOOL, { recursive: true }); writeFileSync(join(SPOOL, `${this.instanceId}-${process.pid}.json`), JSON.stringify({ events: this.queue })); } catch { /* nothing to do while dying */ } } /** What a crashed process left behind, sent once (a rename claims it first). */ resend() { if (!existsSync(SPOOL)) return; for (const f of readdirSync(SPOOL)) { const claimed = join(SPOOL, `${f}.${process.pid}.sending`); try { if (f.endsWith('.sending')) continue; renameSync(join(SPOOL, f), claimed); const { events } = JSON.parse(readFileSync(claimed, 'utf8')); unlinkSync(claimed); void fetch(this.endpoint, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ events }), signal: AbortSignal.timeout(5000) }).catch(() => { }); } catch { /* another process took it */ } } } /** Once a minute: Talos's latency and load, and the process's. */ sample(every) { const loop = monitorEventLoopDelay({ resolution: 20 }); loop.enable(); let cpu = process.cpuUsage(); let t = performance.now(); const q = (xs, p) => xs.length ? +[...xs].sort((a, b) => a - b)[Math.min(xs.length - 1, Math.floor(p * xs.length))].toFixed(1) : undefined; return setInterval(() => { const now = performance.now(); const used = process.cpuUsage(cpu); const m = process.memoryUsage(); const { calls, modelMs, runMs } = this.period; this.emit('performance', { type: 'load', periodMs: Math.round(now - t), talos: { modelCalls: calls, modelMsP50: q(modelMs, 0.5), modelMsP95: q(modelMs, 0.95), modelShare: +(modelMs.reduce((a, b) => a + b, 0) / (now - t)).toFixed(4), answers: runMs.length, answerMsP50: q(runMs, 0.5), answerMsP95: q(runMs, 0.95), }, process: { cpu: +((used.user + used.system) / 1000 / (now - t) / availableParallelism()).toFixed(4), rssMb: Math.round(m.rss / 2 ** 20), heapMb: Math.round(m.heapUsed / 2 ** 20), eventLoopMsP50: +(loop.percentile(50) / 1e6).toFixed(2), eventLoopMsP99: +(loop.percentile(99) / 1e6).toFixed(2), uptimeS: Math.round(process.uptime()), }, machine: { load: loadavg(), freeMemMb: Math.round(freemem() / 2 ** 20), totalMemMb: Math.round(totalmem() / 2 ** 20) }, }); this.period = { calls: 0, modelMs: [], runMs: [] }; loop.reset(); cpu = process.cpuUsage(); t = now; }, every).unref(); } /** * Feedback on one call: a text, a rating from 1 to 5, or both. Sent at once, * and even with telemetry off — it is the developer's own explicit message. */ async feedback(callId, fb) { const text = fb?.text?.trim() || undefined; const rating = fb?.rating; if (!callId) throw new Error('talos feedback: which call? (the callId Talos returned)'); if (text === undefined && rating === undefined) throw new Error('talos feedback: give a text, a rating, or both'); if (rating !== undefined && !(Number.isInteger(rating) && rating >= 1 && rating <= 5)) throw new Error('talos feedback: the rating is a whole number from 1 to 5'); const r = await fetch(this.endpoint, { method: 'POST', headers: { 'content-type': 'application/json' }, signal: AbortSignal.timeout(10_000), body: JSON.stringify({ events: [{ type: 'feedback', instance: this.instanceId, at: new Date().toISOString(), callId, text, rating }] }), }); if (!r.ok) throw new Error(`talos feedback: the collector answered ${r.status}`); } /** Stop sending (and send what is queued). */ async close() { for (const x of this.timers) clearInterval(x); live.delete(this); await this.flush(); } } export const platformInfo = () => ({ node: process.version, platform: platform(), arch: arch(), cpus: availableParallelism() });