feat(otel): one path out of the hive, not two

mara: 'there should only be the one via otel-collector'. Collapses
collector.enable away - enabling otel runs the collector, and agents
always export to it.

The argument for it is the same one the collector exists for: as long
as the direct path stays selectable, the credential-in-settings.json
hole stays selectable. An option that can reintroduce a hole is a hole
with extra steps. HYPERHIVE_OTEL_HEADERS_CREDENTIAL is now never
forwarded to containers at all rather than conditionally.

Two consequences, both deliberate:

- the enable option's own promise changes. It said each harness exports
  directly so telemetry survives anything host-side being down; that is
  now false for every hive rather than for opted-in ones. Stated in the
  option text rather than left for a reader to discover.
- the collector-requires-headersCredential assertion is gone. It was
  correct only under the shape it was written for: when the collector
  was opt-in FOR the credential, opting in without one was pure
  indirection. With one path it isn't - an upstream needing no auth
  header is legitimate, and the assertion would have rejected it. The
  upstream header block is now omitted entirely in that case, rather
  than rendering an ${env:...} reference nothing sets.
This commit is contained in:
atlas 2026-08-15 10:21:09 +02:00 committed by mara
commit 9549cdf9bd
2 changed files with 41 additions and 74 deletions

View file

@ -81,41 +81,31 @@ in
# don't render no-op env lines. # don't render no-op env lines.
let let
otel = config.services.hyperhive.otel; otel = config.services.hyperhive.otel;
# `otel.endpoint` keeps meaning "where telemetry ultimately goes",
# on every hive, whether or not a collector runs. What changes with
# a collector is only where the *first* hop lands — so the
# agent-facing value is DERIVED here rather than by redefining the
# option. Redefining it would migrate the meaning of a value every
# existing hive already has set, silently, while still evaluating.
# With the collector off this expression is `otel.endpoint`, i.e.
# byte-identical to before the collector existed.
collectorOn = otel.collector.enable;
in in
{ {
HYPERHIVE_OTEL_ENDPOINT = # `otel.endpoint` means "where telemetry ultimately goes" and keeps
if collectorOn then # that meaning; what agents are handed is the *first hop*, which is
"http://${config.services.hyperhive.network.bridgeIp}:${toString otel.collector.port}" # always this hive's own collector. Deriving it rather than
else # redefining `endpoint` is what lets every existing deployment keep
otel.endpoint; # its configured value untouched.
HYPERHIVE_OTEL_ENDPOINT = "http://${config.services.hyperhive.network.bridgeIp}:${toString otel.collector.port}";
# The first hop is the collector's OTLP/HTTP receiver, which speaks # The first hop is the collector's OTLP/HTTP receiver, which speaks
# protobuf regardless of what the upstream wants — `otel.protocol` # protobuf regardless of what the upstream wants — `otel.protocol`
# describes the *upstream* link, and the collector's own exporter is # describes the *upstream* link, and the collector's own exporter is
# what has to honour it (see nix/host-modules/otel.nix). # what has to honour it (see nix/host-modules/otel.nix).
HYPERHIVE_OTEL_PROTOCOL = if collectorOn then "http/protobuf" else otel.protocol; HYPERHIVE_OTEL_PROTOCOL = "http/protobuf";
} }
// lib.optionalAttrs (otel.extraResourceAttributes != "") { // lib.optionalAttrs (otel.extraResourceAttributes != "") {
HYPERHIVE_OTEL_EXTRA_RESOURCE_ATTRIBUTES = otel.extraResourceAttributes; HYPERHIVE_OTEL_EXTRA_RESOURCE_ATTRIBUTES = otel.extraResourceAttributes;
} }
// lib.optionalAttrs (otel.headersCredential != null && !collectorOn) { # HYPERHIVE_OTEL_HEADERS_CREDENTIAL is deliberately NOT emitted, and
# This is the variable that puts the upstream token in an agent's # its absence is the security half of this design. It is the variable
# own `~/.claude/settings.json` (host_config.rs forwards it into the # that put the upstream token in an agent's own settings.json:
# container as an nspawn credential; claude-settings.nix's # host_config.rs forwards it into the container as an nspawn
# `hive-otel-header` oneshot then writes it into a file the agent # credential, and claude-settings.nix's `hive-otel-header` oneshot
# can read). Not emitting it is what actually removes the token from # then writes the value into a file the agent can read. The collector
# agent containers — the collector holding the credential is only # holding the credential achieves nothing while the harness keeps
# half of it, and the half that is invisible in a diff. # handing out a copy — so there is exactly one holder, on the host.
HYPERHIVE_OTEL_HEADERS_CREDENTIAL = otel.headersCredential;
}
// lib.optionalAttrs (otel.metricIntervalMs != null) { // lib.optionalAttrs (otel.metricIntervalMs != null) {
HYPERHIVE_OTEL_METRIC_INTERVAL_MS = toString otel.metricIntervalMs; HYPERHIVE_OTEL_METRIC_INTERVAL_MS = toString otel.metricIntervalMs;
} }

View file

