Watch
0
0
Fork
You've already forked hyperhive
0

hive collector runs only on hive hosts; review classifier pushes metrics through its forwarder

This commit is contained in:
atlas 2026-10-26 15:58:44 +01:00
commit 0cbfa806d3
9 changed files with 191 additions and 98 deletions

View file

@ -47,7 +47,7 @@ that touches it.
| `hive-forge` | host (shared) | host addresses | forgejo `:3000` http, `:2222` git-ssh; fronted by the `forge.<swarm-domain>` vhost | | `hive-forge` | host (shared) | host addresses | forgejo `:3000` http, `:2222` git-ssh; fronted by the `forge.<swarm-domain>` vhost |
| `hive-matrix` | private, veth on bridge | DHCP pool | tuwunel on UDS `/run/hive-matrix/tuwunel.sock` → the matrix vhost; out to store, SSO, peers | | `hive-matrix` | private, veth on bridge | DHCP pool | tuwunel on UDS `/run/hive-matrix/tuwunel.sock` → the matrix vhost; out to store, SSO, peers |
| `swarm-ci-runner` | private, veth on bridge | DHCP pool | outbound only (runner → forge); no inbound surface | | `swarm-ci-runner` | private, veth on bridge | DHCP pool | outbound only (runner → forge); no inbound surface |
| `swarm-review-classifier` | private, veth on bridge | DHCP pool | outbound only (classifier → queue, secret store, hive collector); no inbound surface | | `swarm-review-classifier` | private, veth on bridge | DHCP pool | outbound only (classifier → queue, secret store, swarm collector); no inbound surface |
| `h-<agent>` | private, veth on bridge | DHCP pool | web UI via UDS `/run/hive-agent/<name>` → nginx sub-path; in-container UI port hashed 8100–8999 | | `h-<agent>` | private, veth on bridge | DHCP pool | web UI via UDS `/run/hive-agent/<name>` → nginx sub-path; in-container UI port hashed 8100–8999 |
The flows, end to end: The flows, end to end:

View file

