The ops endpoint
health() answers may I take traffic? in a status code. ops() answers
what is going on in here? in a body — which is why it is authenticated and that one is
not.
import { ops } from '@sigx/actors/host';
import { clusterStats } from '@sigx/actors/cluster';
export const app = defineActorApp({ actors, storage })
.use(metrics())
.use(health())
.use(ops({
secret: process.env.SIGX_OPS_SECRET,
cluster: (signal) => clusterStats(placement, { signal }),
}));
| Route | Returns |
|---|---|
GET /_sigx/ops | OpsSnapshot — { v, at, uptimeMs, stats, health, ops } |
GET /_sigx/ops/cluster | a clusterStats() fan-out; 404 when unwired |
The secret is mandatory
ops() throws at construction without one outside __DEV__.
An ops endpoint that is unauthenticated by omission publishes your deployment, and nothing in the response says so. Actor type names, cluster topology and — if you ask for them — actor keys.
SIGX_OPS_SECRET is the conventional way to supply it, and it is what the
CLI reads.
Why the cluster fan-out is a thunk
ops() lives in @sigx/actors/host and clusterStats in @sigx/actors/cluster. Passing the
call as a thunk keeps the cluster bundle out of a single-node host that will never use it:
ops({ secret, cluster: (signal) => clusterStats(placement, { signal }) });
ops({ cluster }) also takes an optional second argument — the parsed query — so
GET /_sigx/ops/cluster?detail=1&activations=20&host=<id> reaches through. The one-argument
form still works.
Contributing a section
registry.reportOps('queue', () => ({ depth: queue.length, oldestMs: queue.oldestAge() }));
registry.ops(); // the aggregate
Providers run per read and must stay synchronous. Names must be unique. A throwing
provider costs only its own section, which comes back as { error } — one broken provider
never takes down the endpoint.
reportDigest(name, (options?) => …) is the mergeable sibling, used by
cluster stats to fold metrics across hosts.
Actor keys are off by default
The activation list is not in the default response, and requested limits are clamped by the responder rather than trusted from the query.
Actor keys can be personal data — a user id, an email, an order number. Turning the list on is a deliberate act, over an authenticated channel, and it is worth knowing what your keys contain before you do.
Reaching ops() from a browser
Two facts drive everything here:
ops()sets no CORS headers. A browser cannot call it cross-origin at all, however the fetch is configured.- It refuses to construct without a bearer secret outside
__DEV__, because it reports actor type names, traffic shape and cluster topology — and the activation list carries actor keys, which are user data.
The conclusion follows: the browser calls a same-origin route of your own app, which authenticates the operator however the app already does and forwards to the host with the bearer attached server-side. The secret never reaches the client.
Work around the CORS block by putting the secret in browser code and you have published your cluster topology to every visitor — and nothing in the browser will tell you.
The whole server half:
// GET /admin/ops → the host snapshot
// GET /admin/ops/cluster → the fan-out
if (url.pathname === '/admin/ops' || url.pathname.startsWith('/admin/ops/')) {
if (!(await isOperator(request))) return new Response('no', { status: 403 });
return fetch(HOST_ORIGIN + url.pathname.replace('/admin/ops', '/_sigx/ops') + url.search, {
headers: { authorization: `Bearer ${process.env.OPS_SECRET}` }
});
}
and the browser half is httpSource({ url: location.origin, base: '/admin/ops' }) from
@sigx/actors-monitor — with no secret —
or <ActorsDashboard source={…} /> from
@sigx/actors-dashboard over it.
Two details fail silently and are worth getting right the first time:
- Forward the sub-path and the query verbatim.
httpSourceappends/clusterand?detail=1&host=…itself. A proxy that drops the path turns the cluster view into a 404; one that drops the query leaves every per-host drill-down waiting for a detail poll forever, with no error anywhere. - Match the mount exactly, or a
/or?after it.startsWith('/ops')also matches/opsummary, and forwards your bearer to a URL nobody meant to build.
Forward the method too — ops() serves GET and HEAD and answers 405 to anything else —
and pass through www-authenticate and allow, which carry the diagnosis when a poll fails:
a 401 from the host means the proxy's secret is stale, not that the host is down.
In a cluster, the proxy needs any one surviving host — the fan-out reaches the rest — so
a list of candidate origins tried in order is more robust than a single address. The
examples/dashboard directory of the actors repo has the production-shaped version, with
its tests.
Next steps
- Cluster stats — the fleet-wide view.
- The CLI — a terminal dashboard over this endpoint.
- The web dashboard — the same five tabs in a browser.
- Observability — Prometheus alongside it.