@ -16,9 +16,19 @@
enable = lib.mkEnableOption '' enable = lib.mkEnableOption ''
hive-wide export of every agent's Claude Code stats (token usage, hive-wide export of every agent's Claude Code stats (token usage,
cost, tool calls) to an OTLP endpoint via Claude Code's built-in cost, tool calls) to an OTLP endpoint via Claude Code's built-in
OpenTelemetry. One switch for all agents; each harness exports OpenTelemetry. One switch for all agents.
directly to the collector, so it keeps working even when hive-c0re
is down Enabling this also runs a collector on the host: there is exactly
one way telemetry leaves this hive, and it is through that
collector. Agents export unauthenticated to a bridge address only
their own containers can reach, and the collector is the only
holder of the upstream credential an agent never sees it.
The collector is therefore in the path of all telemetry. It runs
on the same host as the agents and restarts on failure, and
telemetry is not the control plane, so degraded telemetry is not
degraded operation but the export no longer survives independently
of anything host-side
''; '';
endpoint = lib.mkOption { endpoint = lib.mkOption {
@ -88,31 +98,6 @@
''; '';
}; };
collector.enable = lib.mkOption {
type = lib.types.bool;
default = false;
description = ''
Run an OpenTelemetry collector on this host and have agents
export to it instead of straight to `endpoint`.
The point is the credential. Without this, every agent needs
`headersCredential` in order to talk to the upstream and the
harness delivers it into the agent's own `settings.json`, where
the agent can read it. With a collector the token stops at the
host: the collector holds it, agents send unauthenticated to a
bridge address only their own containers can reach.
It also makes the collector a dependency in the export path.
Today each harness exports directly, so telemetry survives
anything host-side being down. That property is traded for the
credential reduction; the collector is on the same host as the
agents, so the window is small, but it is not zero.
Off by default, and off means *absent*: no unit, no port, and
`endpoint` keeps its current meaning for every agent.
'';
};
collector.upstreamHeaderName = lib.mkOption { collector.upstreamHeaderName = lib.mkOption {
type = lib.types.str; type = lib.types.str;
default = "Authorization"; default = "Authorization";
@ -176,7 +161,7 @@
]; ];
}) })
(lib.mkIf (config.services.hyperhive.otel.enable && config.services.hyperhive.otel.collector.enable) (lib.mkIf config.services.hyperhive.otel.enable (
( (
let let
otel = config.services.hyperhive.otel; otel = config.services.hyperhive.otel;
@ -192,31 +177,19 @@
upstreamName = if grpcUpstream then "otlp" else "otlphttp"; upstreamName = if grpcUpstream then "otlp" else "otlphttp";
upstream = { upstream = {
endpoint = otel.endpoint; endpoint = otel.endpoint;
}
// lib.optionalAttrs (otel.headersCredential != null) {
# The value is interpolated by the collector at runtime from # The value is interpolated by the collector at runtime from
# its environment, never by nix. `EnvironmentFile` below is # its environment, never by nix. `EnvironmentFile` below is
# what puts it there. # what puts it there. No credential configured means no
# header at all — an upstream that needs no auth is a
# legitimate deployment, and rendering `${env:…}` for a
# variable nothing sets would send the literal.
headers.${otel.collector.upstreamHeaderName} = "\${env:${otel.collector.upstreamHeaderName}}"; headers.${otel.collector.upstreamHeaderName} = "\${env:${otel.collector.upstreamHeaderName}}";
} }
// lib.optionalAttrs (otel.protocol == "http/json") { encoding = "json"; }; // lib.optionalAttrs (otel.protocol == "http/json") { encoding = "json"; };
in in
{ {
assertions = [
{
# The collector's whole purpose is to hold the credential so
# agents do not have to. With none configured it is pure
# indirection, and the operator has almost certainly not got
# the deployment they think they have.
assertion = otel.headersCredential != null;
message = ''
services.hyperhive.otel.collector.enable is true but
otel.headersCredential is null. The collector exists to be
the only holder of the upstream credential; with no
credential it just forwards, and every agent's exporter
would be unauthenticated end to end.
'';
}
];
# Reachable from agent containers and nowhere else: this opens # Reachable from agent containers and nowhere else: this opens
# the port on the bridge interface only. # the port on the bridge interface only.
services.hyperhive.network.exposeHostPorts = [ otel.collector.port ]; services.hyperhive.network.exposeHostPorts = [ otel.collector.port ];
@ -244,9 +217,13 @@
# systemd's EnvironmentFile format — so the secret reaches the # systemd's EnvironmentFile format — so the secret reaches the
# process as an environment variable without ever being read by # process as an environment variable without ever being read by
# nix, written to the store, or passed in argv. # nix, written to the store, or passed in argv.
systemd.services.opentelemetry-collector.serviceConfig.EnvironmentFile = otel.headersCredential; systemd.services.opentelemetry-collector.serviceConfig =
lib.optionalAttrs (otel.headersCredential != null)
{
EnvironmentFile = otel.headersCredential;
};
} }
) )
) ))
]; ];
} }