Files
software-mansion__argent/packages/tool-server/test/startup-telemetry.test.ts
filip131311 2196cc18f0 feat(telemetry): anonymous crash diagnostics on toolserver:stop (#494)
## Why

PostHog shows **~9,785 `toolserver:stop` crashes over 90 days**, but
every one collapses into a single bucket — `error_kind=crash`,
`error_code=TOOLSERVER_UNCAUGHT_EXCEPTION`. We know *that* the tool
server died on a top-level throw, never *which* throw. The population is
also two very different things we can't tell apart today:

- **~99%** crash at **<5s uptime with 0 tool calls** — startup faults,
and heavily concentrated per-user (top 10 users ≈ 31% of all crashes;
one user logged 1,027), i.e. **boot → crash → relaunch loops**.
- **~1,000** crash after **>10 min** of real serving — genuine runtime
bugs.

Without the actual exception, all 9,785 rows are unrankable and
unfixable.

## What

Four **crash-only, provably-anonymous** fields on `toolserver:stop`,
enforced by the existing coded-allowlist sanitizer so **no free text can
ever reach PostHog**:

| field | value | why |
|---|---|---|
| `error_name` | error class name (`TypeError`, …) | "what kind" |
| `error_syscall` | Node's `err.code` (`EADDRINUSE`, `ECONNREFUSED`, …)
| the key discriminator for the startup-loop population (port/socket
conflicts) |
| `crash_fingerprint` | 16-hex hash of the **de-identified** top stack
frames | clusters the same crash across users; input carries no
identity, output is opaque |
| `crash_phase` | `"startup"` \| `"serving"` | splits the two
populations directly |

Turns 9,785 opaque rows into a handful of rankable clusters.

## Anonymity (the hard requirement)

- **No error message, no raw stack.** Those routinely embed
`/Users/<name>/…`, URLs, argv, hostnames, interpolated values — the
highest-PII surface and impossible to reliably scrub. The fingerprint
replaces the message for clustering; `error_name` + `error_syscall` give
the "what kind" without free text.
- **Fingerprint input is de-identified before hashing**
(defense-in-depth on top of the hash): first-party frames → basename
only (drops the home-dir/project prefix), `node_modules` frames →
package-relative tail (drops the user prefix; also collapses the pnpm
`.pnpm/pkg@ver/` layout), `node:` internals kept verbatim, column
dropped. So the *same crash on two machines produces the same
fingerprint*, and no username/path survives even into the hash input.
- **Sanitizer is the final gate.** `error_name`
`^[A-Za-z][A-Za-z0-9_]{0,63}$`, `error_syscall` `^E[A-Z0-9_]{1,31}$`,
`crash_fingerprint` `^[0-9a-f]{16}$`, `crash_phase` a 2-value enum. Any
path/message/lowercase/oversize value is dropped before it can leave the
machine.

## Scope & safety (no leaking crashes out of scope)

- Fields are populated **only** in `crashShutdown` (which forces
`reason="crash"`), so clean idle/signal stops carry none. Verified by
the existing startup-telemetry test (the pre-listener watcher-rejection
crash path stays the bare `{reason,uptime_ms,total_tool_calls}` shape).
- The four keys exist only on `ToolserverStopProps` and only in the
`toolserver:stop` allowlist entry — they can't ride any other event.
- `describeCrash` is **total**: every field extraction is isolated, so a
hostile throwing getter on `.name`/`.code`/`.stack` yields the phase
(and whatever else is readable) instead of turning crash reporting into
a second crash. The crash/exit guarantee — re-entrancy guard, force-exit
timer, `finalExitCode` escalation — is untouched.

## Tests

- New `crash-diagnostics.test.ts`: class-name/constructor-name/primitive
extraction, syscall capture, cross-user determinism (incl. **Windows**
drive-letter paths and **pnpm** layout), node_modules tail,
line-sensitivity, no-stack omission, hostile-getter resilience, and
"never emits a message/stack" confinement.
- `sanitize.test.ts`: crash fields pass when well-formed; dropped for
message-as-name, path/lowercase syscall, wrong-length/uppercase
fingerprint, unknown phase; `ERR_*` accepted.
- Full telemetry suite (278) + tool-server startup test green;
lint/prettier clean.

## Reviewed

Ran an adversarial review pass (anonymity leak-hunt, determinism,
crash-path safety, scope). Ship-ready; all findings were Low/Nit and the
actionable ones (hostile-getter hardening, Windows/pnpm test coverage,
`error_syscall` doc clarity) are folded in.

### Deliberately deferred (documented, not blockers)
- Sanitizer doesn't *cross-gate* the crash fields on `reason==="crash"`
— the per-field validator design has no sibling access; coupling is
enforced in emit code. The fields are non-identifying regardless, so a
hypothetical stray `crash_phase` on an idle stop is a data nit, not a
leak.
- First-party frames reduce to basename; if `dist` ever ships unbundled
with duplicate basenames, two sites at the same line could collide.
Non-issue for the bundled tool-server.
2026-07-13 22:16:33 +02:00

125 lines
4.4 KiB
TypeScript

import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
const telemetryMock = vi.hoisted(() => ({
init: vi.fn(),
attachRegistryTelemetry: vi.fn(() => ({
detach: vi.fn(),
recordInvocation: vi.fn(),
getTotalToolCalls: vi.fn(() => 0),
})),
track: vi.fn(),
shutdown: vi.fn().mockResolvedValue(undefined),
warmTelemetryIdentity: vi.fn().mockResolvedValue(undefined),
}));
const registryMock = vi.hoisted(() => ({
dispose: vi.fn().mockResolvedValue(undefined),
}));
const httpHandleMock = vi.hoisted(() => ({
dispose: vi.fn(),
app: {
listen: vi.fn(),
},
}));
const updateCheckerMock = vi.hoisted(() => ({
dispose: vi.fn(),
}));
const watcherMock = vi.hoisted(() => ({
stop: vi.fn(),
}));
// Stub the telemetry surface for assertions, but keep the REAL describeCrash so
// the startup `.catch` path produces genuine anonymous diagnostics (error_name /
// crash_fingerprint / crash_phase) rather than the phase-only catch fallback.
vi.mock("@argent/telemetry", async (importOriginal) => {
const actual = await importOriginal<typeof import("@argent/telemetry")>();
return { ...telemetryMock, describeCrash: actual.describeCrash };
});
// Keep the real registry exports (notably TypedEventEmitter, which
// variant-proposals.ts constructs at module load via index.ts) and override
// only attachRegistryLogger — wiring the real logger onto the stubbed registry
// here would call `.events.on` on a registry mock that has no event emitter.
vi.mock("@argent/registry", async (importOriginal) => {
const actual = await importOriginal<typeof import("@argent/registry")>();
return {
...actual,
attachRegistryLogger: vi.fn(),
};
});
vi.mock("../src/utils/setup-registry", () => ({
createRegistry: vi.fn(() => registryMock),
}));
vi.mock("../src/http", () => ({
createHttpApp: vi.fn(() => httpHandleMock),
}));
vi.mock("../src/utils/update-checker", () => ({
startUpdateChecker: vi.fn(() => updateCheckerMock),
}));
vi.mock("../src/utils/simulator-watcher", () => ({
startSimulatorWatcher: vi.fn(() => ({
stop: watcherMock.stop,
ready: new Promise<void>((_resolve, reject) => {
setTimeout(() => reject(new Error("watcher failed")), 0);
}),
})),
}));
describe("tool-server startup telemetry", () => {
let exitSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
vi.clearAllMocks();
exitSpy = vi.spyOn(process, "exit").mockImplementation(() => undefined as never);
});
afterEach(() => {
exitSpy.mockRestore();
});
it("drains telemetry when startup fails before the listener binds", async () => {
const { start } = await import("../src/index");
start();
// The identity warm-up is kicked off during startup (before the readiness
// gate), so the fingerprint resolve happens off the accept path. Regression
// guard: removing `warmTelemetryIdentity()` from start() must fail here.
expect(telemetryMock.warmTelemetryIdentity).toHaveBeenCalledTimes(1);
await vi.waitFor(() => {
expect(telemetryMock.shutdown).toHaveBeenCalledWith(1500);
});
// This path (watcher-ready rejection) fails the server before the listener
// binds, so it is a startup-phase crash: the readiness-gate `.catch` attaches
// the anonymous diagnostics derived from the rejection error. The rejection is
// a bare `new Error("watcher failed")`, so error_name is "Error", there is no
// syscall code, and the de-identified stack yields a 16-hex fingerprint.
expect(telemetryMock.track).toHaveBeenCalledWith("toolserver:stop", {
reason: "crash",
uptime_ms: expect.any(Number),
total_tool_calls: 0,
error_name: "Error",
crash_fingerprint: expect.stringMatching(/^[0-9a-f]{16}$/),
crash_phase: "startup",
});
expect(updateCheckerMock.dispose).toHaveBeenCalledOnce();
expect(watcherMock.stop).toHaveBeenCalledOnce();
expect(httpHandleMock.dispose).toHaveBeenCalledOnce();
expect(registryMock.dispose).toHaveBeenCalledOnce();
expect(exitSpy).toHaveBeenCalledWith(1);
process.emit("SIGTERM");
expect(telemetryMock.track).toHaveBeenCalledTimes(1);
expect(telemetryMock.shutdown).toHaveBeenCalledTimes(1);
expect(updateCheckerMock.dispose).toHaveBeenCalledOnce();
expect(watcherMock.stop).toHaveBeenCalledOnce();
expect(httpHandleMock.dispose).toHaveBeenCalledOnce();
expect(registryMock.dispose).toHaveBeenCalledOnce();
});
});