@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
JavaScript
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() });