# threepm_io.hpp

Application metrics and events for a process started by
[3pm](https://github.com/Unitech/3pm) — pm2's `io.metric` / `io.counter` /
`io.notify_error`, for C++17. Header-only, POSIX sockets, no JSON library,
no dependency.

```cmake
include(FetchContent)
FetchContent_Declare(threepm_io
  URL https://3pm.edgecr4ft.net/sdk/cpp/threepm-io-cpp-0.64.0.tar.gz
  URL_HASH SHA256=<sha256>)                 # in https://3pm.edgecr4ft.net/sdk/sdk.json
FetchContent_MakeAvailable(threepm_io)      # or add_subdirectory(3pm/packages/cpp), or copy include/
target_link_libraries(plugin_host PRIVATE threepm::io)
```

The tests build only when this directory is the top-level project, never
inside yours (`-DTHREEPM_IO_TESTS=ON` forces them).

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

Io& io = instance();                         // or your own `Io` (one per source)
InitOptions opts; opts.source = "plugin-host";
io.init(opts);

auto late    = io.counter("host.late_callbacks");
auto latency = io.histogram("host.callback.latency", [] { HistogramOptions h; h.unit = "ms"; return h; }());
auto state   = io.gauge("host.state", {"", "0 idle, 1 loading, 2 running, 3 error", {}});
io.metric("host.queue.depth", [&] { return double(queue.size()); });

late.inc();                                  // lock-free: fine on the audio thread
latency.observe(0.42);                       // no libm, no allocation, no lock
state.set(2);
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));
```

Then `3pm show <app>` (section "Custom metrics") and `3pm events -f app`.

Outside 3pm (no `THREEPM_CTL_SOCK` in the environment) every call is a
no-op. Nothing blocks the caller: one `std::thread` connects, sends the
whole registry every second, reconnects with backoff (1 s → 30 s) and
gives up after five refused `hello`s (the process outlived its
incarnation). Events raised before the connection is up are queued (64).
The destructor (or `shutdown()`) flushes and stops the thread.

| Call | Notes |
|---|---|
| `init(InitOptions{source, flush_ms, on_shutdown})` | Idempotent; `on_shutdown` runs (on the SDK thread) when the daemon asks for a graceful stop |
| `gauge` / `counter` / `meter(name, MetricOptions{unit, description, labels})` | Same name + labels = same series; `.labels({{k, v}})` derives a child; counters are cumulative; past 50 label sets a metric folds into `overflow="true"` |
| `histogram(name, HistogramOptions{…, scale, min_exp})` | Base-2 exponential, `scale` 0..4 (default 1). Fixed array of 128 buckets (`HISTOGRAM_SLOTS`, the daemon's cap) starting at `2^min_exp`; by default the window is centred on 1: scale 0 → [2^-64, 2^64), 1 → [2^-32, 2^32) (~2e-10 … 4e9), 2 → [2^-16, 2^16), 3 → [2^-8, 2^8), 4 → [2^-4, 2^4). Values outside fold into the edge buckets — that is what makes `observe` lock-free |
| `metric(name, fn, opts)` | Gauge by callback, evaluated on the SDK thread at every flush; it may use the registry, `flush()` or `shutdown()` (a nested flush renders the series only) |
| `event(name, EventOptions{level, attrs, exc, fp})` | `Attrs().add(key, string\|double\|any integer\|bool)`, ≤ 16 keys, a repeated key replaces; strings are made valid UTF-8 |
| `notify_error(const std::exception&, attrs)` / `notify_error(type, message, attrs, name, stacktrace)` / `notify_error(…, EventOptions, name)` | `error`-level event with `exc.{type,message,stacktrace}` (the type demangled); the `EventOptions` form reaches `level`, `fp` and the trace |
| `flush(wait_ms)`, `shutdown(wait_ms)`, `stats()` | `flush(wait_ms)` waits for the connection, then for the bytes — for a short-lived process; `shutdown` is `flush(wait_ms)` then stop, and is safe from `on_shutdown` |

`set`, `inc` and `observe` are lock-free atomic operations on preallocated
storage (`inc`/`observe` are a CAS loop: one writer per series for
real-time use — measured 7 ns alone, 2 µs with eight writers contending):
call them from a real-time thread once per callback, not once per sample.
`gauge()`, `counter()`, `histogram()`, `metric()` and `.labels()` take the
registry mutex: create the handles up front and keep them. `metric()`
callbacks and `on_shutdown` run on the SDK thread.
Full reference, bounds and the other languages: `docs/app-metrics.md` in
the 3pm repository. Tests: `cmake -S . -B build && cmake --build build &&
(cd build && ctest)`.
