yeet:telemetry
yeet:telemetry turns metrics into the wire format the tools people already run expect — today, the Prometheus text exposition format (version 0.0.4). Metrics are data: a script either describes a document of metric families as a plain object and renders it, or keeps a registry that it updates in place and renders (or serves to scrapers) as a snapshot. The encoder enforces every rule the Prometheus parser does, so a bad document fails in your script with the metric named, not at scrape time with a line number.
import Telemetry, { gauge, counter, by } from 'yeet:telemetry';
console.log(await Telemetry.render({
node: {
load1: gauge({ help: '1m load average.', value: 0.42 }),
cpu_seconds_total: counter({
help: 'CPU time.',
unit: 'seconds',
value: by('mode', { user: 1234.5, idle: 98765 }),
}),
},
}));
# HELP node_load1 1m load average.
# TYPE node_load1 gauge
node_load1 0.42
# HELP node_cpu_seconds_total CPU time.
# TYPE node_cpu_seconds_total counter
node_cpu_seconds_total{mode="user"} 1234.5
node_cpu_seconds_total{mode="idle"} 98765
Everything runs in-isolate — nothing crosses to the daemon. render is asynchronous only because leaves of a document may be functions or promises, and because a document can be fetched from a worker.
Importing
import Telemetry, {
gauge, counter, untyped, histogram, summary, // family constructors
by, exp, linear, exp2, log2Buckets, // helpers
render, snapshot, keep, // rendering, and workers
TelemetryError,
} from 'yeet:telemetry';
| Export | Kind | Description |
|---|---|---|
Telemetry | class (default) | A registry of families a long-lived script updates in place. Also carries every named export as a static (Telemetry.gauge, Telemetry.render, Telemetry.Error) |
gauge, counter, untyped | function | Build a scalar family for a document |
histogram | function | Build a histogram family |
summary | function | Build a summary family |
by | function | Fan an object out into labelled samples |
exp, linear, exp2 | function | Bucket bounds for a registry histogram |
log2Buckets | function | A kernel-side log2 histogram as a histogram sample |
render | function | Encode a document, or a worker's snapshot, to text |
snapshot | function | Ask a worker running serve() for its document |
keep | function | Hold a shared worker open for the life of this isolate |
TelemetryError | class | What the encoder throws — a TypeError naming the metric |
Concepts
Documents
A document is an object of metric families keyed by name. A nested plain object is a name prefix: its keys are joined to it with _. Entries that are null or undefined are skipped, so a family can be conditionally present.
{
node: {
memory: {
MemFree_bytes: gauge({ value: 7 }), // node_memory_MemFree_bytes
},
},
up: gauge({ value: 1 }), // up
maybe: cond ? gauge({ value: 1 }) : null, // omitted when cond is false
}
A family is { type, help?, unit?, value }, built with a constructor. Any object with a string type is treated as a family, so a leaf that is neither a family nor a nested object (a bare number, an array) is refused.
Metric names must match [a-zA-Z_:][a-zA-Z0-9_:]*. Each name may carry one family — a duplicate is refused, including one that only collides through a prefix (node: { cpu } beside node_cpu). A histogram owns <name>_bucket, <name>_sum and <name>_count, and a summary owns <name>_sum and <name>_count; another family may not take those names, since a scraper would parse them as two families sharing samples.
Families are written in insertion order, separated by a blank line, each as an optional # HELP, a # TYPE, and its samples. The output always ends in exactly one newline.
Samples and labels
A family's value is one of three shapes:
value | Meaning |
|---|---|
| a sample | One unlabelled series |
[[labels, sample], ...] | One series per entry, each with its own label set |
[[labels, sample, timestampMs], ...] | The same, with a per-sample timestamp |
labels is a plain object. Label names must match [a-zA-Z_][a-zA-Z0-9_]*; a name starting with __ is reserved, and le and quantile are written by the encoder and refused as input. Label values may be strings, numbers, bigints or booleans (coerced with String); a null or undefined value drops that label from the set; anything else is refused. Two entries with the same label set are a duplicate series and refused. The encoder escapes \, " and newlines in label values and \ and newlines in HELP text.
A scalar sample is written as the format spells it:
| Sample | Written as |
|---|---|
| a finite number | its JS string form (0.42, 98765, 1e-9) |
a bigint | its exact digits |
true / false | 1 / 0 |
NaN | NaN |
Infinity / -Infinity | +Inf / -Inf |
null / undefined | no sample — the series is omitted (the HELP/TYPE header still prints) |
Anything else (a string, an object) is also treated as no sample. A negative counter is refused.
A timestamp is the sample's time in milliseconds: a safe integer, or a bigint for a full int64. Any other number is refused.
Histograms and summaries
A histogram's sample is an object rather than a number:
{ buckets: [[le, cumulativeCount], ...], sum?, count? }
buckets list upper bounds in ascending order with cumulative counts (each count includes every bucket below it) — both are checked. A bucket whose count is null is skipped. The +Inf bucket is synthesized: from count when given (it must not be below the last bucket), else from the last bucket's count. An explicit Infinity bound is kept, and count, if given, must agree with it. sum and count are written as <name>_sum and <name>_count when present; count defaults to the +Inf bucket. A labelled histogram keeps le as the last label, as the format wants.
A summary's sample is:
{ quantiles: [[q, value], ...], sum?, count? }
Each q must lie in [0, 1] and quantiles must ascend. A histogram or summary family whose value is null renders its HELP and TYPE lines only.
Units
unit is optional metadata a family may carry. In a document it must already be the suffix of the name — _seconds on wait_seconds, or before _total on a counter (wait_seconds_total) — and is refused otherwise. In a registry the unit (and _total on a counter) is appended to the name for you. The Prometheus text encoder writes no # UNIT line; the field is carried so an encoder that needs it finds it in the snapshot.
Lazy leaves
Any node of a document may be a function or a promise, resolved before encoding: a whole document, a nested prefix object, a family's value, or the sample inside a [labels, sample] pair. A function may itself return a promise. This lets a document read its values at render time rather than when it was written:
const doc = {
node_load1: gauge({ help: '1m load average.', value: async () => (await loadavg()).one }),
open_files: gauge({ value: () => [[{ container: 'a' }, countA()], [{ container: 'b' }, countB()]] }),
};
console.log(await render(doc)); // reads happen here
Registry or document?
A script whose metrics are read fresh each time — a poll of /proc, a query of yeet.graph, a batch read of an eBPF map — writes a document with lazy leaves and renders it. A script that accumulates state over its life — counting events as they arrive, observing latencies into buckets — instantiates a Telemetry registry, writes to its series, and renders its snapshot.
The two meet through the snapshot: telemetry.snapshot() returns a plain object in the document shape, so it spreads into a larger document, crosses postMessage, and is what render accepts. A snapshot family carries two things a document family need not: unit, and created, a list of creation times in milliseconds parallel to value. The Prometheus encoder ignores created; an encoder for a format with start times (OpenTelemetry's) would read it.
const t = new Telemetry();
t.gauge('a').set(1);
console.log(await render({ ...(await t.snapshot()), b: gauge({ value: 2 }) }));
Serving a scrape
A Prometheus server scrapes over HTTP, and a yeet service can answer it: a web-server unit with a route whose portal is console and whose tenancy is per-connection spawns a fresh isolate for each request and streams that isolate's console output back as the response body (text/plain; charset=utf-8), ending when the script exits. A stateless script is therefore a complete exporter on its own: console.log(await render(doc)).
A registry needs a home that outlives one request. That home is a shared worker — a script the host runs once per (service, script, name), which every isolate in the same service reaches through new SharedWorker(spec). The registry lives in the worker and calls serve(); the scrape isolate asks it for a snapshot with render({ worker }). Three scripts make the pattern:
// collector.js — a shared worker. Owns the registry and keeps it current.
import Telemetry from 'yeet:telemetry';
const telemetry = new Telemetry({ service: 'svctap' });
const requests = telemetry.counter('http_requests', { help: 'Requests seen.', labels: ['status'] });
const inflight = telemetry.gauge('http_inflight', { help: 'Requests in flight.' });
// ... attach probes, subscribe to events, and write to the series as they arrive:
// requests.labels({ status: '200' }).inc(); inflight.set(n);
telemetry.serve(); // answer snapshot requests on every connection
// scrape.js — one isolate per scrape. Renders the worker's snapshot and exits.
import { render } from 'yeet:telemetry';
console.log(await render({ worker: './collector.js' }));
// app.js — a long-lived unit in the same service. The host stops a shared
// worker with its last connection, and a scrape isolate lives milliseconds,
// so something long-lived holds a connection open for as long as it runs.
import { keep } from 'yeet:telemetry';
keep('./collector.js');
// ... the rest of the script's work, which keeps this isolate up
# metrics.toml — yeet service import metrics.toml --now
alias = "metrics"
[units.app]
isolate = "./app.js"
[units.scrape]
isolate = "./scrape.js"
lazy = true # runs once per scrape; nothing should start it at boot
[units.web]
web-server = "http://127.0.0.1:9100"
[units.web.routes."/metrics"]
target = "scrape"
portal = "console"
tenancy = "per-connection"
The worker's path is resolved like an import, relative to the script naming it, and two specifiers that resolve to one file reach one worker — app.js and scrape.js name the same ./collector.js. The rendered document always leads with a yeet_worker_up gauge: 1 when the worker answered, 0 with the reason in its HELP text (a timeout, a load error, a collector that threw) when it did not, so a scrape still succeeds and an alert can fire on the value. Pass throwOnDeadWorker: true to fail the render instead, or omitWorkerUp: true to leave the gauge out.
# HELP yeet_worker_up 1 when the shared worker answered.
# TYPE yeet_worker_up gauge
yeet_worker_up 1
# HELP http_requests_total Requests seen.
# TYPE http_requests_total counter
http_requests_total{status="200"} 41
...
serve() also answers on a dedicated worker's single connection, so a long-lived script can put its registry in new Worker('./collector.js') and render it on a timer with render({ worker }). Outside a service, a plain yeet run isolate is a scope of its own: its shared workers are reachable only from itself, so a standalone script generally just keeps the registry in-isolate and calls telemetry.render().
F gauge, counter, untyped
gauge({ help?: string; unit?: string; value: Value }): Family
counter({ help?: string; unit?: string; value: Value }): Family
untyped({ help?: string; unit?: string; value: Value }): Family
A family of the named type for a document. value takes any of the sample shapes; it may also be a function or a promise. Nothing is validated here — every check runs at render time, with the metric's final (prefixed) name in the error.
import { gauge, counter, by } from 'yeet:telemetry';
const doc = {
up: gauge({ value: true }), // up 1
requests_total: counter({ help: 'Requests.', value: by('code', { 200: 41, 500: 2 }) }),
vendor_score: untyped({ value: 12345678901234567890n }), // bigint, exact
};
F histogram
histogram({ help?, unit?, value: Value }): Family
histogram({ help?, unit?, buckets: [number, number][]; sum?: number; count?: number }): Family
A histogram family. The second form is shorthand for one unlabelled histogram sample: buckets, sum and count are gathered into the value. Given neither value nor a sample, the family renders HELP and TYPE only.
histogram({ help: 'Request latency.', unit: 'seconds', buckets: [[0.1, 1], [1, 3]], sum: 1.5, count: 4 })
# HELP request_latency_seconds Request latency.
# TYPE request_latency_seconds histogram
request_latency_seconds_bucket{le="0.1"} 1
request_latency_seconds_bucket{le="1"} 3
request_latency_seconds_bucket{le="+Inf"} 4
request_latency_seconds_sum 1.5
request_latency_seconds_count 4
A labelled histogram passes a sample per label set:
histogram({ value: [[{ route: '/' }, { buckets: [[1, 1]], sum: 0.5, count: 1 }]] })
// h_bucket{route="/",le="1"} 1 ...
F summary
summary({ help?, unit?, value: Value }): Family
summary({ help?, unit?, quantiles: [number, number][]; sum?: number; count?: number }): Family
A summary family, with the same shorthand as histogram over a summary sample.
summary({ quantiles: [[0.5, 1], [0.99, 9]], sum: 10, count: 3 })
// s{quantile="0.5"} 1 / s{quantile="0.99"} 9 / s_sum 10 / s_count 3
F by
by(label: string, values: Record<string, Sample>): [labels, Sample][]
Fans an object out into labelled samples, one per key: by('mode', { user: 1, idle: 2 }) is [[{ mode: 'user' }, 1], [{ mode: 'idle' }, 2]]. Handy for anything already keyed by the label you want.
F exp, linear, exp2
exp(start: number, factor: number, count: number): number[] // start, start*factor, ...
linear(start: number, width: number, count: number): number[] // start, start+width, ...
exp2(count: number, scale = 1): number[] // 2^1 .. 2^count, times scale
Bucket bounds for a registry histogram: exp(1, 2, 4) is [1, 2, 4, 8], linear(0, 5, 3) is [0, 5, 10], exp2(3) is [2, 4, 8] and exp2(2, 0.5) is [1, 2]. Don't include Infinity; the encoder adds the +Inf bucket.
F log2Buckets
log2Buckets(slots: Iterable<number | bigint>, scale = 1, opts?: { sum?: number; count?: number }): HistogramSample
Turns a kernel-side log2 histogram into a histogram sample. Slot i holds the observations with floor(log2(v)) == i, so its cumulative bucket bound is 2^(i+1), times scale to convert the kernel's unit into the metric's. Counts are accumulated (bigints included), count defaults to the total, and sum is passed through if you have one. See the eBPF recipe.
log2Buckets([1, 2, 0], 1e-9, { sum: 4e-9 });
// { buckets: [[2e-9, 1], [4e-9, 3], [8e-9, 3]], sum: 4e-9, count: 3 }
F render
render(doc: Document, opts?): Promise<string>
render(fn: (sources) => Document, opts?): Promise<string>
render(sources: { worker, timeout?, throwOnDeadWorker?, omitWorkerUp? }, opts?): Promise<string>
render(sources: { worker, timeout?, throwOnDeadWorker?, omitWorkerUp? }, fn: (sources) => Document, opts?): Promise<string>
opts = { format?: 'prometheus' }
Encodes a document to text. The forms:
render(doc)— the document as written.render(fn)— the documentfnreturns (it may be async).render({ worker })— the snapshot of a worker runningserve(), rendered as is.render({ worker }, fn)—fnis called with{ worker: snapshot }and returns the document, so a scrape can spread the worker's families into its own.
worker is a path (resolved like an import; a fresh SharedWorker connection is opened and closed around the request), a SharedWorker, a Worker, or any port-like object with postMessage. The other keys of the sources object are snapshot's options: timeout, throwOnDeadWorker and omitWorkerUp. Lazy leaves are resolved, then the document is encoded; format picks the encoder and only 'prometheus' exists.
// A scrape that adds its own family to the worker's document
console.log(await render({ worker: './collector.js', timeout: 500 }, ({ worker }) => ({
...worker,
scrape_isolate_heap_bytes: gauge({ value: heapUsed() }),
})));
Rejects with a TelemetryError for a document the format refuses, with an unknown format, or for a dead worker when throwOnDeadWorker is set.
F snapshot
snapshot(worker: string | Worker | SharedWorker | port, opts?: {
timeout?: number; // ms to wait for the answer, default 2000
throwOnDeadWorker?: boolean; // reject instead of rendering yeet_worker_up 0
omitWorkerUp?: boolean; // leave the yeet_worker_up gauge out
}): Promise<Document>
Asks a worker running serve() for its snapshot and returns it as a document with yeet_worker_up in front. render({ worker }) calls this; use it directly when you want the document rather than text.
By default the call never rejects. When the worker did not answer — the port timed out, the worker failed to load, or its collector threw — the result is { yeet_worker_up: gauge 0 } alone, with the reason appended to the gauge's HELP, so a scrape still renders something to alert on. Two options change that:
throwOnDeadWorkerrejects with aTelemetryErrorwhose message isworker snapshot: <reason>instead — for a script that would rather fail loudly than publish a0.omitWorkerUpleaves the gauge out entirely: the worker's document as is when it answered, an empty document when it did not (unlessthrowOnDeadWorkeris also set).
A path opens its own connection and closes it afterwards; a Worker, SharedWorker or port you hand in is yours and stays open. While the request is pending, unrelated messages arriving on the port go to the onmessage handler that was set before, which is restored afterwards.
const doc = await snapshot('./collector.js');
if (doc.yeet_worker_up.value === false) console.error(doc.yeet_worker_up.help);
// or let it throw
const strict = await snapshot('./collector.js', { throwOnDeadWorker: true, omitWorkerUp: true });
F keep
keep(spec: string): SharedWorker
Opens a SharedWorker on spec, starts its port, and returns it. The host stops a shared worker when its last connection goes away, and a per-request isolate lives milliseconds, so a long-lived isolate in the same service calls keep to hold the worker — and the registry inside it — alive for as long as the caller runs. Close the returned worker's port to let go early.
O Telemetry
A registry: families of series that a long-lived script updates in place, snapshotted into the document shape. Each write method on a family throws a plain TypeError on misuse (a set() on a counter, a missing label), and every family name is fixed at registration.
F new Telemetry
new Telemetry(opts?: { service?: string; resource?: Record<string, string> })
service and resource describe what the metrics are about, OpenTelemetry-style — service is stored as resource['service.name']. The Prometheus encoder does not write them; they are kept on the instance as resource for an encoder that does.
M counter, gauge, untyped
counter(name: string, opts?: { help?: string; unit?: string; labels?: string[] }): Family
gauge(name: string, opts?: { help?: string; unit?: string; labels?: string[] }): Family
untyped(name: string, opts?: { help?: string; unit?: string; labels?: string[] }): Family
Registers a scalar family and returns it. The final name is name, plus _<unit> unless it already ends so, plus _total for a counter unless it already ends so: counter('wait', { unit: 'seconds' }) is wait_seconds_total. Registering a name twice throws. labels fixes the label names every series of the family must carry.
M histogram
histogram(name: string, opts: { help?: string; unit?: string; labels?: string[]; buckets: number[] }): Family
Registers a histogram whose series observe into fixed buckets (upper bounds, sorted for you; build them with exp, linear or exp2). A histogram registered without buckets throws as soon as its first series is created.
M summary
summary(name: string, opts?: { help?: string; unit?: string; labels?: string[] }): Family
Registers a summary. The registry computes no quantiles; a summary series is set() to a whole summary sample you computed elsewhere.
M collect
collect(fn: () => void | Promise<void>): this
Registers a hook run before every snapshot — for a single read that feeds several families. Hooks run in registration order and are awaited.
const t = new Telemetry();
const free = t.gauge('mem_free_bytes', { help: 'MemFree.' });
const avail = t.gauge('mem_available_bytes', { help: 'MemAvailable.' });
t.collect(async () => {
const m = await readMeminfo(); // one read, two families
free.set(m.MemFree);
avail.set(m.MemAvailable);
});
A counter cannot be set() — it only moves up. For a value you read whole from a source that is itself a counter (a kernel statistic, a map's contents), use a family-level collect, which supplies the value verbatim at snapshot time.
M snapshot
snapshot(): Promise<Document>
Runs the collect hooks, then returns every family as a document family: { type, help, unit?, value: [[labels, sample], ...], created: number[] }, keyed by final name. The result is a plain object: spread it into a document, or postMessage it.
M render
render(opts?: { format?: 'prometheus' }): Promise<string>
render(await this.snapshot(), opts).
const t = new Telemetry();
const temp = t.gauge('temp', { unit: 'celsius' });
setInterval(async () => {
temp.set(await readTemp());
console.log(await t.render());
}, 10_000);
M serve
serve(): this
Answers snapshot requests on every connection that reaches this isolate: it sets globalThis.onconnect for a shared worker and globalThis.onmessage for a dedicated one. Only messages of the module's own protocol are handled; each is answered with a fresh snapshot(). A hook or collector that throws comes back to the requester as an error reply (which snapshot turns into yeet_worker_up 0) rather than as the worker's unhandled error, which no client would hear.
Call it from the worker script — see Serving a scrape. serve() replaces onconnect/onmessage, so install it before any handlers of your own, or don't mix the two on one worker.
P resource
The resource map from the constructor, with service.name set from service.
O Family
What the registry's counter/gauge/histogram/summary/untyped return. Properties: name (the final, suffixed name), type, help, unit, labelNames, and buckets on a histogram.
M labels
labels(values: Record<string, string | number | bigint | boolean>): Series
The series for one label set, created on first use. Every name in labelNames must be present with a non-null value (coerced with String, so { status: 200 } and { status: '200' } are one series), and no other name may appear; both throw a TypeError. Series are never evicted, so keep label values bounded — a client IP or a request id as a label grows the worker's heap with every new value.
M inc, dec, set, observe
The write methods of a Series, available directly on an unlabelled family (a family with labels throws: write through labels({...})). See Series.
M collect
collect(fn: () => Sample | [labels, Sample][] | Promise<...>): this
Supplies the family's value at snapshot time, replacing whatever was written to its series: a bare value is one unlabelled sample, a list of [labels, sample] pairs is many. This is how a registry carries values read whole from elsewhere (a kernel counter, a map's contents) beside the ones it accumulates itself.
t.gauge('open_files', { labels: ['container'] })
.collect(() => [[{ container: 'a' }, 3], [{ container: 'b' }, 4]]);
t.counter('ctx_switches')
.collect(async () => (await readStat()).ctxt); // written as ctx_switches_total
O Series
One label set of a family. Every method returns the series, so writes chain.
| Method | counter | gauge | untyped | histogram | summary |
|---|---|---|---|---|---|
inc(n = 1) — add n ≥ 0 | yes | yes | yes | throws | — |
dec(n = 1) — subtract n | throws | yes | throws | throws | throws |
set(v) — replace the value | throws | yes | yes | throws | yes, with a summary sample |
observe(v) — count v into its bucket, add to sum and count | throws | throws | throws | yes | throws |
inc refuses a negative or non-numeric n; observe refuses a non-number or NaN. A histogram series snapshots as cumulative buckets over its bounds, plus sum and count; the encoder adds +Inf.
const lat = t.histogram('lat', { unit: 'seconds', buckets: [0.1, 1, 10] });
lat.observe(0.05).observe(0.5).observe(5).observe(50);
// lat_seconds_bucket{le="0.1"} 1 / {le="1"} 2 / {le="10"} 3 / {le="+Inf"} 4
// lat_seconds_sum 55.55 / lat_seconds_count 4
Data types
I Family
interface Family {
type: 'counter' | 'gauge' | 'histogram' | 'summary' | 'untyped';
help?: string;
unit?: string;
value: Value;
created?: number[]; // a registry snapshot only; ignored by the Prometheus encoder
}
type Value = Sample | [labels: Labels, sample: Sample, timestampMs?: number | bigint][];
type Labels = Record<string, string | number | bigint | boolean | null | undefined>;
I Sample
type Sample = number | bigint | boolean | null | undefined | HistogramSample | SummarySample;
interface HistogramSample { buckets?: [le: number, cumulativeCount: number | bigint][]; sum?: number; count?: number }
interface SummarySample { quantiles?: [q: number, value: number][]; sum?: number; count?: number }
A scalar family takes the scalar forms; a histogram or summary family takes its own object. Any leaf may also be a function or promise producing the value.
I Document
type Document = { [name: string]: Family | Document | null | undefined };
Errors
The encoder throws (and render rejects with) a TelemetryError — a TypeError whose message is "<metric>: <reason>" and whose metric property names the family (or the sample suffix, such as h_bucket) at fault. Match on the class, or on metric:
try {
await render(doc);
} catch (e) {
if (e instanceof TelemetryError) console.error(`bad metric ${e.metric}: ${e.message}`);
}
What it refuses:
| Reason | Rule |
|---|---|
invalid metric name / invalid label name | The name regexes above |
already declared as <type> | Two families for one name, including through a prefix |
collides with the <type> <name> / a sample of the <type> | A family named like a histogram's or summary's sample suffix |
unknown metric type | type outside the five |
unit "<u>" is not the name's suffix | See Units |
label "<k>" is reserved / written by the encoder | __*, le, quantile |
label "<k>" must be a string | An object or symbol label value |
duplicate series | Two entries with one label set |
counters cannot be negative | A counter sample below zero |
bucket bounds must ascend / bucket counts must be cumulative | See Histograms |
count ... disagrees with the +Inf bucket / is below the last bucket | An inconsistent count |
quantile ... is not in [0, 1] / quantiles must ascend | Summary ordering |
a timestamp is an integer of milliseconds | A fractional or unsafe timestamp |
expected a metric family or a nested object | A bare value or array where a family should be |
unknown format | A format other than prometheus |
worker snapshot: <reason> | snapshot with throwOnDeadWorker and a worker that did not answer |
Registry misuse — a duplicate registration, a missing or unknown label, set() on a counter, dec() on anything but a gauge, observe() off a histogram, a histogram without buckets — throws a plain TypeError at the call site, before anything reaches the encoder.
Recipes
Export a kernel log2 histogram from an eBPF map
A BPF program that keeps a log2 histogram in an array map — slot b counting the nanosleep requests of 2^b .. 2^(b+1) microseconds, say — is already most of an exporter. log2Buckets turns the slots into a histogram sample, and scale converts the kernel's microseconds into the seconds the metric is named in:
import probe from './array.bpf.o';
import { ArrayMap } from 'yeet:bpf';
import { histogram, log2Buckets, render } from 'yeet:telemetry';
const control = await probe.bind('sleep_hist', { kind: 'array' }).start();
const hist = new ArrayMap(control, 'sleep_hist');
const doc = {
nanosleep_request_seconds: histogram({
help: 'Requested sleep durations.',
unit: 'seconds',
value: async () => log2Buckets((await hist.lookupBatch(24)).map(([, n]) => n), 1e-6),
}),
};
setInterval(async () => console.log(await render(doc)), 15_000);
Slot b covers 2^b .. 2^(b+1) µs, so bucket i gets the bound 2^(i+1) × 1e-6 seconds and the cumulative count of every slot up to it. No sum is available from the map, so none is written.
Count events into a registry from a subscription
import Telemetry from 'yeet:telemetry';
const telemetry = new Telemetry({ service: 'exectap' });
const execs = telemetry.counter('execs', { help: 'exec() calls seen.', labels: ['comm'] });
const sub = await events.subscribe(({ exec_event: e }) => {
execs.labels({ comm: e.comm }).inc();
});
setInterval(async () => console.log(await telemetry.render()), 10_000);
comm is bounded (a process name, capped at 16 bytes), so it is a reasonable label. A pid or filename would not be: the registry never evicts a series, so each new value would stay in the worker's heap for good.
Timestamp samples you read at a known time
const at = Date.now();
render({
probe_temp_celsius: gauge({ value: [[{ sensor: 'cpu' }, 61.5, at], [{ sensor: 'gpu' }, 48, at]] }),
});
// probe_temp_celsius{sensor="cpu"} 61.5 1758931200000
Prometheus uses the scrape time unless a sample carries its own, so add timestamps only for readings that are genuinely older than the scrape.
Mix accumulated counts with a live host reading
A registry holds what the script counted since it started; a document leaf reads what the host knows right now. The snapshot spreads into the document, so one page carries both:
import Telemetry, { gauge, render } from 'yeet:telemetry';
const t = new Telemetry();
const execs = t.counter('execs', { help: 'exec() calls seen since start.' });
// ... execs.inc() as events arrive
const page = async () => render({
...(await t.snapshot()),
host_uptime_seconds: gauge({
help: 'Host uptime.',
unit: 'seconds',
value: async () => {
const { data, errors } = await yeet.graph.query('{ host { uptime { uptime } } }');
return errors ? null : data.host.uptime.uptime; // null: no sample, HELP/TYPE still print
},
}),
});
setInterval(async () => console.log(await page()), 10_000);
Answer a scrape from a dedicated worker
// main.js — long-lived; the collector runs in its own isolate
import { Worker } from 'yeet:worker';
import { render } from 'yeet:telemetry';
const collector = new Worker('./collector.js'); // collector.js: new Telemetry().serve()
setInterval(async () => console.log(await render({ worker: collector })), 10_000);
A dedicated worker has one opener, so this suits a single long-running script; for per-scrape isolates use a shared worker as in Serving a scrape.