@mythicalos/telemetry
v0.1.2
Published
The shared mythicalOS heartbeat client: the frozen envelope and per-product bodies, the canonical runtime validator, derived instance identity, consent state with provenance, and an SSRF-isolated dual-send transport. Pseudonymous by design and honest abou
Maintainers
Readme
@mythicalos/telemetry
The shared heartbeat client for the mythicalOS product family: one frozen envelope, three per-product bodies, one runtime validator, one identity derivation, one consent model, one transport.
Why one package: the privacy rules must exist exactly once. Three hand-maintained copies of "counts and buckets only, nothing nameable" is three chances to leak, and a client and a collector that each keep their own validator will drift — a broader client gets its payloads rejected, a narrower one loses real data. Keeping the schema, the emitter and the validator together is what lets a single lockstep test span all three.
What is collected
The complete field list — every leaf, its producer, its value class and its temporal class — is in
docs/FIELDS.md at the repository root. Nothing outside that list is sent,
and the schema rejects undeclared fields structurally at every level.
The payload carries no names, paths, hostnames, emails, prompts, source, SQL, job names or database identifiers.
It is pseudonymous, not anonymous — and we say so
The instance id is stable across days, so the daily records form a longitudinal series, and a rare combination of version, OS/arch and metric mix can single out a small installation. Do not describe this payload as "anonymous" in UI copy, a README, or a privacy notice.
Scope
This package covers the heartbeat channel. A product may have other outbound channels with their own consent story. Do not write "telemetry carries no identifiers" over a product whose other channels say otherwise — scope the claim to usage telemetry and describe the rest separately.
Identity — derived, stateless, no shared secret
instance_secret 32 random bytes, hex. NEVER serialized on any HTTP route.
instance_id uuidv4Format(sha256(instance_secret)[0..16])Ingest authorization is the collector recomputing that derivation from the header secret and constant-time comparing it to the payload's claimed id. No registration, no stored key material, no first-writer race, and no shared or baked write key — one install cannot impersonate another, and there is no credential to leak. Do not introduce one.
⚠ The derivation hashes the UTF-8 bytes of the 64-character hex string, not the decoded 32 bytes. Changing that silently mints a new id for every existing install, orphaning its history and its delete capability.
identity.test.tspins known secret → id vectors for exactly this reason.
Destination-scoped copy identities
A copy destination gets its own secret and its own derived id. The central secret is never sent to a copy — an operator holding it could authenticate as that install at central and delete its data. Changing the copy URL rotates that identity and destroys the retired one; a secret is never carried to a new destination.
Consent
{ enabled: boolean, source: "unset" | "default-on" | "user", decided_at: string }Only source: "unset" may be flipped on by a migration. source: "user" is never overwritten —
by a migration or by anything else. source: "unset" never sends, whatever enabled says.
Provenance prevents future ambiguity; it cannot reconstruct what a pre-existing bare false
meant. fromLegacyBoolean defaults to the conservative reading (treat it as a user opt-out) and
the choice is overridable and documented, not silent.
Dual-send
Always central; optionally also an operator copy. A copy supplements central and never replaces it — copy-without-central is not a valid configuration and is rejected.
Delivery is not atomic and is deliberately not coupled: a flaky operator endpoint must not
suppress central, and a central outage must not suppress the copy. Each (day, destination) keeps
its own durable record — attempt count, last attempt, next attempt, terminal status — because a
single per-day marker is wrong the moment there are two destinations: mark on first success and the
copy never retries; leave it unmarked and central is re-sent on every copy retry.
Retries are bounded and jittered (a fleet-wide fixed retry after an outage is a thundering herd),
single-flight per (day, destination), and carry an idempotency key so an operator collector can
dedupe.
Opt-out is a hard fence. Disabling telemetry, or changing an endpoint or credential, cancels queued retries and purges pending state. The fence is per destination: changing only the copy URL does not cancel an unresolved central delivery whose consent and configuration are unchanged. Only a global opt-out fences both.
Partial state is exposed, not hidden — "central delivered, copy unresolved" is a real state and
SendReport.partial reports it.
A copy endpoint that is configured but unusable does not suppress central. The two destinations are validated and fenced independently: a typo in the operator URL retires the copy and is reported on the emit result, while central's unresolved deliveries are untouched.
On-disk state, and what happens with two writers
Three documents under <stateRoot>/telemetry/: instance.json (the central identity),
copy-identities.json (the destination-scoped copy identity), delivery.json (per-destination
delivery records). Every write that replaces an existing document — delivery state, a rotation,
the self-heal of an unreadable file — goes temp-then-rename, so a reader sees the old bytes or the
new ones and never a torn mix. A first mint is not a replacement and does not use rename; see
below.
A first mint is a claim, not a write. createFileExclusiveSync writes the content into a
private same-directory temp and then link(2)s it into place, so the destination goes from absent
to complete in one step; EEXIST means another writer got there first, and the loser adopts that
identity instead of returning its own. This matters more than it looks: an id that exists only in
one process's memory is data the installation can neither read nor delete, because the id is the
capability a user exercises to ask for their data back. So a first mint resolves — claimed, or
adopted — before centralIdentity() / copyIdentityFor() return anything. One exception, and it
is deliberate: if the claim cannot be attempted at all (a full disk, a read-only mount, an I/O
error), the store logs and returns a process-stable in-memory identity rather than throwing, and
retries the write on the next call — telemetry never takes down the product it reports on, and the
retry resolves rather than overwriting whatever landed meanwhile. A document that cannot be read
is still replaced — nothing will ever release that name, and without the replace the install
would never get a durable identity — but the replace is tied to the inode that was read, so a real
document that turned up while the corrupt one was being judged is adopted instead of overwritten.
Where the filesystem
cannot hard-link, the claim degrades to an exclusive O_EXCL create — still exclusive, and
inode-guarded so that losing the name mid-write reports the loss rather than a false success — and
warns. The guard is a check and not a hold: it removes the window in which the destination is an
empty file, not the instant between its last check and its return, which no filesystem primitive
can remove.
Delivery state is never cached. It is re-read immediately before every mutation and the change is applied to what is actually on disk, so a second writer's records are not erased by a stale in-memory copy of the document — including across the HTTP round trip, which is a window no write may span. A reply that arrives after its delivery was fenced, or after a different delivery took the same key under a new generation, is discarded rather than applied.
The honest limits, because the shorter version would be a lie:
- The read-modify-write is a single synchronous read → mutate → atomic write, and no more than that. The filesystem offers no compare-and-exchange, so two writers landing inside that window can still resolve to one, and two first sends of the same day can still both go out.
- Rotation is a replace, not a claim, because superseding the stored identity is the whole
point of
rotateCentral(). Two processes rotating central at the same instant can still each mint, and the loser's secret will not be the one on disk. (Changing the copy destination is also a replacement, but it goes through the inode-tied path and keeps anything already holding that destination, so two processes moving to the same new endpoint converge on one secret.) - After a failed write,
centralIdentity()can return a different identity later in the same process. An identity that never reached disk has no claim on the name, so the retry claims and — if another writer got there first — adopts what is actually stored rather than renaming over it. Read the identity per use rather than caching it across a process's lifetime, which the emitter already does. - The opt-out fence is process-local. It cancels in-flight requests and purges pending state in the process that observes it; it cannot reach into another process's open socket. Each process reads consent live and stops on its own next tick.
One writer per state directory remains the supported topology. What changed is that no path
which merely reads now destroys an identity: it claims, adopts, or leaves it alone. Every write
that replaces one is either a deliberate act — rotateCentral(), a copy destination change,
clearCopyIdentity() retiring a removed endpoint — or the self-heal of a document that cannot be
read, which is inode-tied so that anything real appearing meanwhile is adopted instead. What is
left is the irreducible check-then-act instants above.
transport.record() returns a detached snapshot — mutating it changes nothing.
Operator-endpoint isolation
The copy destination is attacker-controlled input, fetched from inside the deployment. src/ssrf.ts
enforces: https only (with an explicit opt-in for http to loopback), no redirect following,
resolve-and-block of loopback / link-local / RFC1918 / CGNAT / ULA / IPv4-mapped / NAT64 / 6to4 /
multicast / reserved space with every resolved address required to pass, a hard per-attempt
deadline plus an overall fan-out budget, a bounded response read, bounded concurrency, and a
circuit breaker.
Nothing the endpoint sends back is retained. The response body is drained under a bound and discarded; what is kept is the HTTP status and, for a transport-level refusal, a reason from a closed set this package defines. Earlier revisions kept a scrubbed excerpt as an "actionable error" and tried to filter key material out of it — but the endpoint already holds the write key, so it chooses the spelling, and three successive filters were each defeated by the next encoding (punctuation-separated, then alphanumeric-interleaved, then \uXXXX escapes). Any content filter that passes prose will pass some encoding of a secret, so the class was removed rather than filtered. A status code is still specific and still actionable, and it carries no attacker-chosen bytes into local state, a log, or a support screenshot.
The connection is pinned to the validated address. Validate-then-fetch leaves a DNS-rebinding
window in which validation sees a public address and the connection resolves to 127.0.0.1; the
socket layer is handed a resolver that can only return the address already validated, so there is
no second resolution to race. SNI and certificate verification stay on the original hostname, so
pinning costs nothing in TLS identity.
Disclosure
emitter.disclosure() returns the exact wire bytes per destination, as a collection — with
dual-send the central and copy bodies differ (different instance_id), so a single response would
be exact for at most one of them. It works while telemetry is off, which is the one moment
someone most wants to look.
Usage
import {
HeartbeatEmitter, IdentityStore, Transport,
buildSagaMetrics, normalizeDeltas, applyDefaultOn, parseConsent,
} from "@mythicalos/telemetry";
const identity = new IdentityStore({ stateRoot });
const transport = new Transport({ stateRoot });
const emitter = new HeartbeatEmitter({
product: "saga",
version: VERSION,
identity,
transport,
getConsent: () => readConsent(), // read LIVE — a flip takes effect next tick
getEndpoints: () => ({ centralUrl, copyUrl }),
buildMetrics: (day) => buildSagaMetrics({ deltas, connections, uptimeSeconds }),
});
const result = await emitter.emit(); // { sent: false, reason: "opted_out" } when disabledProducts holding lifetime counters normalise first — the wire carries per-day deltas only:
const { deltas, snapshot } = normalizeDeltas(yesterdaySnapshot, currentLifetimeCounters);
persist(snapshot);A counter that went backwards is a process restart, and normalizeDeltas emits the new value,
never a negative. Only the producer can tell a restart from a genuine drop; the collector never
can, which is why this belongs at the emitter.
Schema and the field-class manifest
schema/heartbeat.v1.json— the canonical schema, and the only one.additionalProperties: falseat every level, including insidemetrics. There is no second version and no dual ingest: a document either is this shape or is refused.schema/field-classes.json— every leaf's value class and temporal class.cumulativeis not a legal temporal value; there is noopaque-idvalue class.scripts/check-field-classes.ts— the CI gate.
Two leaves were struck from the draft before the freeze, both for the same reason: no producer
emitted them. skuld's events.deferrals was drafted against a counter whose code path had been
removed, and saga's advisories.by_severity.critical names a severity the advisor does not have.
Freezing either would have shipped a field that reads zero forever, which nobody can distinguish
from "this install has none". Re-adding one is a new leaf in a new schema version, not an edit
to this one — lockstep.test.ts pins both absences so the build fails rather than the review.
The check enforces SHAPE, not privacy. It cannot distinguish
database_oid: integerfrom a legitimate count. Any new or reclassified leaf needs a named human privacy review recorded in the pull request. Do not describe this check as enforcing privacy.
Development
bun install
bun test # includes lockstep.test.ts: JSON Schema ↔ validator ↔ real emitter output
bun run typecheck
bun run check:manifest