Skip to main content

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';
ExportKindDescription
Telemetryclass (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, untypedfunctionBuild a scalar family for a document
histogramfunctionBuild a histogram family
summaryfunctionBuild a summary family
byfunctionFan an object out into labelled samples
exp, linear, exp2functionBucket bounds for a registry histogram
log2BucketsfunctionA kernel-side log2 histogram as a histogram sample
renderfunctionEncode a document, or a worker's snapshot, to text
snapshotfunctionAsk a worker running serve() for its document
keepfunctionHold a shared worker open for the life of this isolate
TelemetryErrorclassWhat 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:

valueMeaning
a sampleOne 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:

SampleWritten as
a finite numberits JS string form (0.42, 98765, 1e-9)
a bigintits exact digits
true / false1 / 0
NaNNaN
Infinity / -Infinity+Inf / -Inf
null / undefinedno 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 document fn returns (it may be async).
  • render({ worker }) — the snapshot of a worker running serve(), rendered as is.
  • render({ worker }, fn) — fn is 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:

  • throwOnDeadWorker rejects with a TelemetryError whose message is worker snapshot: <reason> instead — for a script that would rather fail loudly than publish a 0.
  • omitWorkerUp leaves the gauge out entirely: the worker's document as is when it answered, an empty document when it did not (unless throwOnDeadWorker is 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.

Methodcountergaugeuntypedhistogramsummary
inc(n = 1) — add n ≥ 0yesyesyesthrows—
dec(n = 1) — subtract nthrowsyesthrowsthrowsthrows
set(v) — replace the valuethrowsyesyesthrowsyes, with a summary sample
observe(v) — count v into its bucket, add to sum and countthrowsthrowsthrowsyesthrows

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:

ReasonRule
invalid metric name / invalid label nameThe 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 typetype outside the five
unit "<u>" is not the name's suffixSee Units
label "<k>" is reserved / written by the encoder__*, le, quantile
label "<k>" must be a stringAn object or symbol label value
duplicate seriesTwo entries with one label set
counters cannot be negativeA counter sample below zero
bucket bounds must ascend / bucket counts must be cumulativeSee Histograms
count ... disagrees with the +Inf bucket / is below the last bucketAn inconsistent count
quantile ... is not in [0, 1] / quantiles must ascendSummary ordering
a timestamp is an integer of millisecondsA fractional or unsafe timestamp
expected a metric family or a nested objectA bare value or array where a family should be
unknown formatA 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.