@ -20,7 +20,11 @@ the host is:
| **swarm tier** — `deploy.swarm-otel` | once per swarm | every hive's collector | writes the swarm's stores and exports upstream | | **swarm tier** — `deploy.swarm-otel` | once per swarm | every hive's collector | writes the swarm's stores and exports upstream |
| **hive tier** — always on | every hive | that hive's agents, on the bridge | forwards to the swarm tier. Holds no credential | | **hive tier** — always on | every hive | that hive's agents, on the bridge | forwards to the swarm tier. Holds no credential |
An all-local host runs both; the hop between them configures itself. An all-local host runs both; the hop between them configures itself. A host that
runs the swarm's services without a hive runs only the swarm tier: the hive tier
labels a hive's telemetry outside its agents' control, and that host has no hive.
The swarm services there push to the swarm collector directly, each as its own
OAuth2 client.
```nix ```nix
services.hyperhive.deploy.swarm-otel.upstream = { services.hyperhive.deploy.swarm-otel.upstream = {

View file

@ -655,14 +655,15 @@ Add a filter such as `F_OUTCOME:="post-failed"` before the `|` to narrow it.
The swarm UI shows no token counts, cache counts or cost, on any page. On the The swarm UI shows no token counts, cache counts or cost, on any page. On the
claude runtimes, every classifier run starts its `claude` process with claude runtimes, every classifier run starts its `claude` process with
`OTEL_RESOURCE_ATTRIBUTES` naming the repo and the PR, so the cost reads from `OTEL_RESOURCE_ATTRIBUTES` naming the repo and the PR, so the cost reads from
the metrics store by repo and PR. The process exports to the hive-tier collector on the metrics store by repo and PR. The process exports to the OTLP receiver of the
the classifier's host, where that host runs one. On a subscription, cost is notional. container's own forwarder on loopback, which pushes to the swarm collector as the
classifier's OAuth2 client, on a host with a hive or without one. On a subscription, cost is notional.
On the ACP runtime, the service computes each classification's cost itself, On the ACP runtime, the service computes each classification's cost itself,
from the settings' `acp.prices` and the token counts the agent reports for the from the settings' `acp.prices` and the token counts the agent reports for the
prompt, and adds it to the counter `hyperhive.review_classifier.cost.usage`, prompt, and adds it to the counter `hyperhive.review_classifier.cost.usage`,
labelled `repo`, `pr`, `model` (the provider's) and `currency` (`USD`). It labelled `repo`, `pr`, `model` (the provider's) and `currency` (`USD`). It
exports to the same collector `claude` does. A classification stopped at its exports to the same receiver `claude` does. A classification stopped at its
deadline, or whose agent reports no token counts, adds nothing. opencode deadline, or whose agent reports no token counts, adds nothing. opencode
reports no cost of its own for a provider without prices in its config, which reports no cost of its own for a provider without prices in its config, which
is why the prices are a setting. is why the prices are a setting.

View file

@ -101,6 +101,20 @@ in
''; '';
}; };
otlpMetricsPort = lib.mkOption {
type = lib.types.nullOr lib.types.port;
default = null;
example = 4318;
description = ''
When set, the collector also takes OTLP/HTTP metrics on
`127.0.0.1:<port>` inside this container and pushes them to
{option}`services.hyperhive.swarmJournalForwarder.endpoint` under the
same OIDC client as the logs. For a process in the container that
speaks OTLP but cannot fetch a token. Null: the collector reads the
journal and nothing else.
'';
};
hostSecretFile = lib.mkOption { hostSecretFile = lib.mkOption {
type = lib.types.nullOr lib.types.str; type = lib.types.nullOr lib.types.str;
default = null; default = null;
@ -176,7 +190,8 @@ in
# A parser, not a wiring check: it accepts an extension that is never # A parser, not a wiring check: it accepts an extension that is never
# started. # started.
validateConfigFile = true; validateConfigFile = true;
settings = { settings = lib.mkMerge [
{
# The receiver's read cursor, so a restart resumes rather than skipping # The receiver's read cursor, so a restart resumes rather than skipping
# what was written while it was down. `create_directory`, because # what was written while it was down. `create_directory`, because
# `otelcol validate` runs in the build sandbox before systemd's # `otelcol validate` runs in the build sandbox before systemd's
@ -245,7 +260,19 @@ in
"file_storage" "file_storage"
authName authName
]; ];
}
# A separate set the module system merges in: joining it to the first with
# `//` would replace the first set's whole `service` attrset.
(lib.mkIf (cfg.otlpMetricsPort != null) {
receivers.otlp.protocols.http.endpoint = "127.0.0.1:${toString cfg.otlpMetricsPort}";
# No `resource` processor: it upserts the journal's `service.name`, and
# the sender names itself.
service.pipelines.metrics = {
receivers = [ "otlp" ];
exporters = [ "otlphttp" ];
}; };
})
];
}; };
# `Restart` is nixpkgs' `"always"`; a second definition conflicts. Only # `Restart` is nixpkgs' `"always"`; a second definition conflicts. Only

View file

