Watch
0
0
Fork
You've already forked hyperhive
0
hyperhive/nix/agent-modules/otel.nix

298 lines
13 KiB
Nix

# OpenTelemetry wiring for an agent container: the per-agent options (on by
# default, exporting to the hive's collector by name) plus
# `debug`, which the agent's own config sets, the
# environment every OTLP producer inside the container reads, and the
# collector that forwards this container's journal to the hive.
#
# Two directions, one endpoint. Metrics are PUSHED by processes in here
# that speak OTLP themselves; logs are not pushed by anyone, because
# nothing in a journal has an SDK. The forwarder is what turns the second
# into the first, and it has to run inside the container — a hive-level
# reader cannot see this journal at all (the host-side per-container
# directory is an id-mapped bind mount journald never writes into).
#
# Claude Code is one producer here, not the owner. `hive-metric` — and
# any future in-container exporter — reads the same endpoint, protocol
# and resource labels, so they are container environment rather than
# claude settings. Claude's own switches (its telemetry master flag,
# which signals it emits) stay in `claude-settings.nix`, which reads the
# options declared below.
{
lib,
pkgs,
config,
...
}:
let
cfg = config.services.hyperhive.agent.otel;
userName = config.services.hyperhive.agent.user.name;
# Swarm display name, read from the per-agent option meta.rs renders
# (NOT from `environment.variables`, which carries it at *runtime*
# only). `null` means the swarm is unnamed; "unknown" is then an honest
# label rather than a guess.
swarmDisplayName =
if config.services.hyperhive.agent.swarmName == null then
"unknown"
else
config.services.hyperhive.agent.swarmName;
# Resource labels every producer in this container stamps on what it
# emits. `service.name` names the container's role, not one binary
# inside it: claude's samples and `hive-metric`'s both come from this
# agent, and their metric names already tell them apart. No `hive`: the
# swarm collector stamps it from the receiver that accepted the hive's
# push (nix/host-modules/swarm-otel.nix), so the build names no hive.
resourceAttributes =
"service.name=hyperhive-agent,agent=${userName},swarm=${swarmDisplayName}"
+ lib.optionalString (cfg.extraResourceAttributes != "") ",${cfg.extraResourceAttributes}";
# The variables an OTEL SDK reads on its own, in any language, in any
# process here.
#
# There is no auth header among them, and no mechanism to add one. An
# agent exports to the hive's own collector, which is the only thing
# holding a credential for anything upstream; nothing an agent can read
# is a secret to the swarm. Forwarding the operator's upstream token into
# this container and merging it into the agent's own
# `~/.claude/settings.json` would hand every agent the hive's credential.
producerEnv = {
OTEL_EXPORTER_OTLP_ENDPOINT = cfg.endpoint;
OTEL_EXPORTER_OTLP_PROTOCOL = cfg.protocol;
# Force CUMULATIVE temporality — Claude Code defaults to DELTA,
# which Prometheus/Mimir-family backends (incl. grafana-lgtm)
# silently drop without a deltatocumulative processor.
OTEL_EXPORTER_OTLP_METRICS_TEMPORALITY_PREFERENCE = "cumulative";
OTEL_RESOURCE_ATTRIBUTES = resourceAttributes;
};
# The exporter's NAME picks the wire protocol, so `protocol` has to be
# read here as well as handed to the SDKs above — same expression as the
# swarm tier's upstream exporter in nix/host-modules/swarm-otel.nix.
logExporter = if cfg.protocol == "grpc" then "otlp" else "otlphttp";
in
{
# On by default: every hive runs a collector and answers
# `hive-collector.hive` with its own bridge address, so the endpoint below
# is the same string in every agent on every hive and nothing is rendered
# into the meta flake. The port is the literal the host fixes at
# `services.hyperhive.swarm.hiveOtel.collectorPort` (read-only there).
# Marked `internal`: these are not meant to be set in an agent.nix, except
# `debug`.
options.services.hyperhive.agent.otel = {
enable = lib.mkOption {
type = lib.types.bool;
default = true;
internal = true;
description = ''
Export this agent's telemetry — Claude Code's stats (token
usage, cost, tool calls) and anything pushed with
`hive-metric` — to the hive's collector. Each agent exports
directly to it, so it keeps working even when hive-c0re is
down.
'';
};
endpoint = lib.mkOption {
type = lib.types.str;
default = "http://hive-collector.hive:4318";
internal = true;
description = ''
OTLP collector endpoint, set as `OTEL_EXPORTER_OTLP_ENDPOINT`.
The name is the one `services.hyperhive.otel.collector.host`
holds, which the hive's resolver answers.
'';
};
protocol = lib.mkOption {
type = lib.types.enum [
"http/protobuf"
"http/json"
"grpc"
];
default = "http/protobuf";
internal = true;
description = ''
OTLP wire protocol, set as `OTEL_EXPORTER_OTLP_PROTOCOL`.
The hive collector's receiver speaks OTLP/HTTP protobuf, so
the default is the only value that reaches it.
'';
};
extraResourceAttributes = lib.mkOption {
type = lib.types.str;
default = "";
internal = true;
description = ''
Extra comma-separated entries appended to
`OTEL_RESOURCE_ATTRIBUTES` after the built-in
`service.name` / `agent` / `swarm` labels.
Host-driven via
`services.hyperhive.deploy.hive-controller.otel.extraResourceAttributes`.
'';
};
debug = lib.mkOption {
type = lib.types.bool;
default = false;
description = ''
Emit claude's OTEL SDK diagnostics to stderr
(`CLAUDE_CODE_OTEL_DIAG_STDERR=1`). Set it in this agent's own
config while troubleshooting its collector connection; it is noisy,
so leave it off otherwise. Only takes effect when `enable` is true.
'';
};
};
config = lib.mkIf cfg.enable {
# Two assignments, because they cover disjoint sets of processes and
# neither implies the other:
#
# - `systemd.globalEnvironment` is merged into the `Environment=`
# lines of every generated unit, so the harness, the
# bash/matrix/MCP daemons and everything the bash tool spawns
# beneath them all get it. This is the assignment that matters:
# those daemons are claude's *siblings*, not its children, so
# nothing shipped inside claude's own settings could ever reach
# them, and `hive-metric` invoked from a tool call exited with
# "OTEL_EXPORTER_OTLP_ENDPOINT not set".
# - `environment.variables` lands in `/etc/set-environment`, sourced
# by `/etc/profile` — login shells, i.e. `hivectl shell` and
# `hivectl choom`, which systemd does not start.
#
# `NIX_REMOTE` in `default.nix` is set both ways for this same
# reason. The difference is visible on any running agent:
# `HIVE_ASSETS_DIR` is an `environment.variables` entry and is absent
# from `systemctl show hive-bash-daemon.service -p Environment`,
# while `NIX_REMOTE` — the same value, also set globally — is there.
systemd.globalEnvironment = producerEnv;
environment.variables = producerEnv;
# The journal this container writes is reachable from inside it and
# from nowhere else, which is why the forwarder runs here rather than
# the hive reading the container's journal from outside: the host-side
# per-container directory is an id-mapped bind mount and journald
# writes nothing into it.
assertions = [
{
# Sibling of the swarm collector's storage assertion in
# nix/host-modules/swarm-otel.nix, and it exists because the
# failure is silent at every layer: with a volatile journal the
# receiver below finds an empty directory, reads nothing, and the
# collector starts clean and stays healthy forever.
assertion =
!(lib.elem config.services.journald.storage [
"volatile"
"none"
]);
message = ''
services.hyperhive.agent.otel.enable is on for agent ${userName}, but
services.journald.storage is
"${config.services.journald.storage}" in this container.
The log forwarder reads /var/log/journal, which journald only
writes when it stores persistently: with "volatile" the journal
lives in /run/log/journal and with "none" there is none at all.
Either way the forwarder would ship nothing while looking
healthy.
'';
}
];
services.opentelemetry-collector = {
enable = true;
# Contrib, and not a preference: `journald` is a contrib receiver.
# The upstream default build has no way to read a journal at all.
package = pkgs.opentelemetry-collector-contrib;
# Runs `otelcol validate` at build time. ⚠️ A parser, not a wiring
# check — it accepts a pipeline naming a component the build lacks,
# and the collector then dies at startup. Same caveat as both other
# tiers; see nix/host-modules/otel.nix for the measurement.
validateConfigFile = true;
settings = {
# `journalctl --follow --lines=0` (what this receiver runs under
# the hood) ships only what's written *after* it starts — every
# collector start has a silent gap at the front. `storage:` below
# persists the read cursor, so a restart resumes from where it
# left off instead of re-opening that gap; `start_at` stays `end`
# (its default) because a cursor already covers every run after
# the first, and `beginning` without one would re-ship the whole
# journal on every restart. The cursor lives in the unit's own
# `StateDirectory` (nixpkgs' module already sets one, `%S`, below
# as `WorkingDirectory` too) — container-lifetime, not the
# host-persisted state bind mount: it indexes /var/log/journal,
# itself fresh on every container recreate, so the two must share
# a lifetime or the cursor outlives the journal it points into.
# `create_directory` matters here for a build-time-vs-runtime
# reason, not a convenience one: `validateConfigFile` above runs
# `otelcol validate` in the nix build sandbox, before systemd's
# `StateDirectory=` has ever created this path. Without it the
# validator refuses with "directory must exist" and the build fails.
extensions.file_storage = {
directory = "/var/lib/opentelemetry-collector";
create_directory = true;
};
receivers.journald = {
# ⚠️ STATED, and it must stay stated: the receiver's own default
# is the RUNTIME journal (`/run/log/journal`), which in an agent
# container is empty — journald stores persistently here, so
# every entry is under /var/log/journal. Dropping this line
# leaves a collector that validates, starts, reports healthy and
# forwards nothing.
directory = "/var/log/journal";
storage = "file_storage";
# No `units` allowlist, and no user-session filter like the swarm
# tier's: a container's journal is the harness and what the
# harness spawns, so there is no foreign traffic to filter out and
# an allowlist would only be a list to forget to update.
# journald's PRIORITY carries the level every line already has;
# without this the record reaches VictoriaLogs with
# `severity_text: Unspecified` and the store cannot tell an error
# from a debug line. The table — and the inverted direction that
# makes it worth a file of its own — lives in
# ../journald-severity.nix, shared with the swarm tier's receiver.
operators = import ../journald-severity.nix;
};
# Identity the hop above cannot supply. A host-side reader can say
# which machine a line came from; only a collector running as the
# agent can say which agent. `hive` and `swarm` are deliberately
# NOT stamped here: the swarm tier upserts both from the receiver
# that accepted the sample, precisely so the labels come from
# something the sender cannot write.
processors.resource.attributes = [
{
key = "service.name";
value = "hyperhive-agent";
action = "upsert";
}
{
key = "agent";
value = userName;
action = "upsert";
}
];
# The hive's collector, at the same base address every in-process
# producer already exports to. `endpoint` is a BASE the exporter
# appends `/v1/logs` to — the path an OTLP/HTTP receiver serves.
exporters.${logExporter} = {
endpoint = cfg.endpoint;
}
// lib.optionalAttrs (cfg.protocol == "http/json") { encoding = "json"; };
service.pipelines.logs = {
receivers = [ "journald" ];
processors = [ "resource" ];
exporters = [ logExporter ];
};
# An extension configured but not listed here is INERT — the
# journald receiver's `storage: file_storage` above would name a
# component the collector never starts.
service.extensions = [ "file_storage" ];
};
};
};
}