feat(#1930): optional per-agent OTEL stats export via claude-code telemetry

This commit is contained in:
damocles 2026-06-23 12:51:57 +02:00 committed by mara
commit 97ef00742e

View file

@ -174,6 +174,65 @@ in
visible = false;
};
options.hyperhive.otel = {
enable = lib.mkEnableOption ''
exporting this agent's Claude Code stats (token usage, cost, tool
calls) to an OTLP endpoint via Claude Code's built-in OpenTelemetry.
Each agent's harness exports its own stats directly to the collector,
so it keeps working even when hive-c0re is down. Meant to be enabled
hive-wide (one switch for every agent) - there is no per-agent
opt-in flag beyond this option
'';
endpoint = lib.mkOption {
type = lib.types.str;
default = "";
example = "https://collector.example.com/otel";
description = ''
OTLP collector endpoint, set as `OTEL_EXPORTER_OTLP_ENDPOINT`.
Required when `enable` is true.
'';
};
protocol = lib.mkOption {
type = lib.types.enum [
"http/protobuf"
"http/json"
"grpc"
];
default = "http/protobuf";
description = ''
OTLP wire protocol, set as `OTEL_EXPORTER_OTLP_PROTOCOL`.
'';
};
headersCredential = lib.mkOption {
type = lib.types.nullOr lib.types.path;
default = null;
example = "/run/secrets/otel-headers";
description = ''
Path to an operator-provided secret file whose contents become
`OTEL_EXPORTER_OTLP_HEADERS` (e.g.
`Authorization=Bearer <token>`). Loaded via systemd
`LoadCredential` into the unit-private credential store at
runtime, so the token is never copied into the nix store or
exposed in the process argv. Leave null if the endpoint needs no
auth header.
'';
};
extraResourceAttributes = lib.mkOption {
type = lib.types.str;
default = "";
example = "deployment.environment=prod";
description = ''
Extra comma-separated entries appended to
`OTEL_RESOURCE_ATTRIBUTES` after the built-in
`service.name` / `agent` / `hive` / `swarm` labels.
'';
};
};
options.hyperhive.allowedRecipients = lib.mkOption {
type = lib.types.listOf lib.types.str;
default = [ ];
@ -722,6 +781,11 @@ in
'';
assertions = [
# OTEL export needs an endpoint to point at.
{
assertion = !config.hyperhive.otel.enable || config.hyperhive.otel.endpoint != "";
message = "hyperhive.otel.enable is true but hyperhive.otel.endpoint is empty.";
}
# Guard the inputs-routed-as-output pattern: the agent flake.nix is
# expected to set `_module.args.flakeInputs = builtins.removeAttrs inputs ["self"]`.
# If `self` leaks into flakeInputs the agent gets a spurious attrset
@ -1640,6 +1704,34 @@ in
systemd.services.hive-ag3nt =
let
binary = "hive";
otel = config.hyperhive.otel;
# Claude Code's native OpenTelemetry is env-driven; the harness
# spawns `claude` as a child which inherits this unit's env, so
# setting these here is all it takes to export per-agent stats.
otelEnv = lib.optionalAttrs otel.enable {
CLAUDE_CODE_ENABLE_TELEMETRY = "1";
OTEL_METRICS_EXPORTER = "otlp";
OTEL_LOGS_EXPORTER = "otlp";
OTEL_EXPORTER_OTLP_PROTOCOL = otel.protocol;
OTEL_EXPORTER_OTLP_ENDPOINT = otel.endpoint;
};
# When OTEL is on, wrap the harness launch so (1) the bearer-token
# header is read from the systemd credential at start (never in the
# nix store or argv) and (2) the resource attributes are assembled
# from this agent's name (known at build time) plus the hive/swarm
# names (inherited HYPERHIVE_HIVE_NAME / HYPERHIVE_SWARM_NAME env,
# the same vars `identity::hive_name`/`swarm_name` read at runtime).
otelExecStart = pkgs.writeShellScript "hive-serve-otel" ''
set -eu
if [ -n "''${CREDENTIALS_DIRECTORY:-}" ] && [ -r "$CREDENTIALS_DIRECTORY/otel-headers" ]; then
OTEL_EXPORTER_OTLP_HEADERS="$(cat "$CREDENTIALS_DIRECTORY/otel-headers")"
export OTEL_EXPORTER_OTLP_HEADERS
fi
export OTEL_RESOURCE_ATTRIBUTES="service.name=hyperhive-agent,agent=${userName},hive=''${HYPERHIVE_HIVE_NAME:-unknown},swarm=''${HYPERHIVE_SWARM_NAME:-unknown}${
lib.optionalString (otel.extraResourceAttributes != "") ",${otel.extraResourceAttributes}"
}"
exec ${pkgs.hyperhive}/bin/${binary} serve
'';
in
{
description = "${binary} harness";
@ -1662,9 +1754,10 @@ in
# `hive_c0re::agent_sockets::socket_path_for(name)` so lifecycle
# bind-mounts and gateway upstream config stay in sync.
HIVE_WEB_SOCKET = "/run/hive-agent/${userName}/web.sock";
};
}
// otelEnv;
serviceConfig = {
ExecStart = "${pkgs.hyperhive}/bin/${binary} serve";
ExecStart = if otel.enable then "${otelExecStart}" else "${pkgs.hyperhive}/bin/${binary} serve";
Restart = "on-failure";
RestartSec = 2;
# Per-service runtime dir owned by `User=` below; the harness
@ -1674,6 +1767,9 @@ in
RuntimeDirectory = "hive-config";
User = userName;
Group = userName;
}
// lib.optionalAttrs (otel.enable && otel.headersCredential != null) {
LoadCredential = [ "otel-headers:${otel.headersCredential}" ];
};
};