# Application metrics and events: the SDKs

What a process **started by 3pm** reports about itself — pm2's
`io.metric` / `io.counter` / `io.histogram` / `io.notifyError`, for any
language and any number of processes per app. Four SDKs with the same
vocabulary and the same behavior, in `packages/`:

| Language | Package | Install |
|---|---|---|
| Node ≥ 18 / Electron | `@3pm/api` (`io`, plus `@3pm/api/renderer`) | `npm install https://3pm.edgecr4ft.net/sdk/node/3pm-api-0.64.0.tgz` — `packages/node` |
| Python ≥ 3.9 | `threepm-io` (`threepm_io`, standard library only) | `pip install --index-url https://3pm.edgecr4ft.net/simple/ threepm-io` or `pip install -e packages/python` |
| C++17 | `threepm_io.hpp` (header-only, POSIX, no JSON library) | `FetchContent` on `https://3pm.edgecr4ft.net/sdk/cpp/threepm-io-cpp-0.64.0.tar.gz`, `add_subdirectory(packages/cpp)` → `threepm::io`, or copy `include/` |
| Rust ≥ 1.88 | `threepm-io` (`serde` + `serde_json`, std threads) | `cargo add threepm-io --registry threepm`, the registry being `sparse+https://3pm.edgecr4ft.net/sdk/cargo/` in `.cargo/config.toml` — `packages/rust` |

The four are published on `https://3pm.edgecr4ft.net` only (not on npm,
PyPI or crates.io) by `just publish-sdk`; every version, its files and
their sha256: `https://3pm.edgecr4ft.net/sdk/sdk.json`.

Read them: `3pm show <app>` (section "Custom metrics", approximate
quantiles for histograms), `3pm show <app> --json` (`app_metrics`),
`3pm events -f app`. On a fleet: the agent and the backend
(`docs/backend-agent/`). Storage model, bounds and the wire:
`docs/metrics.md` "Application metrics" and `docs/protocol.md` "Control
channel".

## How it works

Nothing is injected into your process. The SDK reads three variables the
daemon puts in every child's environment — `THREEPM_CTL_SOCK`,
`THREEPM_ID`, `THREEPM_GEN` — connects to the control socket in the
background, says `hello` with your `source`, then sends its **whole
registry once per second** (`flush_ms`) and every event as it happens.
Counters are cumulative and histograms are cumulative buckets, so a lost
batch loses nothing: the next one carries the same totals.

Without those variables (a plain `node app.js`, a unit test) the SDK is
**disabled**: every call is a no-op, no socket, no thread, no timer.

A `source` names one process within the app (`^[a-z0-9][a-z0-9_-]{0,31}$`;
`electron-main`, `cv-engine`, `plugin-host`…) and becomes the `src`
label. It is unique per instance: a process that restarts reconnects
under the same source and replaces its predecessor; its counters restart
from zero (a reset, which rates absorb). The same goes for two live
processes that say hello under one source: they evict each other, so
give each process its own. The Node SDK picks a distinct default when
you name none: `main` for the process 3pm spawned, `worker-<pid>-<threadId>`
in a worker thread (thread ids restart in every process), `pid-<pid>` in a process forked with an IPC channel
(`child_process.fork`, a cluster worker) or started by the process that
holds `main` (it leaves `THREEPM_IO_MAIN=<id>.<gen>.<pid>` in the
environment its children inherit; a child started before that call, by
a non-Node parent, cannot be told apart — name it). Two such processes
that both claim `main` settle it: the one whose hello is refused falls
back to `pid-<pid>` once, and that refusal never counts toward the SDK
giving up.

## The surface

