Observability
@sigx/actors-otel turns the host's
own metrics into something your monitoring stack already speaks — without making OpenTelemetry
a dependency of anything that does not want it.
pnpm add @sigx/actors-otelPrometheus
The /prometheus entry imports no OpenTelemetry at all:
import { prometheusOps } from '@sigx/actors-otel/prometheus';
app.use(metrics()).use(prometheusOps({ secret: process.env.SIGX_OPS_SECRET }));
Mounts GET /_sigx/metrics beside ops(), with the same bearer
posture — the secret is mandatory outside dev.
scrape_configs:
- job_name: sigx-actors
metrics_path: /_sigx/metrics
authorization: { credentials_file: /etc/prometheus/sigx-ops-secret }
static_configs: [{ targets: ['actors-host:7311'] }]
Options: path (default /_sigx/metrics), secret, prefix (default sigx_actors_), and
bucketsSeconds — a per-octave grid from 1µs to ~134s, about 28 bounds.
renderPrometheus(digest, stats, options) is the pure function underneath, if you would
rather serve it yourself. Among what it emits is {prefix}one_way_failures_total — a
one-way call that failed after acceptance has nowhere else to
be reported, so this counter is the only place it surfaces in Prometheus.
The cardinality rule: labels are
typeandmethod— never actor keys. An actor key is unbounded by construction, and a key-labelled metric will take your Prometheus down. This is enforced, not advisory.
metrics().reset()breaks monotonicity. Prometheus counters are expected only ever to increase; areset()looks like a counter restart. That is survivable —rate()handles restarts — but do not wirereset()to anything periodic.
OpenTelemetry traces
import { otelTraces } from '@sigx/actors-otel';
app.use(otelTraces({ turnSpans: true }));
One CLIENT span per dispatch and one SERVER span per turn, joined across hosts by the
W3C traceparent the runtime propagates: captured from the public endpoint header and carried
on the cluster envelope.
Span attributes use a key hash, not the key, for the same reason the routing token does — and with the same caveat that a hash is log hygiene, not privacy.
@opentelemetry/api is an optional peer dependency. With no tracer provider registered
the plugin is inert and costs nothing.
The metrics bridge
import { otelMetricsBridge } from '@sigx/actors-otel';
app.use(otelMetricsBridge({ percentileGauges: true }));
Feeds the host's counters and histograms into whatever OTel meter provider you have
configured, for stacks that collect metrics over OTLP rather than by scraping — including
sigx.actors.calls.one_way_failures, the OTLP spelling of the counter above.
Socket sessions
Socket connections are counted separately, because none of the things worth watching about them — how many are open, how long they live, how many were refused — are visible to the turn metrics.
socketStats() is one recorder per host or listener, handed to every session:
import { socketStats } from '@sigx/actors/server';
const stats = socketStats();
attachActorSocket(server, { host, stats });
Publish it as an ops section, and it appears under ops.sockets behind the existing
bearer posture — no endpoint changes:
registry.reportOps('sockets', () => stats.snapshot());
snapshot() carries the totals — connections opened, closed and refused (origin or auth,
turned away before serving a byte); calls started and failed; subscriptions opened and closed;
protocolBreaches (1003/1009) and lifetimeCloses (1008 from revalidateMs or
maxConnectionMs) — plus three live gauges summed across open sessions (open, inFlight,
subscriptions) and a connection-lifetime histogram, null until there is data.
Four more fields describe live delivery:
| Field | What it counts |
|---|---|
deliveries | {i,v} frames pushed for a live subscription — not call results, not stream chunks |
deliveryBytes | outbound bytes for those frames, as UTF-16 code units — exact for ASCII, under-reports otherwise, deliberately: no UTF-8 pass on the hot path |
throttleQuantized | subscriptions whose requested throttleMs the server's policy rounded |
bufferedBytes | the host's own send-buffer depth, summed across open sessions — number | null |
deliveries is the number to watch. A delivery is ~77% socket write, one write syscall
per subscriber, so it is what a fan-out host's cost is proportional to. Pair it with open
and subscriptions: the same subscription count on fewer connections measured 6–10× cheaper
per delivery, so subscriptions per connection is a ratio worth showing.
bufferedBytes: null is not zero. It means no open session
could report one — a custom adapter that does not supply the seam, or no open sessions at
all. Reporting it as 0 would say "the hosts are not buffering", which is a claim nobody has
measured. Same rule lifetimeMs follows.
It is a gauge, and it is polled: a burst between two scrapes is invisible, so it under-reports
peaks the way open does. The Node adapter supplies it automatically from the socket's
bufferedAmount; a custom adapter passes bufferedBytes() to
createActorSocketSession if its transport can report a queue
depth, or accepts that the host cannot report its own backpressure.
stats.digest() is the mergeable form, in the same log-linear layout as every other
histogram, so a fleet-wide view merges the way cluster stats
does.
Prometheus, OTel and CLI rendering are not wired to this yet. The ops section is the way to read socket stats today.
Choosing
| You have | Use |
|---|---|
| Prometheus / VictoriaMetrics / Grafana Agent | prometheusOps() — no OTel dependency |
| An OTLP collector | otelMetricsBridge() + otelTraces() |
| Neither, and a terminal | the CLI dashboard |
| A custom stack | read ops() directly |
Core plumbing this rides on: ActorCallContext.traceparent, the trailing call parameter on
ActorTurnObserver, and bucketUpperBoundsUs() / timingSafeEquals from
@sigx/actors/host.
Next steps
@sigx/actors-otel— the package reference.- Metrics — what is being exported.
- Cluster stats — the fleet-wide view.