@ -91,17 +91,16 @@ in
collector.enable = lib.mkOption { collector.enable = lib.mkOption {
type = lib.types.bool; type = lib.types.bool;
readOnly = true; readOnly = true;
default = default = config.services.hyperhive.deploy.hive-controller.enable;
config.services.hyperhive.deploy.hive-controller.enable defaultText = lib.literalExpression "deploy.hive-controller.enable";
|| config.services.hyperhive.deploy.allSwarmServices;
defaultText = lib.literalExpression "deploy.hive-controller.enable || deploy.allSwarmServices";
description = '' description = ''
Whether this host runs the hive tier's collector. Not a switch: Whether this host runs the hive tier's collector. Not a switch:
every host that runs a hive every host that runs a hive
({option}`services.hyperhive.deploy.hive-controller.enable`) runs ({option}`services.hyperhive.deploy.hive-controller.enable`) runs
one, and so does a host carrying all the swarm's shared services one, and no other host does. It exists to stamp the `hive` label on
({option}`services.hyperhive.deploy.allSwarmServices`), whose own a hive's telemetry outside its agents' control, so a host without a
containers export to it. hive has nothing for it to label: the swarm services there export to
the swarm collector directly, each as its own client.
Agents export to it unauthenticated at Agents export to it unauthenticated at
{option}`services.hyperhive.otel.collector.host`, a name this {option}`services.hyperhive.otel.collector.host`, a name this

View file

@ -39,7 +39,6 @@ let
autheliaCfg = hyperhiveCfg.swarm.authelia; autheliaCfg = hyperhiveCfg.swarm.authelia;
gatewayCfg = hyperhiveCfg.gateway; gatewayCfg = hyperhiveCfg.gateway;
networkCfg = hyperhiveCfg.network; networkCfg = hyperhiveCfg.network;
otelCfg = hyperhiveCfg.otel;
tlsCfg = hyperhiveCfg.deploy.hive-controller.tls; tlsCfg = hyperhiveCfg.deploy.hive-controller.tls;
machine = "swarm-review-classifier"; machine = "swarm-review-classifier";
@ -55,6 +54,8 @@ let
# Loopback in the container's own netns, and distinct from the other service # Loopback in the container's own netns, and distinct from the other service
# containers' forwarders all the same. # containers' forwarders all the same.
forwarderTelemetryPort = 8894; forwarderTelemetryPort = 8894;
# The standard OTLP/HTTP port, on the container's loopback.
forwarderOtlpPort = 4318;
# The daemon reaches authelia, the store and the queue by name over TLS, and # The daemon reaches authelia, the store and the queue by name over TLS, and
# in self-signed mode their leaves chain to the hive CA. # in self-signed mode their leaves chain to the hive CA.
@ -89,14 +90,15 @@ let
credential = id: "/run/credentials/${unit}.service/${id}"; credential = id: "/run/credentials/${unit}.service/${id}";
forwarderCredential = id: "/run/credentials/${forwarderUnit}.service/${id}"; forwarderCredential = id: "/run/credentials/${forwarderUnit}.service/${id}";
# What `claude` exports its metrics with, and the daemon its ACP runs' cost, # What `claude` exports its metrics with, and the daemon its ACP runs' cost:
# where this host runs a hive-tier collector on the bridge. The daemon adds # the container's own forwarder, which pushes them to the swarm collector as
# the repo and the PR to each `claude` run's resource attributes, and to # this service's client, on every host. The daemon adds the repo and the PR
# each cost record's attributes. # to each `claude` run's resource attributes, and to each cost record's
otelEnv = lib.optionalAttrs otelCfg.collector.enable { # attributes.
otelEnv = {
CLAUDE_CODE_ENABLE_TELEMETRY = "1"; CLAUDE_CODE_ENABLE_TELEMETRY = "1";
OTEL_METRICS_EXPORTER = "otlp"; OTEL_METRICS_EXPORTER = "otlp";
OTEL_EXPORTER_OTLP_ENDPOINT = "http://${networkCfg.bridgeIp}:${toString hyperhiveCfg.swarm.hiveOtel.collectorPort}"; OTEL_EXPORTER_OTLP_ENDPOINT = "http://127.0.0.1:${toString forwarderOtlpPort}";
OTEL_EXPORTER_OTLP_PROTOCOL = "http/protobuf"; OTEL_EXPORTER_OTLP_PROTOCOL = "http/protobuf";
# Claude Code defaults to delta, which the metrics store drops. # Claude Code defaults to delta, which the metrics store drops.
OTEL_EXPORTER_OTLP_METRICS_TEMPORALITY_PREFERENCE = "cumulative"; OTEL_EXPORTER_OTLP_METRICS_TEMPORALITY_PREFERENCE = "cumulative";
@ -218,6 +220,7 @@ in
inherit clientId; inherit clientId;
tokenUrl = "${autheliaCfg.url}/api/oidc/token"; tokenUrl = "${autheliaCfg.url}/api/oidc/token";
telemetryPort = forwarderTelemetryPort; telemetryPort = forwarderTelemetryPort;
otlpMetricsPort = forwarderOtlpPort;
store = { store = {
package = baoDeploy.package; package = baoDeploy.package;
addr = "https://${baoCfg.domain}:${toString baoCfg.port}"; addr = "https://${baoCfg.domain}:${toString baoCfg.port}";

View file

@ -85,6 +85,13 @@ let
deploy.review-classifier.enable = true; deploy.review-classifier.enable = true;
}; };
# The same classifier on a host that runs a hive: its metrics path is the
# same as without one.
withClassifierOnHive = hiveWithController {
deploy.bao.enable = true;
deploy.review-classifier.enable = true;
};
# The host fetches that restart their consumer when the store's value # The host fetches that restart their consumer when the store's value
# changes (../host-modules/lib/refresh-consumer.nix), each on a fixture # changes (../host-modules/lib/refresh-consumer.nix), each on a fixture
# that renders it. # that renders it.
@ -142,12 +149,11 @@ let
let let
es = swarmServiceEnables bare; es = swarmServiceEnables bare;
in in
# `otel` is the hive tier, which every hive runs and so `bare` does # The hosting switch's host is the control that the lookup finds what
# too: it is the control that the lookup finds what is there, and the # is there.
# other eight are the swarm's shared services. lib.length (lib.attrNames es) == 8
lib.length (lib.attrNames es) == 9 && !lib.any lib.id (lib.attrValues es)
&& es.otel && lib.all lib.id (lib.attrValues (swarmServiceEnables shared.servicesHere));
&& !lib.any lib.id (lib.attrValues (removeAttrs es [ "otel" ]));
} }
{ {
name = "the all-local mode turns the swarm controller on"; name = "the all-local mode turns the swarm controller on";
@ -716,6 +722,64 @@ let
lib.attrValues withClassifier.systemd.services lib.attrValues withClassifier.systemd.services
)); ));
} }
{
# The collector the classifier's metrics reach is the container's own
# forwarder, which holds the service's client, so the path is the same
# with or without a hive on the host. The logs pipeline is the control
# that the lookup finds what is there.
name = "the review classifier pushes its metrics to the swarm collector through its own forwarder, with or without a hive";
ok =
let
check =
h:
let
c = h.containers.swarm-review-classifier.config;
s = c.services.opentelemetry-collector.settings;
otel = h.services.hyperhive.swarm.otel;
in
c.systemd.services.swarm-review-classifier.environment.OTEL_EXPORTER_OTLP_ENDPOINT
== "http://127.0.0.1:4318"
&& s.receivers.otlp.protocols.http.endpoint == "127.0.0.1:4318"
&& s.service.pipelines.metrics.receivers == [ "otlp" ]
&& s.service.pipelines.metrics.exporters == [ "otlphttp" ]
&& s.exporters.otlphttp.endpoint == "https://${otel.domain}/swarm-review-classifier"
&& s.exporters.otlphttp.auth.authenticator == "oauth2client/swarm"
&& s.service.pipelines.logs.receivers == [ "journald" ];
in
check withClassifier && check withClassifierOnHive;
}
{
# Only the classifier asks the forwarder for a metrics receiver; the
# others carry a journal. The classifier is the control.
name = "a service forwarder without an OTLP port has no metrics pipeline";
ok =
let
s = withCi.containers.swarm-ci-runner.config.services.opentelemetry-collector.settings;
c =
withClassifier.containers.swarm-review-classifier.config.services.opentelemetry-collector.settings;
in
c.service.pipelines ? metrics && !(s.service.pipelines ? metrics) && !(s.receivers ? otlp);
}
{
# The hive tier is the labelling point for a hive's telemetry, so it runs
# where a hive does and nowhere else. A services host with no hive keeps
# its swarm collector and loses only the host-level one.
name = "the hive collector runs on a hive host and not on a swarm-services host without one";
ok =
let
bridge = bare.services.hyperhive.network.bridgeIp;
in
bare.services.hyperhive.otel.collector.enable
&& bare.services.opentelemetry-collector.enable
&&
bare.services.opentelemetry-collector.settings.receivers.otlp.protocols.http.endpoint
== "${bridge}:4318"
&& shared.swarmServicesWithController.services.opentelemetry-collector.enable
&& !shared.servicesHere.services.hyperhive.otel.collector.enable
&& !shared.servicesHere.services.opentelemetry-collector.enable
&& lib.elem "hive-collector.hive" bare.services.hyperhive.gateway.localNames
&& !(lib.elem "hive-collector.hive" shared.servicesHere.services.hyperhive.gateway.localNames);
}
{ {
# See the same case in ./builder.nix. # See the same case in ./builder.nix.
name = "the review classifier's container runs the leaf-minting unit before every start"; name = "the review classifier's container runs the leaf-minting unit before every start";

View file

@ -208,12 +208,9 @@ let
# the two tier cases differ only in which receiver they hand it. # the two tier cases differ only in which receiver they hand it.
carriesJournaldSeverity = receiver: lib.elem journaldSeverityParser (receiver.operators or [ ]); carriesJournaldSeverity = receiver: lib.elem journaldSeverityParser (receiver.operators or [ ]);
# The enables that switch owns. ⚠️ `otel` is the per-hive collector's own # The enables that switch owns. The hive tier's collector is not among them: it
# option and is NOT under `deploy` — spelled at the wrong path it would be # follows `deploy.hive-controller.enable`, and a services host without a hive
# undeclared rather than false, and a roster that quietly loses a member is # runs none.
# what the count guard in the cases below exists to catch. Its membership
# here is deliberate and was the fix for a gap, not an oversight: the hive
# tier lands wherever the swarm services do.
swarmServiceEnables = swarmServiceEnables =
machine: machine:
let let
@ -221,7 +218,6 @@ let
in in
{ {
matrix = h.deploy.matrix.enable; matrix = h.deploy.matrix.enable;
otel = h.otel.collector.enable;
authelia = h.deploy.authelia.enable; authelia = h.deploy.authelia.enable;
nats = h.deploy.nats.enable; nats = h.deploy.nats.enable;
swarm-otel = h.deploy.swarm-otel.enable; swarm-otel = h.deploy.swarm-otel.enable;

View file

@ -207,7 +207,7 @@ let
let let
es = swarmServiceEnables swarmServicesHere; es = swarmServiceEnables swarmServicesHere;
in in
lib.length (lib.attrNames es) == 9 && lib.all lib.id (lib.attrValues es); lib.length (lib.attrNames es) == 8 && lib.all lib.id (lib.attrValues es);
} }
{ {
# The container is the control that the toggle reaches the module, not # The container is the control that the toggle reaches the module, not
@ -1211,7 +1211,6 @@ let
readers = [ readers = [
"swarm-bao-forge-oidc" "swarm-bao-forge-oidc"
"swarm-bao-matrix-oidc" "swarm-bao-matrix-oidc"
"swarm-bao-hive-otel-oidc"
"swarm-bao-grafana-oidc" "swarm-bao-grafana-oidc"
"swarm-bao-grafana-admin" "swarm-bao-grafana-admin"
"swarm-bao-grafana-metrics-bearer" "swarm-bao-grafana-metrics-bearer"
@ -1240,7 +1239,7 @@ let
!(lib.any (n: lib.elem n (s.after ++ s.wants ++ s.requires)) storeUnits); !(lib.any (n: lib.elem n (s.after ++ s.wants ++ s.requires)) storeUnits);
in in
lib.all waits readers lib.all waits readers
&& lib.length readersHere == 10 && lib.length readersHere == 9
&& lib.elem "swarm-bao-grafana-admin" readersHere && lib.elem "swarm-bao-grafana-admin" readersHere
&& lib.length readersElsewhere == 7 && lib.length readersElsewhere == 7
&& lib.all namesNoStoreUnit readersElsewhere && lib.all namesNoStoreUnit readersElsewhere