| Call | Semantics |
|---|---|
| `init({source, flush_ms = 1000, on_shutdown})` | Connect in the background; idempotent. `on_shutdown` runs when the daemon asks for a graceful stop (`shutdown_with_message`) |
| `gauge(name, {unit, description, labels})` → `.set(v)` | Last value |
| `counter(name, …)` → `.inc(n = 1)` | Cumulative since process start, never decremented; rates at read time |
| `meter(name, …)` → `.mark(n = 1)` | pm2's meter: the same counter |
| `histogram(name, {…, scale = 1})` → `.observe(v)` | Base-2 exponential buckets (below); `count`/`sum` cumulative; quantiles at read time, so a fleet can add them up |
| `.labels({k: v})` on any of the three | A child series with those labels added (prom-client style). Same name + labels = same object; cache the child, the lookup validates the labels each time. On the `overflow="true"` fold it returns the fold |
| `metric(name, fn, {…})` | A gauge read by calling `fn` at every flush — in the flush thread (Python, C++, Rust) or on the event loop (Node). A name is either a metric or a callback, never both |
| `event(name, {level, attrs, exc, fp})` | Sent at once; queued (64) while the connection comes up |
| `notify_error(err, {attrs, name})` | An `error` event carrying `exc.{type, message, stacktrace}` |
| `flush({wait_ms})`, `shutdown()` | `flush` is non-blocking unless `wait_ms` is given, then waits for the connection and the bytes (a short-lived script); `shutdown` does the same wait, then closes. Called from the SDK's own thread (a callback, `on_shutdown`) neither waits |

Names: `^[a-z][a-z0-9_.]{0,63}$`, dots for levels (`http.latency`,
`cv.xruns`), the unit **not** in the name (`http.latency` + `unit: "ms"`,
not `http.latency_ms`). Units are UCUM (`ms`, `s`, `By`, `1`, `%`, `Cel`),
descriptions ≤ 128 **bytes** (every cap here is in UTF-8 bytes, like the
daemon's; the SDKs cut on a character boundary and drop a unit or a
description holding a control character), both sent with every batch and
kept in the catalogue. The daemon announces `THREEPM_CTL_CAPS=sticky_meta`
to its apps: an entry without `u` or `d` then leaves the series' stored
ones unchanged, and an SDK that reads the capability sends them once per
series and connection (the Rust SDK since 0.37).

### Labels

