diff --git a/nix/templates/harness-base.nix b/nix/templates/harness-base.nix index ecb00ca8..59f29d0d 100644 --- a/nix/templates/harness-base.nix +++ b/nix/templates/harness-base.nix @@ -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 `). 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}" ]; }; };