Up to **3 keys** per metric (`^[a-z][a-z0-9_]{0,31}$`), values ≤ 64
bytes without control characters (numbers and booleans are stringified
the JSON way: `200` → `"200"`, `1.0` → `"1"`, `true` → `"true"`); the
twenty-three keys the fleet sets itself are refused (`cluster`, `node`,
`app`, `instance`, `ns`, `src`, `overflow`, `vmrange`, `le`, `hostname`,
`os`, `arch`, `agent_version`, `mount`, `window`, the host dimensions
`core`, `iface`, `blockdev`, `sensor`, `gpu`, `array`, and the process
tree's `proc`, `pid`). A
metric with an invalid label set is refused **by the SDK** (a silent
no-op series; `THREEPM_IO_DEBUG=1` says why).

Past **50 label sets** on one metric, the SDK folds the excess into a
single series labelled `overflow="true"` (the OpenTelemetry
`otel.metric.overflow` model): counters and histograms add up, gauges keep
the last value — sums stay exact, cardinality stays bounded, memory stays
bounded in the SDK and in the daemon. Design labels accordingly: a
`status` or a `route` pattern, never a user id.

### Histograms

Exponential buckets, base 2, the OpenTelemetry `ExponentialHistogram`
layout restricted to positive values: at `scale = s` bucket `i` covers
`(2^(i/2^s), 2^((i+1)/2^s)]`, upper bound inclusive (a value exactly on a
boundary belongs to the bucket below), `0` and subnormals in a zero
bucket, NaN/infinities/negatives ignored. The default `scale = 1` (√2
factor, ~6.6 buckets per decade, ±19 % on a quantile, ~27 buckets over
four decades) fits the 1 000-series budget with dozens of label sets;
`scale = 2` or `3` for one key metric that needs ±9 % or ±4 %.

A histogram carries at most **128 non-empty buckets** (128 doublings at
scale 0, 64 at scale 1, 8 at scale 4). Node, Python and Rust do what the
OpenTelemetry SDK does when a 129th bucket would open: they lower the
scale by one, merging bucket `i` into `⌊i/2⌋` (exact on this grid; the
daemon sees the new `s` and replaces the buckets), so a scale-4 histogram
fed a wide range ends up at the scale that fits — quantile error grows,
nothing is refused. C++ keeps its `observe` lock-free with a fixed array
of 128 slots at the requested scale, centred on 1 unless `min_exp` says
otherwise (scale 1: `[2^-32, 2^32)`, scale 4: `[2^-4, 2^4)`); a value
outside the window lands in the edge slot, so widen the window (lower
the scale or set `min_exp`) for a metric with another range.

The index is derived from the double's exponent and mantissa against a
frozen table — no `log2`, no libm call — and all four SDKs reproduce
`threepm_protocol::exphist::classify` **bit for bit**: the shared vectors
`packages/exphist-vectors.json` (generated by the Rust tests) are replayed
by every suite. Changing the grid is a `PROTOCOL_VERSION` bump.

Observe **per event** (a request, an audio callback), not per sample.
In C++ `count`, `sum` and the buckets are read without a lock at flush
time: a batch is eventually consistent, never exactly. A counter or a
sum that would overflow to infinity ignores the observation.

### Events

The OpenTelemetry log-record shape: `name` (same grammar as metrics),
`level` in `debug`/`info`/`warn`/`error`/`fatal` (`severity_number`
5/9/13/17/21), flat scalar `attrs` (≤ 16 keys, strings ≤ 256; no nested
objects — the log store indexes flat fields), an optional exception
(`type` ≤ 128, `message` ≤ 1 KiB, `stacktrace` ≤ 8 KiB; longer values are
cut, not refused) and an optional fingerprint `fp` (≤ 64). Without `fp`
the daemon derives one from the name, the exception type and the first
three frames of the trace.

The daemon stamps the time, replaces the instance's `cwd` and `$HOME` in
the trace by `.` and `~`, and rate-limits **per fingerprint** (1/s, burst
10) and per instance (10/s, burst 50): a crash loop shows up as
`repeat = N` on the next event of that fingerprint that passes, never as
a flood. What you put in a label, an attribute or an exception message
crosses the fleet link as is — no secrets there.

### Behavior you can rely on

- **Never blocking, never throwing.** Every call returns immediately.
  The outbound buffer is bounded (64 KiB): when the daemon does not read,
  the batch is dropped and the next one carries the totals. Nothing is
  logged above debug (`THREEPM_IO_DEBUG=1` to trace). (Node: the actions
  declaration and replies travel on their own budget, under the daemon's
  1 MiB line cap, so metrics can never crowd them out; `io.action` with a
  `3pm:` name is the one call that throws.)
- **Never keeps the process alive.** Sockets and timers are unref'd
  (Node), threads are daemons (Python) or joined by the destructor (C++).
  In Rust nothing holds a process up at all — `main` returning is the end,
  so a short-lived program flushes before returning and a worker that only
  answers actions ends its `main` with `io().serve()`.
- **Reconnection** 1 s → 30 s with doubling when the daemon goes away.
- **Gives up after 5 consecutive refusals** of `hello` — a connection the
  daemon closes right away means the process outlived its incarnation
  (a stale `THREEPM_GEN`: a `setsid` helper, a process that survived a
  restart). The SDK stops trying for good.
- **Bounds mirror the daemon's** (`docs/metrics.md`): 200 names, 3 label
  keys, 50 label sets then overflow, 128 buckets per histogram, 256
  entries and 32 KiB per line (longer registries are split over several
  lines, each under the outbound budget). What the SDK sends is never
  refused: `app_metrics_rejected` on the record stays at 0, and a line
  the daemon cannot parse is skipped by the daemon, never fatal.
- **Advanced knobs**, mainly for tests, same names in the four:
  `reconnect_min_ms`, `reconnect_max_ms`, `max_buffer_bytes`,
  `max_refusals`, `refusal_window_ms`.

## Node — `@3pm/api`

```js
const { io } = require('@3pm/api');
io.init({ source: 'worker', builtins: true, on_shutdown: () => server.close() });

const requests = io.counter('http.requests', { labels: { method: 'GET' } });
const latency  = io.histogram('http.latency', { unit: 'ms' });
const temp     = io.gauge('room.temp', { unit: 'Cel', description: 'studio temperature' });
io.metric('queue.depth', () => queue.length);

requests.labels({ status: res.statusCode }).inc();
latency.observe(elapsedMs);
temp.set(21.5);
io.event('deploy', { level: 'warn', attrs: { version: '1.2' } });
io.notifyError(err, { attrs: { where: 'worker' } });
```

`builtins: true` registers ordinary opt-in metrics, read once per flush
(a registry collector takes each snapshot once) or recorded as things
happen:

| Metric | Kind, unit | What |
|---|---|---|
| `nodejs.eventloop.delay` | histogram, ms | lag of a 100 ms timer, every sample |
| `nodejs.eventloop.delay.p99`, `.max` | gauge, ms | `monitorEventLoopDelay` (10 ms resolution) over the flush window; a window without a sample keeps the last value |
| `nodejs.eventloop.utilization` | gauge, 1 | busy time over wall time since the previous flush: saturation, which lag alone does not tell from OS throttling. Absent where libuv does not account the loop's time (Electron's main process runs Chromium's loop) |
| `nodejs.gc.duration{kind}` | histogram, ms | every GC pause, `kind` = `minor` / `major` / `incremental` / `weakcb` |
| `nodejs.heap.used`, `.committed`, `.limit` | gauge, By | V8 heap in use, committed (`heapTotal`), and its limit (out of memory past it) |
| `nodejs.memory.external`, `.array_buffers` | gauge, By | outside the V8 heap: C++-bound objects, (Shared)ArrayBuffers — Buffers, audio, images |
| `nodejs.handles.active` | gauge, 1 | live libuv handles and requests |
| `process.cpu.utilization` | gauge, 1 | CPU time over wall time since the previous flush (1 = one core) |
| `process.page_faults.major` | counter | page faults served from disk (memory pressure) |
| `process.context_switches.involuntary` | counter | preemptions by the kernel (CPU contention) |

`builtins: { heap_spaces: true }` adds `nodejs.heap.space.used{space}`
(the V8 heap in use per space: `old_space`, `new_space`,
`large_object_space`…, about 13 series). What a runtime lacks is not
registered. No built-in name ends in `total`, `count`, `sum` or
`bucket`: the fleet backend gives counters and histograms those
suffixes, and a gauge named so would collide — name your own gauges the
same way (`heap.committed`, not `heap.total`). Callback metrics and collectors run on the event loop at
flush; nothing keeps a short-lived script alive.

**Actions.** An app exposes functions to `3pm trigger` and to the fleet
backend (pm2's `io.action`):

```js
io.action('cache.flush', { description: 'drop the cache', timeout_ms: 5000 }, async (params, ctx) => {
  return { flushed: await cache.flush(params.all) };   // JSON, ≤ 512 KiB; a throw or a rejection is the failure
});
io.action('dump', async (params, ctx) => {             // a big result: a file
  await fs.promises.writeFile(ctx.file + '.tmp', JSON.stringify(state));
  return { bytes: await ctx.commitFile('json') };      // rename into place, name the extension
});
const off = io.action('reset', { remote: false }, () => reset());  // `3pm trigger` only; `off()` withdraws it
```

`name` is `^[A-Za-z0-9_.:-]{1,64}$`, 32 per source; a `3pm:` name
throws (the SDK's prefix); `remote` defaults to `true`; `timeout_ms`
(default 30 s, ceiling 10 min) bounds every call. The whole list is
declared right after each `hello` (`{"msg":"actions","list":[…]}`,
ahead of any queued event) and whenever it changes, once per tick, and
sent again every flush until a write of it succeeded. A call arrives as
`{"msg":"action","id","name","params","timeout_ms","file"}` and is
answered with `{"msg":"action_reply","id","ok","data?","error?",
"file?"}`; `ctx` carries `id`, `name`, `timeout_ms`, `file` (a path the
daemon prepared) and `commitFile(ext)`. Lone surrogates in a result,
an error message or a description become U+FFFD (the daemon would
refuse the line); a reply that cannot be written (over the line cap)
becomes a short `ok: false`, so the call fails instead of timing out.
Past the call's deadline `commitFile` deletes the `.tmp` and throws —
the daemon has already given up. A handler still running is not called
again (`busy`); a hung one is released at its timeout. `init()` declares
`3pm:profile` (a CPU or heap sampling profile of the process through
`node:inspector`, `{kind: cpu|heap, duration_ms: 1000..60000 (10 000),
sampling}`) unless `profile: false`: params are validated, not coerced
(an absent field takes its default), and a call whose timeout leaves
less than 15 s after the sampling fails before anything is sampled
(`3pm profile` asks for the sampling + 20 s). `3pm trigger <app>` lists
what is declared; `docs/protocol.md` has the bounds and error codes.

**Electron.** A renderer has no Unix socket, so its metrics travel over
IPC. Main process:

```js
const electron = require('electron');
const { io } = require('@3pm/api');
io.init({ source: 'electron-main', builtins: true, on_shutdown: teardown });
const bridge = io.ipcBridge(electron.ipcMain); // one control connection per renderer source
io.electron(electron);                         // Chromium processes, power, crashes, hangs
```

`io.electron(electron)` adds what only the main process sees (the
module is passed in, never required; `powerMonitor` waits for `ready`):

| Metric | Kind, unit | What |
|---|---|---|
| `electron.processes{type}` | gauge, 1 | Chromium processes by type (`Browser`, `Tab`, `GPU`, `Utility`…), one `app.getAppMetrics()` per flush |
| `electron.process.cpu{type}` | gauge, 1 | their CPU (1 = one core); a type gone drops to 0 |
| `electron.process.memory{type}` | gauge, By | their working set |
| `electron.power.on_battery` | gauge, 1 | 1 on battery, 0 on AC |
| `electron.power.thermal_state` | gauge, 1 | 1 nominal, 2 fair, 3 serious, 4 critical (where the OS reports it) |
| `electron.power.speed_limit` | gauge, % | CPU speed limit, once the OS announced one |
| `electron.process.gone{type,reason}` | counter | processes gone (`crashed`, `oom`, `killed`, `clean-exit`…) |
| `electron.unresponsive` | counter | times a window stopped responding |

and events: `electron.process_gone` (`error`, `info` for a clean exit;
type, reason, exit code), `electron.unresponsive` (`warn`) and
`electron.responsive` (`duration_ms`), `electron.power.suspend` /
`resume` / `on_battery` / `on_ac`, `electron.power.thermal_state`
(`warn` from `serious`), `electron.power.speed_limit` (`warn` under
100 %). A window is named by its id and kind, never its URL or title.
It returns an idempotent `stop()`, also run by `shutdown()`.

Renderer (or preload):

```js
const { createRendererIo } = require('@3pm/api/renderer');
const rio = createRendererIo(ipcRenderer, { source: 'renderer', builtins: true });
rio.event('view.change', { attrs: { view: 'main' } });
```

The renderer's `builtins: true` registers what the runtime offers, with
no Node builtin module: `renderer.eventloop.delay` (histogram, 100 ms
timer), `renderer.longtask.duration` (histogram, `PerformanceObserver`
long tasks), `renderer.heap.used` / `.committed` / `.limit` (Electron's
`process.getHeapStatistics()`, else Chromium's coarse
`performance.memory`), `renderer.blink.allocated` / `.committed` (Blink's
DOM, layout and style memory), `renderer.cpu.utilization`, and
`renderer.dom.nodes` (re-counted every 30 s: a tree walk).

Same surface; `3pm:metrics` carries `{source, m}` every flush,
`3pm:event` one event, `3pm:actions` the renderer's declarations. The
bridge validates what arrives, re-sends the last picture of each
renderer every second, drops entries not refreshed for 120 s, and caps
the number of sources (16); a renderer naming itself `main` or the main
process's own source is ignored (its hello would evict the main
process). Only the main process should register `on_shutdown`. With an
`ipcRenderer` that has `on`, `rio.action()` works too: the bridge
forwards calls on `3pm:action` and takes the answer on
`3pm:action_reply` (no `ctx.file` in a renderer; `shutdown()` removes
the listener). Several windows may share a source: each window's
declarations are its own (another window cannot withdraw them), a call
goes to the window that declared the action — the latest, when several
declared one name — and only that window's reply is taken; a window's
actions end when it declares again, navigates or reloads (main frame,
new document), crashes or is destroyed. The bridge declares
`3pm:profile` for every renderer source whose `webContents` has a
debugger and runs it over CDP (`Profiler` / `HeapProfiler`, each
command bounded to 15 s): only when exactly one live window holds the
source — with several, the call fails and asks for one source per
window — and never over a debugger already attached (DevTools open: an
immediate `debugger already attached`); it always detaches what it
attached. `3pm profile saw --src renderer`.

## Python — `threepm-io`

```python
import threepm_io as io

io.init(source="cv-engine")
xruns   = io.counter("cv.xruns", description="PortAudio over/underruns")
jitter  = io.histogram("cv.callback.jitter", unit="ms")
clients = io.gauge("cv.ws.clients")
io.metric("cv.queue.depth", lambda: len(queue))   # runs in the flush thread

xruns.inc()
jitter.observe(0.42)
clients.set(3)
io.event("midi.port_lost", level="warn", attrs={"port": "ES-9"})
try:
    open_device()
except Exception as e:
    io.notify_error(e, attrs={"device": "ES-9"})
```

Actions and calls, with a file in and a file out — the Node SDK's wire,
so either side may be the other language (full table:
`packages/python/README.md`):

```python
@io.action("segment", input={"max_bytes": 50_000_000, "ext": ["png", "jpg"]}, timeout_ms=60_000)
def segment(params, ctx):
    return model.run(ctx.input.path, params)      # its own thread; JSON ≤ 512 KiB, or ctx.commit_file(ext)
io.serve()                                        # block until 3pm stops the process

res = io.call("gpu-1", "vision", "segment", {"t": 0.7}, file="/tmp/capture.png")   # blocks; raises io.CallError
```

Not there yet: streams (`io.open`/`io.stream`), `input.stream`,
`3pm:profile`. `init()` without `source` is `main` (`pid-<pid>` for a
process it started), as in Node.

One daemon thread per instance writes and reads the socket; a second,
short-lived one per connection reads what the daemon sends. `metric()`
callbacks run in that thread — outside any asyncio loop, so read a plain
attribute or a `len()`, not a coroutine. `threepm_io.Io()` gives an
independent instance (another `source` in the same process). A
short-lived script should `io.flush(wait_ms=500)` before exiting.

## C++ — `threepm_io.hpp`

```cpp
#include <threepm_io.hpp>
using namespace threepm::io;

Io& io = instance();
InitOptions opts; opts.source = "plugin-host"; opts.on_shutdown = [] { request_stop(); };
io.init(opts);

auto late    = io.counter("host.late_callbacks");
HistogramOptions h; h.unit = "ms";
auto latency = io.histogram("host.callback.latency", h);
auto state   = io.gauge("host.state", {"", "0 idle, 1 loading, 2 running, 3 error", {}});

late.inc();                 // on the audio thread: atomic add, no lock, no allocation
latency.observe(0.42);      // exponent/mantissa lookup, no libm
EventOptions ev; ev.level = "warn"; ev.attrs.add("port", "ES-9");
io.event("midi.port_lost", ev);
io.notify_error("HostError", last_error, Attrs().add("plugin", name));
```

`set`, `inc` and `observe` are lock-free atomic operations on
preallocated storage (`observe` and `inc` add to a double by CAS: one
writer per series stays at a few nanoseconds, eight writers on the same
series cost microseconds — give each thread its own series). Creating a
metric or calling `.labels()` takes the registry mutex: do it at setup,
cache the handles, never on the audio thread. A histogram owns a fixed
array of 128 slots at its scale (window above). One `std::thread` per
instance does the I/O; `on_shutdown` and `metric()` callbacks run on it,
and may call `shutdown()`. The destructor flushes and joins.

## Rust — `threepm-io`

```rust
use serde_json::{json, Value};
use threepm_io::{io, Action, ActionCtx, Event, Init, Level, Metric};

io().init(Init::new().source("worker").on_shutdown(|| stop.store(true, Relaxed)));

let requests = io().counter("http.requests", Metric::new().label("method", "GET"));
let latency  = io().histogram("http.latency", Metric::new().unit("ms"));
let temp     = io().gauge("room.temp", Metric::new().unit("Cel"));
io().metric("queue.depth", Metric::new(), move || read.len() as f64);

requests.labels(&[("status", "200")]).inc(1.0);   // the step is explicit
latency.observe(elapsed_ms);
temp.set(21.5);
io().event("deploy", Event::new().level(Level::Warn).attr("version", "1.2"));
io().notify_error(&err, Event::new().attr("where", "worker"));

io().action("cache.flush", Action::new().timeout_ms(5_000),
    |params: Value, _: &ActionCtx| -> Result<Value, String> {
        Ok(json!({ "flushed": cache.flush(&params["prefix"]) }))
    });
io().serve();     // a worker whose only job is to answer actions
```

`io()` is the process's instance; `Io::new()` makes another. One thread
per instance writes and reads the socket, plus one per connection and one
per call in flight; `metric` callbacks and `on_shutdown` run on those
threads, so read a value there and do not block. `Series` is
`Clone + Send + Sync`: create the handles at startup and keep them.
Nothing returns a `Result` or panics on telemetry — a metric that cannot
exist comes back as an inert handle (`enabled() == false`).

Actions carry the same contract as Node's (`ctx.file` + `ctx.commit_file(ext)`
for a result too big for one line, `remote`, `timeout_ms`, `concurrent`,
`output_max_bytes`, a local `ctx.input`), with the handler's error type
being anything that prints. **Not implemented yet**: `io.call`, streams,
blob inputs and outputs (everything on `data.sock`), the built-in
`3pm:profile`, and Node's `builtins` runtime metrics. Differences,
bounds and the full surface: `packages/rust/README.md`.

## Writing your own client

The wire is plain NDJSON on `$THREEPM_CTL_SOCK` (`docs/protocol.md`,
"Control channel"): `{"msg":"hello","id":$THREEPM_ID,"gen":$THREEPM_GEN,
"source":"worker"}` first, then `{"msg":"metrics","m":[…]}` every second
and `{"msg":"event",…}` when something happens. `examples/metrics.py` is
the reference client in raw Python, histogram index included.

## Tests

`just sdks` runs the four suites — Node (`node --test`), Python
(`pytest`), C++ (`ctest`), Rust (`cargo test` + `clippy`) — each against a
fake control socket (hello and source, batches, events, reconnection and
backoff, the five-refusal give-up, the full outbound buffer, `shutdown`,
label overflow) and each replaying `packages/exphist-vectors.json`; it
also builds the C++ and Rust e2e clients.
`crates/threepm-cli/tests/e2e_io.rs` then starts them under a real daemon
and checks `show --json` and `events -f app`
(`cargo nextest run -p threepm-cli --test e2e_io`, part of `just e2e`);
`e2e_actions.rs` calls the Rust client's actions with `3pm trigger`.
