Compare commits
29 changed files with 306 additions and 260 deletions
|
|
@ -117,8 +117,8 @@ dnsmasq is **authoritative** for the hive's own zone (`<hive-domain>`)
|
||||||
plus whatever swarm-service names this host contributes via
|
plus whatever swarm-service names this host contributes via
|
||||||
`gateway.localNames` — `forge.<swarm-domain>` and `chat.<swarm-domain>`
|
`gateway.localNames` — `forge.<swarm-domain>` and `chat.<swarm-domain>`
|
||||||
(matrix) when this host runs those services, `auth.<swarm-domain>`
|
(matrix) when this host runs those services, `auth.<swarm-domain>`
|
||||||
when it runs authelia, and `hive-collector.hive` on every hive
|
when it runs authelia, and `hive-collector.<swarm-domain>` on every hive
|
||||||
— answering each with the bridge IP (where nginx
|
with `otel.enable` — answering each with the bridge IP (where nginx
|
||||||
is reachable, and where the hive's own collector listens). Note forge and
|
is reachable, and where the hive's own collector listens). Note forge and
|
||||||
matrix are swarm-domain names, not sub-domains of `<hive-domain>`: a swarm
|
matrix are swarm-domain names, not sub-domains of `<hive-domain>`: a swarm
|
||||||
runs one forge and one homeserver, so their names belong to the swarm rather
|
runs one forge and one homeserver, so their names belong to the swarm rather
|
||||||
|
|
@ -182,7 +182,7 @@ By default agents can only reach the host on 80/443 (+53 DNS), so a
|
||||||
host-side service on another port — for example a dev OTLP collector you want
|
host-side service on another port — for example a dev OTLP collector you want
|
||||||
agents to reach directly — is unreachable. (hyperhive's own services
|
agents to reach directly — is unreachable. (hyperhive's own services
|
||||||
need none of this: a module that means to be reachable from agents opens
|
need none of this: a module that means to be reachable from agents opens
|
||||||
its own port here, which the hive collector and `nats.enable` both do. Note
|
its own port here, which `otel.enable` and `nats.enable` both do. Note
|
||||||
that `otel.endpoint` is the _upstream_, which no agent ever dials. See
|
that `otel.endpoint` is the _upstream_, which no agent ever dials. See
|
||||||
`docs/scheduler/observability.md`.)
|
`docs/scheduler/observability.md`.)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -16,14 +16,15 @@ Telemetry crosses two collectors, and which one you configure depends on what
|
||||||
the host is:
|
the host is:
|
||||||
|
|
||||||
| | runs where | receives from | does |
|
| | runs where | receives from | does |
|
||||||
| ------------------------------------ | -------------- | --------------------------------- | ----------------------------------------------- |
|
| ------------------------------------ | ---------------------- | --------------------------------- | ----------------------------------------------- |
|
||||||
| **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** — `otel.enable` | every hive with agents | 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.
|
||||||
|
|
||||||
```nix
|
```nix
|
||||||
services.hyperhive.otel = {
|
services.hyperhive.otel = {
|
||||||
|
enable = true;
|
||||||
endpoint = "https://collector.example.com/otel"; # the upstream
|
endpoint = "https://collector.example.com/otel"; # the upstream
|
||||||
headersCredential = "/run/secrets/otel-headers"; # only the swarm tier reads it
|
headersCredential = "/run/secrets/otel-headers"; # only the swarm tier reads it
|
||||||
};
|
};
|
||||||
|
|
@ -31,20 +32,20 @@ services.hyperhive.otel = {
|
||||||
|
|
||||||
## Enabling export
|
## Enabling export
|
||||||
|
|
||||||
Every hive runs its collector and every agent exports to it, with nothing to
|
`otel.enable` is the single gate on a hive: one switch in the host config
|
||||||
switch on: there is no per-hive or per-agent opt-in.
|
covers every agent container on it.
|
||||||
`endpoint` is where telemetry ends up after it leaves the swarm — optional,
|
`endpoint` is where telemetry ends up after it leaves the swarm — optional,
|
||||||
because the swarm's own metrics store (`deploy.victoriametrics`) is a
|
because the swarm's own metrics store (`deploy.victoriametrics`) is a
|
||||||
destination in its own right. With both, telemetry goes to both. See
|
destination in its own right. With both, telemetry goes to both. See
|
||||||
[`swarm/services.md`](../swarm/services.md#metrics-victoriametrics--grafana).
|
[`swarm/services.md`](../swarm/services.md#metrics-victoriametrics--grafana).
|
||||||
|
|
||||||
**Telemetry leaves a hive exactly one way: through the collector that
|
**Telemetry leaves a hive exactly one way: through the collector that
|
||||||
runs on the host.** Agents never talk to `endpoint` themselves —
|
`enable` starts on the host.** Agents never talk to `endpoint` themselves —
|
||||||
they export unauthenticated to a collector address only their own containers
|
they export unauthenticated to a collector address only their own containers
|
||||||
can reach. They dial it as
|
can reach. They dial it as
|
||||||
`http://hive-collector.hive:4318`, a name identical on
|
`http://hive-collector.<swarm domain>:<collector port>`, a name identical on
|
||||||
every hive and every swarm, which each hive's own dnsmasq answers with its bridge IP, so an
|
every hive, which each hive's own dnsmasq answers with its bridge IP, so an
|
||||||
agent's build carries no hive-specific address and no swarm domain. The otel module opens that
|
agent's build carries no hive-specific address. The otel module opens that
|
||||||
port on the bridge itself. That collector forwards to the swarm's
|
port on the bridge itself. That collector forwards to the swarm's
|
||||||
([`swarm/services.md`](../swarm/services.md#telemetry-collector-otel)), the
|
([`swarm/services.md`](../swarm/services.md#telemetry-collector-otel)), the
|
||||||
single process holding the upstream credential and the only writer to the
|
single process holding the upstream credential and the only writer to the
|
||||||
|
|
@ -169,7 +170,7 @@ a build error naming the reason rather than telemetry silently going nowhere.
|
||||||
## Network access
|
## Network access
|
||||||
|
|
||||||
Agent containers reach the host only on ports 80 and 443 (plus DNS). The
|
Agent containers reach the host only on ports 80 and 443 (plus DNS). The
|
||||||
collector port needs nothing from you: the hive collector opens it on the bridge.
|
collector port needs nothing from you: `otel.enable` opens it on the bridge.
|
||||||
To let agents reach some other host-local service of your own →
|
To let agents reach some other host-local service of your own →
|
||||||
[`networking/network.md`](../networking/network.md#reaching-host-services-exposehostports).
|
[`networking/network.md`](../networking/network.md#reaching-host-services-exposehostports).
|
||||||
|
|
||||||
|
|
@ -179,7 +180,7 @@ The nix module (`nix/host-modules/otel.nix`) generates every
|
||||||
`services.hyperhive.otel.*` option's full type, default, description and
|
`services.hyperhive.otel.*` option's full type, default, description and
|
||||||
example into the [options reference](https://hyperhive.darkest.space/options/)
|
example into the [options reference](https://hyperhive.darkest.space/options/)
|
||||||
(host options — `nix build .#docs-host` for a local render). The same module
|
(host options — `nix build .#docs-host` for a local render). The same module
|
||||||
fixes the collector port every hive and its agents share,
|
declares the collector port every hive and its agents share,
|
||||||
`services.hyperhive.swarm.hiveOtel.collectorPort`, and each hive's own
|
`services.hyperhive.swarm.hiveOtel.collectorPort`, and each hive's own
|
||||||
`services.hyperhive.deploy.hive-controller.otel.extraResourceAttributes`.
|
`services.hyperhive.deploy.hive-controller.otel.extraResourceAttributes`.
|
||||||
That's the reference; this page covers what a per-option listing can't: the
|
That's the reference; this page covers what a per-option listing can't: the
|
||||||
|
|
@ -295,7 +296,7 @@ cleanly and the affected level button goes on returning zero rows:
|
||||||
|
|
||||||
<!-- vale write-good.Passive = NO -->
|
<!-- vale write-good.Passive = NO -->
|
||||||
|
|
||||||
**hive-c0re itself** also exports each agent
|
When OTEL is enabled, **hive-c0re itself** also exports each agent
|
||||||
container's resource load — the same cgroup gauges —
|
container's resource load — the same cgroup gauges —
|
||||||
to this hive's own collector, exactly like an agent does and with
|
to this hive's own collector, exactly like an agent does and with
|
||||||
no separate toggle. These come from the host, not the in-container Claude SDK,
|
no separate toggle. These come from the host, not the in-container Claude SDK,
|
||||||
|
|
@ -341,7 +342,7 @@ a crash and `tracing::warn!`s `"container crash detected"` — nothing else:
|
||||||
no broker event, no per-hive dashboard banner. Both of those used to exist;
|
no broker event, no per-hive dashboard banner. Both of those used to exist;
|
||||||
this change removed them in favour of the log line.
|
this change removed them in favour of the log line.
|
||||||
|
|
||||||
The hive tier's own collector runs a `journald`
|
When `otel.enable` is on, the hive tier's own collector runs a `journald`
|
||||||
receiver scoped to `hive-c0re.service` (no bind mount needed — unlike an
|
receiver scoped to `hive-c0re.service` (no bind mount needed — unlike an
|
||||||
agent's journal, this collector already shares a filesystem with the unit it
|
agent's journal, this collector already shares a filesystem with the unit it
|
||||||
reads, being a host systemd service rather than a container) and forwards
|
reads, being a host systemd service rather than a container) and forwards
|
||||||
|
|
@ -352,13 +353,18 @@ matches it directly and pages the operator — no agent, and no per-hive
|
||||||
dashboard, in the loop.
|
dashboard, in the loop.
|
||||||
|
|
||||||
Requires `services.journald.storage = "persistent"` (the NixOS default) on
|
Requires `services.journald.storage = "persistent"` (the NixOS default) on
|
||||||
every hive host — asserted at eval time.
|
every hive host with `otel.enable` set — asserted at eval time.
|
||||||
|
|
||||||
|
⚠️ **A hive with `otel.enable` off has no operator-visible crash signal at
|
||||||
|
all** — the per-hive dashboard banner this replaces no longer exists, and
|
||||||
|
there is no fallback path. Turning on `otel.enable` is what makes a crash
|
||||||
|
reach anyone.
|
||||||
|
|
||||||
## Agent-emitted per-turn metrics (`hive-agent`)
|
## Agent-emitted per-turn metrics (`hive-agent`)
|
||||||
|
|
||||||
<!-- vale write-good.Passive = NO -->
|
<!-- vale write-good.Passive = NO -->
|
||||||
|
|
||||||
The harness itself (`hive-agent`) exports one small set
|
When OTEL is enabled, the harness itself (`hive-agent`) exports one small set
|
||||||
of metrics per claude turn, recorded the moment the turn ends (not polled).
|
of metrics per claude turn, recorded the moment the turn ends (not polled).
|
||||||
These are deliberately the fields Claude Code's own built-in export (see
|
These are deliberately the fields Claude Code's own built-in export (see
|
||||||
above) can't know about — the harness's own wall-clock timing, what woke the
|
above) can't know about — the harness's own wall-clock timing, what woke the
|
||||||
|
|
@ -502,7 +508,8 @@ and the start of the response body. It repeats a persisting failure every
|
||||||
## Agent-emitted custom metrics (`hive-metric`)
|
## Agent-emitted custom metrics (`hive-metric`)
|
||||||
|
|
||||||
Agents can push arbitrary labeled metrics to the same OTEL collector via the
|
Agents can push arbitrary labeled metrics to the same OTEL collector via the
|
||||||
`hive-metric` CLI tool, available in every agent container.
|
`hive-metric` CLI tool, available in every agent container when
|
||||||
|
`services.hyperhive.otel.enable = true`.
|
||||||
|
|
||||||
### Usage
|
### Usage
|
||||||
|
|
||||||
|
|
@ -537,11 +544,11 @@ hive-metric queue_depth 17 --type gauge
|
||||||
hive-metric api_latency_ms 142.5 --type gauge --labels model=sonnet --labels tier=api
|
hive-metric api_latency_ms 142.5 --type gauge --labels model=sonnet --labels tier=api
|
||||||
```
|
```
|
||||||
|
|
||||||
### Error when an agent turns OTEL off
|
### Error when OTEL isn't configured
|
||||||
|
|
||||||
An agent whose own config sets `services.hyperhive.agent.otel.enable = false`
|
When `services.hyperhive.otel.enable = false` (the default), the
|
||||||
has no `OTEL_EXPORTER_OTLP_ENDPOINT` env var, and `hive-metric` exits with an
|
`OTEL_EXPORTER_OTLP_ENDPOINT` env var isn't set and `hive-metric` exits
|
||||||
informative error message. No silently dropped metrics.
|
with an informative error message. No silently dropped metrics.
|
||||||
|
|
||||||
### Wire format
|
### Wire format
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -561,17 +561,42 @@ fn hive_ca_source() -> Option<String> {
|
||||||
Some(path)
|
Some(path)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Extra `OTEL_RESOURCE_ATTRIBUTES` entries for every agent, read off
|
/// Hive-wide OTEL config injected into every agent's build, read off
|
||||||
/// hive-c0re's own unit env
|
/// hive-c0re's own unit env (set from `services.hyperhive.otel.*`,
|
||||||
/// (`services.hyperhive.deploy.hive-controller.otel.extraResourceAttributes`).
|
/// `services.hyperhive.swarm.hiveOtel.*` and
|
||||||
/// `None` when unset or empty. This is the only OTEL value the renderer
|
/// `services.hyperhive.deploy.hive-controller.otel.*`). A present, non-empty
|
||||||
/// writes into the flake: agents export to the hive collector by default and
|
/// `HYPERHIVE_OTEL_ENDPOINT` is the enable signal — the host module
|
||||||
/// find it by name, so there is no enable flag, endpoint or protocol to
|
/// asserts the endpoint is set whenever `otel.enable` is true, so
|
||||||
/// carry.
|
/// "endpoint present" == "OTEL on". The optional fields map to the
|
||||||
fn otel_extra_resource_attributes() -> Option<String> {
|
/// matching host options and are only carried when set.
|
||||||
std::env::var("HYPERHIVE_OTEL_EXTRA_RESOURCE_ATTRIBUTES")
|
struct OtelConfig {
|
||||||
|
endpoint: String,
|
||||||
|
protocol: String,
|
||||||
|
extra_resource_attributes: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read the hive-wide OTEL config from env, or `None` when OTEL is off.
|
||||||
|
/// Mirrors `hive_ca_source` — host state surfaced to the meta renderer
|
||||||
|
/// so it can bake build-time `services.hyperhive.agent.otel.*` config into each agent
|
||||||
|
/// (the per-agent options the harness modules consume). Returns `None`
|
||||||
|
/// when the endpoint signal is absent so the renderer emits no
|
||||||
|
/// `services.hyperhive.agent.otel.*` lines and agents keep the disabled default.
|
||||||
|
fn otel_config() -> Option<OtelConfig> {
|
||||||
|
let endpoint = std::env::var("HYPERHIVE_OTEL_ENDPOINT")
|
||||||
|
.ok()
|
||||||
|
.filter(|v| !v.is_empty())?;
|
||||||
|
let protocol = std::env::var("HYPERHIVE_OTEL_PROTOCOL")
|
||||||
.ok()
|
.ok()
|
||||||
.filter(|v| !v.is_empty())
|
.filter(|v| !v.is_empty())
|
||||||
|
.unwrap_or_else(|| "http/protobuf".to_owned());
|
||||||
|
let extra_resource_attributes = std::env::var("HYPERHIVE_OTEL_EXTRA_RESOURCE_ATTRIBUTES")
|
||||||
|
.ok()
|
||||||
|
.filter(|v| !v.is_empty());
|
||||||
|
Some(OtelConfig {
|
||||||
|
endpoint,
|
||||||
|
protocol,
|
||||||
|
extra_resource_attributes,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The ordered set of CA certs embedded next to the meta flake, as
|
/// The ordered set of CA certs embedded next to the meta flake, as
|
||||||
|
|
@ -882,20 +907,36 @@ fn render_flake(
|
||||||
ca_refs.join(" ")
|
ca_refs.join(" ")
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
// Extra OTEL resource labels (`deploy.hive-controller.otel.*`) are the one
|
// Hive-wide OTEL stats export (`services.hyperhive.otel.*`): inject the
|
||||||
// OTEL value rendered here; enablement and the collector endpoint are
|
// build-time `services.hyperhive.agent.otel.*` config the harness modules consume (its
|
||||||
// agent-module defaults.
|
// otelEnv + otelExecStart wrapper + LoadCredential). Host-driven, so
|
||||||
if let Some(attrs) = otel_extra_resource_attributes() {
|
// the same config lands on every agent; emitted only when enabled.
|
||||||
|
// Mirrors the CA-cert injection above — host state -> build-time agent
|
||||||
|
// module config.
|
||||||
|
if let Some(otel) = otel_config() {
|
||||||
let esc = |s: &str| s.replace('\\', "\\\\").replace('"', "\\\"");
|
let esc = |s: &str| s.replace('\\', "\\\\").replace('"', "\\\"");
|
||||||
|
out.push_str(" services.hyperhive.agent.otel.enable = true;\n");
|
||||||
|
let _ = writeln!(
|
||||||
|
out,
|
||||||
|
" services.hyperhive.agent.otel.endpoint = \"{}\";",
|
||||||
|
esc(&otel.endpoint)
|
||||||
|
);
|
||||||
|
let _ = writeln!(
|
||||||
|
out,
|
||||||
|
" services.hyperhive.agent.otel.protocol = \"{}\";",
|
||||||
|
esc(&otel.protocol)
|
||||||
|
);
|
||||||
|
if let Some(attrs) = &otel.extra_resource_attributes {
|
||||||
let _ = writeln!(
|
let _ = writeln!(
|
||||||
out,
|
out,
|
||||||
" services.hyperhive.agent.otel.extraResourceAttributes = \"{}\";",
|
" services.hyperhive.agent.otel.extraResourceAttributes = \"{}\";",
|
||||||
esc(&attrs)
|
esc(attrs)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
// Agent-facing service URLs (`services.hyperhive.agent.forge.url`, `services.hyperhive.agent.matrix.url`):
|
// Agent-facing service URLs (`services.hyperhive.agent.forge.url`, `services.hyperhive.agent.matrix.url`):
|
||||||
// emit the host's real values as build-time agent config, the same
|
// emit the host's real values as build-time agent config, the same
|
||||||
// host-state -> agent-module shape as the CA block above.
|
// host-state -> agent-module shape as the otel block above.
|
||||||
//
|
//
|
||||||
// These options exist already, but until now *nothing set them*, so every
|
// These options exist already, but until now *nothing set them*, so every
|
||||||
// agent silently fell back to their `localhost:<port>` defaults while the
|
// agent silently fell back to their `localhost:<port>` defaults while the
|
||||||
|
|
@ -2099,16 +2140,16 @@ mod tests {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn render_flake_carries_no_otel_enablement() {
|
fn render_flake_injects_otel_when_signalled() {
|
||||||
// The agent module defaults otel on and finds the collector by name, so
|
// services.hyperhive.otel.* -> HYPERHIVE_OTEL_* on hive-c0re's unit
|
||||||
// the flake carries no enable, endpoint or protocol line however the
|
// -> injected as build-time services.hyperhive.agent.otel.* into every agent. With
|
||||||
// host's OTEL env is set. Only the per-hive resource labels are
|
// no endpoint signal, no services.hyperhive.agent.otel lines are emitted (agents
|
||||||
// rendered, and only when set.
|
// keep the the harness modules disabled default).
|
||||||
//
|
//
|
||||||
// Serialised against every other env-mutating test in the crate.
|
// Serialised against every other env-mutating test in the crate.
|
||||||
// `stats::otel_metrics::tests` perturbs OTEL env too, and lands in this
|
// `stats::otel_metrics::tests` perturbs HYPERHIVE_OTEL_ENDPOINT too,
|
||||||
// same test binary — "no other test asserts on these" was true of this
|
// and lands in this same test binary — "no other test asserts on
|
||||||
// module and false of the process.
|
// these" was true of this module and false of the process.
|
||||||
let _env = crate::test_env::lock();
|
let _env = crate::test_env::lock();
|
||||||
// SAFETY: serialised by the guard above; restored before returning.
|
// SAFETY: serialised by the guard above; restored before returning.
|
||||||
let render = || {
|
let render = || {
|
||||||
|
|
@ -2124,49 +2165,53 @@ mod tests {
|
||||||
std::env::set_var("HYPERHIVE_OTEL_ENDPOINT", "https://c.example/otel");
|
std::env::set_var("HYPERHIVE_OTEL_ENDPOINT", "https://c.example/otel");
|
||||||
std::env::set_var("HYPERHIVE_OTEL_PROTOCOL", "grpc");
|
std::env::set_var("HYPERHIVE_OTEL_PROTOCOL", "grpc");
|
||||||
}
|
}
|
||||||
let plain = render();
|
let on_minimal = render();
|
||||||
unsafe {
|
unsafe {
|
||||||
std::env::set_var(
|
std::env::set_var(
|
||||||
"HYPERHIVE_OTEL_EXTRA_RESOURCE_ATTRIBUTES",
|
"HYPERHIVE_OTEL_EXTRA_RESOURCE_ATTRIBUTES",
|
||||||
"deployment.environment=prod",
|
"deployment.environment=prod",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
let labelled = render();
|
let on_full = render();
|
||||||
unsafe {
|
unsafe {
|
||||||
std::env::remove_var("HYPERHIVE_OTEL_ENDPOINT");
|
std::env::remove_var("HYPERHIVE_OTEL_ENDPOINT");
|
||||||
std::env::remove_var("HYPERHIVE_OTEL_PROTOCOL");
|
std::env::remove_var("HYPERHIVE_OTEL_PROTOCOL");
|
||||||
std::env::remove_var("HYPERHIVE_OTEL_EXTRA_RESOURCE_ATTRIBUTES");
|
std::env::remove_var("HYPERHIVE_OTEL_EXTRA_RESOURCE_ATTRIBUTES");
|
||||||
}
|
}
|
||||||
|
let off = render();
|
||||||
|
|
||||||
// Presence control: the render is the real flake, carrying the pin.
|
|
||||||
assert!(
|
assert!(
|
||||||
plain.contains("rev=0123abcd"),
|
on_minimal.contains("services.hyperhive.agent.otel.enable = true;"),
|
||||||
"the hyperhive pin line must be rendered:\n{plain}"
|
"otel enable must be injected:\n{on_minimal}"
|
||||||
);
|
|
||||||
for (name, flake) in [("plain", &plain), ("labelled", &labelled)] {
|
|
||||||
for needle in [
|
|
||||||
"agent.otel.enable",
|
|
||||||
"agent.otel.endpoint",
|
|
||||||
"agent.otel.protocol",
|
|
||||||
"c.example",
|
|
||||||
] {
|
|
||||||
assert!(
|
|
||||||
!flake.contains(needle),
|
|
||||||
"{name}: `{needle}` must not be rendered:\n{flake}"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
assert!(
|
|
||||||
!plain.contains("agent.otel.extraResourceAttributes"),
|
|
||||||
"extraResourceAttributes must not appear when unset:\n{plain}"
|
|
||||||
);
|
);
|
||||||
assert!(
|
assert!(
|
||||||
labelled.contains(
|
on_minimal
|
||||||
|
.contains("services.hyperhive.agent.otel.endpoint = \"https://c.example/otel\";"),
|
||||||
|
"otel endpoint must be injected:\n{on_minimal}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
on_minimal.contains("services.hyperhive.agent.otel.protocol = \"grpc\";"),
|
||||||
|
"otel protocol must be injected:\n{on_minimal}"
|
||||||
|
);
|
||||||
|
// Optional fields absent when unset.
|
||||||
|
assert!(
|
||||||
|
!on_minimal.contains("services.hyperhive.agent.otel.extraResourceAttributes"),
|
||||||
|
"extraResourceAttributes must not appear when unset:\n{on_minimal}"
|
||||||
|
);
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
on_full.contains(
|
||||||
"services.hyperhive.agent.otel.extraResourceAttributes = \"deployment.environment=prod\";"
|
"services.hyperhive.agent.otel.extraResourceAttributes = \"deployment.environment=prod\";"
|
||||||
),
|
),
|
||||||
"extraResourceAttributes must be rendered when set:\n{labelled}"
|
"extraResourceAttributes must be injected when set:\n{on_full}"
|
||||||
|
);
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
!off.contains("services.hyperhive.agent.otel"),
|
||||||
|
"no otel lines when disabled:\n{off}"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn render_flake_injects_github_disable_only_when_signalled() {
|
fn render_flake_injects_github_disable_only_when_signalled() {
|
||||||
// services.hyperhive.swarm.github.enable = false -> HYPERHIVE_GITHUB_DISABLED
|
// services.hyperhive.swarm.github.enable = false -> HYPERHIVE_GITHUB_DISABLED
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
//! hive's own collector — the same first hop the agents use, named by the
|
//! hive's own collector — the same first hop the agents use, named by the
|
||||||
//! standard `OTEL_EXPORTER_OTLP_ENDPOINT` (see `nix/host-modules/hive-c0re`,
|
//! standard `OTEL_EXPORTER_OTLP_ENDPOINT` (see `nix/host-modules/hive-c0re`,
|
||||||
//! which sets it to the collector's bridge address where agents get its
|
//! which sets it to the collector's bridge address where agents get its
|
||||||
//! fixed name). No toggle of
|
//! swarm-wide name). No toggle of
|
||||||
//! its own, and no credential: a hive's collector takes unauthenticated OTLP
|
//! its own, and no credential: a hive's collector takes unauthenticated OTLP
|
||||||
//! on the bridge, and the only hop that presents anything is the swarm tier's,
|
//! on the bridge, and the only hop that presents anything is the swarm tier's,
|
||||||
//! which is the one that leaves the swarm.
|
//! which is the one that leaves the swarm.
|
||||||
|
|
@ -100,7 +100,8 @@ fn build_provider(
|
||||||
hyperhive_flake: &str,
|
hyperhive_flake: &str,
|
||||||
) -> Result<SdkMeterProvider> {
|
) -> Result<SdkMeterProvider> {
|
||||||
// http/json — the only OTLP transport this crate enables (matching
|
// http/json — the only OTLP transport this crate enables (matching
|
||||||
// hive-metric); this exporter is always http/json.
|
// hive-metric). The Claude SDK path honours `HYPERHIVE_OTEL_PROTOCOL` for
|
||||||
|
// its own export; this exporter is always http/json.
|
||||||
//
|
//
|
||||||
// The address is deliberately NOT passed here. The SDK reads
|
// The address is deliberately NOT passed here. The SDK reads
|
||||||
// `OTEL_EXPORTER_OTLP_ENDPOINT` itself and appends the signal path
|
// `OTEL_EXPORTER_OTLP_ENDPOINT` itself and appends the signal path
|
||||||
|
|
|
||||||
|
|
@ -12,15 +12,15 @@
|
||||||
//! sibling helper records why: two separate per-module mutexes serialise
|
//! sibling helper records why: two separate per-module mutexes serialise
|
||||||
//! nothing against each other, and that produced a CI-only flake. The pair that
|
//! nothing against each other, and that produced a CI-only flake. The pair that
|
||||||
//! motivated this one is `meta::tests` (which renders a flake from
|
//! motivated this one is `meta::tests` (which renders a flake from
|
||||||
//! `HYPERHIVE_OTEL_EXTRA_RESOURCE_ATTRIBUTES`) and `stats::otel_metrics::tests` (which asserts which
|
//! `HYPERHIVE_OTEL_*`) and `stats::otel_metrics::tests` (which asserts which
|
||||||
//! variable enables the exporter) — different modules, same process env, same
|
//! variable enables the exporter) — different modules, same variable, same
|
||||||
//! binary.
|
//! binary.
|
||||||
//!
|
//!
|
||||||
//! ⚠️ **A green local run is weak evidence for this class.** An agent container
|
//! ⚠️ **A green local run is weak evidence for this class.** An agent container
|
||||||
//! has the hyperhive variables ambient-set, so a losing race still finds a
|
//! has the hyperhive variables ambient-set, so a losing race still finds a
|
||||||
//! plausible value; the nix sandbox strips them, so there the race can delete a
|
//! plausible value; the nix sandbox strips them, so there the race can delete a
|
||||||
//! variable out from under another thread. Reproduce the sandbox shape with
|
//! variable out from under another thread. Reproduce the sandbox shape with
|
||||||
//! `env -u OTEL_EXPORTER_OTLP_ENDPOINT cargo test -p hive-c0re`.
|
//! `env -u HYPERHIVE_OTEL_ENDPOINT cargo test -p hive-c0re`.
|
||||||
|
|
||||||
use std::sync::{Mutex, MutexGuard, PoisonError};
|
use std::sync::{Mutex, MutexGuard, PoisonError};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@ The SDK reads standard OTEL environment variables automatically:
|
||||||
- `OTEL_EXPORTER_OTLP_ENDPOINT` — collector URL (**required**)
|
- `OTEL_EXPORTER_OTLP_ENDPOINT` — collector URL (**required**)
|
||||||
- `OTEL_RESOURCE_ATTRIBUTES` — resource labels (`k=v,...`)
|
- `OTEL_RESOURCE_ATTRIBUTES` — resource labels (`k=v,...`)
|
||||||
|
|
||||||
The harness populates both in every agent container: an agent exports straight to
|
The harness populates both per-agent when
|
||||||
|
`services.hyperhive.otel.enable = true`: an agent exports straight to
|
||||||
its own hive's collector. See `docs/scheduler/observability.md` for
|
its own hive's collector. See `docs/scheduler/observability.md` for
|
||||||
the collector setup and the metrics hyperhive exports.
|
the collector setup and the metrics hyperhive exports.
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,8 @@
|
||||||
//! - `OTEL_EXPORTER_OTLP_HEADERS` — auth headers (`Key=Value,...`)
|
//! - `OTEL_EXPORTER_OTLP_HEADERS` — auth headers (`Key=Value,...`)
|
||||||
//! - `OTEL_RESOURCE_ATTRIBUTES` — resource labels (`k=v,...`)
|
//! - `OTEL_RESOURCE_ATTRIBUTES` — resource labels (`k=v,...`)
|
||||||
//!
|
//!
|
||||||
//! The harness populates all of these in every agent container.
|
//! The harness populates all of these per-agent when
|
||||||
|
//! `services.hyperhive.otel.enable = true`.
|
||||||
//!
|
//!
|
||||||
//! # Usage
|
//! # Usage
|
||||||
//! ```text
|
//! ```text
|
||||||
|
|
@ -24,7 +25,7 @@ use opentelemetry_sdk::metrics::Temporality;
|
||||||
#[derive(Parser)]
|
#[derive(Parser)]
|
||||||
#[command(
|
#[command(
|
||||||
name = "hive-metric",
|
name = "hive-metric",
|
||||||
about = "Push a labeled metric to the hive's OTEL collector",
|
about = "Push a labeled metric to the OTEL collector (requires services.hyperhive.otel.enable = true)",
|
||||||
// Let a leading-minus value parse as the `value` positional rather than
|
// Let a leading-minus value parse as the `value` positional rather than
|
||||||
// being rejected as an unknown flag — negative gauges are legitimate
|
// being rejected as an unknown flag — negative gauges are legitimate
|
||||||
// (e.g. a delta), and it lets the counter-sign check below produce a clear
|
// (e.g. a delta), and it lets the counter-sign check below produce a clear
|
||||||
|
|
@ -89,7 +90,7 @@ fn main() -> Result<()> {
|
||||||
if std::env::var("OTEL_EXPORTER_OTLP_ENDPOINT").is_err() {
|
if std::env::var("OTEL_EXPORTER_OTLP_ENDPOINT").is_err() {
|
||||||
bail!(
|
bail!(
|
||||||
"OTEL_EXPORTER_OTLP_ENDPOINT not set — \
|
"OTEL_EXPORTER_OTLP_ENDPOINT not set — \
|
||||||
an agent that sets services.hyperhive.agent.otel.enable = false has no collector"
|
enable OTEL in NixOS config (services.hyperhive.otel.enable = true)"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -15,8 +15,8 @@
|
||||||
let
|
let
|
||||||
userName = config.services.hyperhive.agent.user.name;
|
userName = config.services.hyperhive.agent.user.name;
|
||||||
homeDir = "/home/${userName}";
|
homeDir = "/home/${userName}";
|
||||||
# OpenTelemetry config (on by default; extra labels baked in per-agent by
|
# Hive-wide OpenTelemetry config (host-driven; baked in per-agent by
|
||||||
# meta.rs). Options declared in `otel.nix`, which also
|
# meta.rs `otel_config`). Options declared in `otel.nix`, which also
|
||||||
# exports the generic OTLP environment this container's producers read.
|
# exports the generic OTLP environment this container's producers read.
|
||||||
otelCfg = config.services.hyperhive.agent.otel;
|
otelCfg = config.services.hyperhive.agent.otel;
|
||||||
# Base claude-code environment applied to every agent regardless of OTEL.
|
# Base claude-code environment applied to every agent regardless of OTEL.
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
# OpenTelemetry wiring for an agent container: the per-agent options (on by
|
# OpenTelemetry wiring for an agent container: the per-agent options the
|
||||||
# default, exporting to the hive's collector by name) plus
|
# meta flake injects (host-driven from `services.hyperhive.otel.*`) plus
|
||||||
# `debug`, which the agent's own config sets, the
|
# `debug`, which the agent's own config sets, the
|
||||||
# environment every OTLP producer inside the container reads, and the
|
# environment every OTLP producer inside the container reads, and the
|
||||||
# collector that forwards this container's journal to the hive.
|
# collector that forwards this container's journal to the hive.
|
||||||
|
|
@ -71,35 +71,35 @@ let
|
||||||
logExporter = if cfg.protocol == "grpc" then "otlp" else "otlphttp";
|
logExporter = if cfg.protocol == "grpc" then "otlp" else "otlphttp";
|
||||||
in
|
in
|
||||||
{
|
{
|
||||||
# On by default: every hive runs a collector and answers
|
# OTEL stats export is configured ONCE at host level via
|
||||||
# `hive-collector.hive` with its own bridge address, so the endpoint below
|
# `services.hyperhive.otel.*` (see nix/host-modules/otel.nix) and
|
||||||
# is the same string in every agent on every hive and nothing is rendered
|
# injected into every agent's build by the meta-flake renderer
|
||||||
# into the meta flake. The port is the literal the host fixes at
|
# (`hive-c0re/src/meta.rs::otel_config`). These per-agent options are
|
||||||
# `services.hyperhive.swarm.hiveOtel.collectorPort` (read-only there).
|
# the build-time implementation surface that injection writes into;
|
||||||
# Marked `internal`: these are not meant to be set in an agent.nix, except
|
# they are not meant to be set directly in an agent.nix. Marked
|
||||||
# `debug`.
|
# `internal` so the host option is the only documented operator knob.
|
||||||
options.services.hyperhive.agent.otel = {
|
options.services.hyperhive.agent.otel = {
|
||||||
enable = lib.mkOption {
|
enable = lib.mkOption {
|
||||||
type = lib.types.bool;
|
type = lib.types.bool;
|
||||||
default = true;
|
default = false;
|
||||||
internal = true;
|
internal = true;
|
||||||
description = ''
|
description = ''
|
||||||
Export this agent's telemetry — Claude Code's stats (token
|
Export this agent's telemetry — Claude Code's stats (token
|
||||||
usage, cost, tool calls) and anything pushed with
|
usage, cost, tool calls) and anything pushed with
|
||||||
`hive-metric` — to the hive's collector. Each agent exports
|
`hive-metric` — to an OTLP endpoint. Each agent exports
|
||||||
directly to it, so it keeps working even when hive-c0re is
|
directly to the hive's collector, so it keeps working even when
|
||||||
down.
|
hive-c0re is down. Host-driven: set
|
||||||
|
`services.hyperhive.otel.enable` instead.
|
||||||
'';
|
'';
|
||||||
};
|
};
|
||||||
|
|
||||||
endpoint = lib.mkOption {
|
endpoint = lib.mkOption {
|
||||||
type = lib.types.str;
|
type = lib.types.str;
|
||||||
default = "http://hive-collector.hive:4318";
|
default = "";
|
||||||
internal = true;
|
internal = true;
|
||||||
description = ''
|
description = ''
|
||||||
OTLP collector endpoint, set as `OTEL_EXPORTER_OTLP_ENDPOINT`.
|
OTLP collector endpoint, set as `OTEL_EXPORTER_OTLP_ENDPOINT`.
|
||||||
The name is the one `services.hyperhive.otel.collector.host`
|
Host-driven via `services.hyperhive.otel.endpoint`.
|
||||||
holds, which the hive's resolver answers.
|
|
||||||
'';
|
'';
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -113,8 +113,7 @@ in
|
||||||
internal = true;
|
internal = true;
|
||||||
description = ''
|
description = ''
|
||||||
OTLP wire protocol, set as `OTEL_EXPORTER_OTLP_PROTOCOL`.
|
OTLP wire protocol, set as `OTEL_EXPORTER_OTLP_PROTOCOL`.
|
||||||
The hive collector's receiver speaks OTLP/HTTP protobuf, so
|
Host-driven via `services.hyperhive.otel.protocol`.
|
||||||
the default is the only value that reaches it.
|
|
||||||
'';
|
'';
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -297,7 +297,7 @@ in
|
||||||
|
|
||||||
Named `swarm-otel` rather than `otel` because there are two
|
Named `swarm-otel` rather than `otel` because there are two
|
||||||
collectors and the tier is the whole distinction:
|
collectors and the tier is the whole distinction:
|
||||||
{option}`services.hyperhive.otel.collector.enable` is the **hive-tier** one,
|
{option}`services.hyperhive.otel.enable` is the **hive-tier** one,
|
||||||
which every hive runs. A bare `deploy.otel` would not say which
|
which every hive runs. A bare `deploy.otel` would not say which
|
||||||
it meant. A hive that does not run the swarm collector still runs
|
it meant. A hive that does not run the swarm collector still runs
|
||||||
its own, and reaches this one by name at
|
its own, and reaches this one by name at
|
||||||
|
|
|
||||||
|
|
@ -41,7 +41,7 @@ let
|
||||||
# ./otel.nix: `fetches`, the collector loading the file this unit writes
|
# ./otel.nix: `fetches`, the collector loading the file this unit writes
|
||||||
# under the hive's own leaf.
|
# under the hive's own leaf.
|
||||||
swarm-bao-hive-otel-oidc =
|
swarm-bao-hive-otel-oidc =
|
||||||
otel.collector.enable
|
otel.enable
|
||||||
&& hyperhiveCfg.hiveName != null
|
&& hyperhiveCfg.hiveName != null
|
||||||
&& havePair baoDeploy.clientCertFile baoDeploy.clientKeyFile
|
&& havePair baoDeploy.clientCertFile baoDeploy.clientKeyFile
|
||||||
&&
|
&&
|
||||||
|
|
|
||||||
|
|
@ -31,7 +31,7 @@ let
|
||||||
waits = senderAuth && !fetches;
|
waits = senderAuth && !fetches;
|
||||||
in
|
in
|
||||||
{
|
{
|
||||||
config = lib.mkIf (hyperhiveCfg.deploy.authelia.enable && otel.collector.enable && waits) {
|
config = lib.mkIf (hyperhiveCfg.deploy.authelia.enable && otel.enable && waits) {
|
||||||
systemd.services.hive-otel-client-secret = {
|
systemd.services.hive-otel-client-secret = {
|
||||||
after = [ "container@${autheliaMachine}.service" ];
|
after = [ "container@${autheliaMachine}.service" ];
|
||||||
requires = [ "container@${autheliaMachine}.service" ];
|
requires = [ "container@${autheliaMachine}.service" ];
|
||||||
|
|
|
||||||
|
|
@ -71,34 +71,64 @@ in
|
||||||
# every agent. See services.hyperhive.swarm.github.enable.
|
# every agent. See services.hyperhive.swarm.github.enable.
|
||||||
HYPERHIVE_GITHUB_DISABLED = "1";
|
HYPERHIVE_GITHUB_DISABLED = "1";
|
||||||
}
|
}
|
||||||
// {
|
// lib.optionalAttrs config.services.hyperhive.otel.enable (
|
||||||
# hive-c0re's OWN container-resource exporter (stats/otel_metrics.rs) reads
|
# OTEL config (`swarm.hiveOtel`, `deploy.hive-controller.otel`, gated on
|
||||||
# the STANDARD OTLP variable — the same one hive-metric and every agent
|
# this hive's `otel.enable`) -> read by meta.rs::otel_config and injected
|
||||||
# read — and lets the SDK resolve the URL, which appends the signal path
|
# as build-time `services.hyperhive.agent.otel.*` into every agent.
|
||||||
# (`/v1/metrics`). Handing the SDK an address programmatically instead
|
# Endpoint presence is the enable signal on the meta side; the
|
||||||
# takes it verbatim: it POSTs to the collector's root, gets a 404 on every
|
# optional fields are only emitted when set so absent values
|
||||||
# export, and says nothing, because OTLP export failures go to an error
|
# don't render no-op env lines.
|
||||||
# handler no binary here installs.
|
let
|
||||||
#
|
otel = config.services.hyperhive.otel;
|
||||||
# The bridge address, not `otel.collector.host`: on the host that name may
|
hiveOtel = config.services.hyperhive.swarm.hiveOtel;
|
||||||
# resolve to loopback (`gateway.localHostsEntry`), where the receiver does
|
controllerOtel = config.services.hyperhive.deploy.hive-controller.otel;
|
||||||
# not listen. Agents are not told an address at all; their module defaults
|
# The first hop, this hive's own collector, under the two spellings its
|
||||||
# to the collector's name.
|
# two kinds of caller need. Agents get the swarm-wide name, so their
|
||||||
OTEL_EXPORTER_OTLP_ENDPOINT = "http://${config.services.hyperhive.network.bridgeIp}:${toString config.services.hyperhive.swarm.hiveOtel.collectorPort}";
|
# builds carry nothing hive-specific; this daemon gets the bridge
|
||||||
}
|
# address, because on the host the name may resolve to loopback
|
||||||
//
|
# (`gateway.localHostsEntry`), where the receiver does not listen.
|
||||||
lib.optionalAttrs
|
agentFirstHop = "http://${otel.collector.host}:${toString hiveOtel.collectorPort}";
|
||||||
(config.services.hyperhive.deploy.hive-controller.otel.extraResourceAttributes != "")
|
hostFirstHop = "http://${config.services.hyperhive.network.bridgeIp}:${toString hiveOtel.collectorPort}";
|
||||||
|
in
|
||||||
{
|
{
|
||||||
HYPERHIVE_OTEL_EXTRA_RESOURCE_ATTRIBUTES =
|
# `otel.endpoint` means "where telemetry ultimately goes" and keeps
|
||||||
config.services.hyperhive.deploy.hive-controller.otel.extraResourceAttributes;
|
# that meaning; what agents are handed is the *first hop*, which is
|
||||||
|
# always this hive's own collector. Deriving it rather than
|
||||||
|
# redefining `endpoint` is what lets every existing deployment keep
|
||||||
|
# its configured value untouched.
|
||||||
|
HYPERHIVE_OTEL_ENDPOINT = agentFirstHop;
|
||||||
|
# hive-c0re's OWN container-resource exporter (stats/otel_metrics.rs)
|
||||||
|
# reads the STANDARD OTLP variable — the same one hive-metric and every
|
||||||
|
# agent read — and lets the SDK resolve the URL, which appends the
|
||||||
|
# signal path (`/v1/metrics`). Handing the SDK an address
|
||||||
|
# programmatically instead takes it verbatim: it POSTs to the
|
||||||
|
# collector's root, gets a 404 on every export, and says nothing,
|
||||||
|
# because OTLP export failures go to an error handler no binary here
|
||||||
|
# installs — the daemon logged "exporter enabled" and never delivered a
|
||||||
|
# sample, for as long as it had that exporter. The variable above cannot
|
||||||
|
# replace this one:
|
||||||
|
# it is the agent-config transport meta.rs reads, and the SDK does not
|
||||||
|
# know that name.
|
||||||
|
OTEL_EXPORTER_OTLP_ENDPOINT = hostFirstHop;
|
||||||
|
# The first hop is the collector's OTLP/HTTP receiver, which speaks
|
||||||
|
# protobuf regardless of what the upstream wants — `otel.protocol`
|
||||||
|
# describes the *upstream* link, and the collector's own exporter is
|
||||||
|
# what has to honour it (see nix/host-modules/otel.nix).
|
||||||
|
HYPERHIVE_OTEL_PROTOCOL = "http/protobuf";
|
||||||
}
|
}
|
||||||
# No upstream credential is emitted here, and its absence is the security half
|
// lib.optionalAttrs (controllerOtel.extraResourceAttributes != "") {
|
||||||
# of this design: it would put the upstream token where an agent could read
|
HYPERHIVE_OTEL_EXTRA_RESOURCE_ATTRIBUTES = controllerOtel.extraResourceAttributes;
|
||||||
# it. Nothing delivers the token into a container — no nspawn credential, no
|
}
|
||||||
# agent-readable header file. The collector holding the credential achieves
|
# HYPERHIVE_OTEL_HEADERS_CREDENTIAL is deliberately NOT emitted, and
|
||||||
# nothing while anything else hands out a copy, so there is exactly one
|
# its absence is the security half of this design: that variable would
|
||||||
# holder and it is on the host.
|
# put the upstream token into an agent's own settings.json. Nothing
|
||||||
|
# delivers the token into the container — no nspawn credential, no
|
||||||
|
# agent-readable header file.
|
||||||
|
#
|
||||||
|
# The rule: the collector holding the credential achieves nothing while
|
||||||
|
# anything else hands out a copy, so there is exactly one holder and it
|
||||||
|
# is on the host.
|
||||||
|
)
|
||||||
// {
|
// {
|
||||||
# In-cluster forge URL — the gateway vhost (`forge.<domain>`), which
|
# In-cluster forge URL — the gateway vhost (`forge.<domain>`), which
|
||||||
# nginx proxies to forgejo. Used both for internal API calls in
|
# nginx proxies to forgejo. Used both for internal API calls in
|
||||||
|
|
|
||||||
|
|
@ -320,7 +320,7 @@ in
|
||||||
description = ''
|
description = ''
|
||||||
Loopback TCP port nginx serves its `stub_status` counters on, at
|
Loopback TCP port nginx serves its `stub_status` counters on, at
|
||||||
`http://127.0.0.1:''${statusPort}/stub_status`. The hive's collector
|
`http://127.0.0.1:''${statusPort}/stub_status`. The hive's collector
|
||||||
(every hive runs one) reads it with its `nginx`
|
({option}`services.hyperhive.otel.enable`) reads it with its `nginx`
|
||||||
receiver.
|
receiver.
|
||||||
|
|
||||||
TCP rather than a unix socket because that receiver can only dial
|
TCP rather than a unix socket because that receiver can only dial
|
||||||
|
|
|
||||||
|
|
@ -55,6 +55,7 @@ let
|
||||||
h.gateway.enable
|
h.gateway.enable
|
||||||
h.gateway.dns.enable
|
h.gateway.dns.enable
|
||||||
h.network.enable
|
h.network.enable
|
||||||
|
h.otel.enable
|
||||||
h.snapshotStore.enable
|
h.snapshotStore.enable
|
||||||
];
|
];
|
||||||
in
|
in
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,11 @@
|
||||||
# The HIVE tier of the collector pair: every hive runs one, on the host that
|
# Hive-wide OTEL stats export, and the HIVE tier of the collector pair.
|
||||||
# runs hive-c0re (`deploy.hive-controller.enable`). Agents export to it at
|
# Set ONCE here at host level; the meta-flake renderer
|
||||||
# the fixed name `services.hyperhive.otel.collector.host`, which this hive's
|
# (`hive-c0re/src/meta.rs::otel_config`) reads the HYPERHIVE_OTEL_* env
|
||||||
# resolver answers with its own bridge IP, so an agent's build carries
|
# exported off hive-c0re's unit (see ./hive-c0re) and injects the matching
|
||||||
# nothing hive-specific.
|
# `services.hyperhive.agent.otel.*` build-time config into EVERY agent (mirroring the
|
||||||
|
# CA-cert injection), so each agent's harness exports its own Claude Code
|
||||||
|
# stats directly to the collector. There is no per-agent opt-in — this is
|
||||||
|
# the single switch for the whole hive.
|
||||||
#
|
#
|
||||||
# This tier receives from this hive's agents and forwards to the swarm's
|
# This tier receives from this hive's agents and forwards to the swarm's
|
||||||
# collector (./swarm-otel.nix). It holds no credential and picks no
|
# collector (./swarm-otel.nix). It holds no credential and picks no
|
||||||
|
|
@ -38,8 +41,6 @@ let
|
||||||
# The hive's own store identity. The collector authenticates to the swarm
|
# The hive's own store identity. The collector authenticates to the swarm
|
||||||
# as the hive, so it reads its secret as the hive: the hive leaf's policy
|
# as the hive, so it reads its secret as the hive: the hive leaf's policy
|
||||||
# (`swarm-secret-client`'s `policy::render`) grants `swarm/hives/<hive>/*`.
|
# (`swarm-secret-client`'s `policy::render`) grants `swarm/hives/<hive>/*`.
|
||||||
runsHive = config.services.hyperhive.otel.collector.enable;
|
|
||||||
|
|
||||||
hiveLeaf =
|
hiveLeaf =
|
||||||
config.services.hyperhive.deploy.bao.clientCertFile != null
|
config.services.hyperhive.deploy.bao.clientCertFile != null
|
||||||
&& config.services.hyperhive.deploy.bao.clientKeyFile != null;
|
&& config.services.hyperhive.deploy.bao.clientKeyFile != null;
|
||||||
|
|
@ -54,19 +55,39 @@ in
|
||||||
name = "hive-otel";
|
name = "hive-otel";
|
||||||
consumers = [ "opentelemetry-collector" ];
|
consumers = [ "opentelemetry-collector" ];
|
||||||
hostUnit = true;
|
hostUnit = true;
|
||||||
enable = runsHive;
|
enable = config.services.hyperhive.otel.enable;
|
||||||
})
|
})
|
||||||
(lib.mkRemovedOptionModule [ "services" "hyperhive" "otel" "enable" ] ''
|
(lib.mkRenamedOptionModule
|
||||||
Every hive runs its own telemetry collector, and every agent exports to
|
[ "services" "hyperhive" "otel" "collector" "port" ]
|
||||||
it by default, so there is no switch. Remove this definition.
|
[ "services" "hyperhive" "swarm" "hiveOtel" "collectorPort" ]
|
||||||
'')
|
)
|
||||||
(lib.mkRemovedOptionModule [ "services" "hyperhive" "otel" "collector" "port" ] ''
|
|
||||||
The hive collector's receiver port is fixed at 4318: agents dial that
|
|
||||||
literal. Remove this definition.
|
|
||||||
'')
|
|
||||||
];
|
];
|
||||||
|
|
||||||
options.services.hyperhive.otel = {
|
options.services.hyperhive.otel = {
|
||||||
|
enable = lib.mkEnableOption ''
|
||||||
|
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
|
||||||
|
OpenTelemetry. One switch for all agents.
|
||||||
|
|
||||||
|
Enabling this also runs this hive's collector on the host: there is
|
||||||
|
exactly one way telemetry leaves this hive, and it is through that
|
||||||
|
collector. Agents export unauthenticated to
|
||||||
|
{option}`services.hyperhive.otel.collector.host`, a name this hive's
|
||||||
|
resolver answers with a bridge address only its own containers can
|
||||||
|
reach.
|
||||||
|
|
||||||
|
That collector forwards to the swarm's
|
||||||
|
({option}`services.hyperhive.deploy.swarm-otel.enable`), which holds the
|
||||||
|
upstream credential and writes the swarm's store. So an agent never
|
||||||
|
sees the credential, and neither does this tier.
|
||||||
|
|
||||||
|
⚠️ 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 does not survive independently
|
||||||
|
of anything host-side
|
||||||
|
'';
|
||||||
|
|
||||||
endpoint = lib.mkOption {
|
endpoint = lib.mkOption {
|
||||||
type = lib.types.str;
|
type = lib.types.str;
|
||||||
default = "";
|
default = "";
|
||||||
|
|
@ -150,51 +171,25 @@ in
|
||||||
'';
|
'';
|
||||||
};
|
};
|
||||||
|
|
||||||
collector.enable = lib.mkOption {
|
|
||||||
type = lib.types.bool;
|
|
||||||
readOnly = true;
|
|
||||||
default =
|
|
||||||
config.services.hyperhive.deploy.hive-controller.enable
|
|
||||||
|| config.services.hyperhive.deploy.allSwarmServices;
|
|
||||||
defaultText = lib.literalExpression "deploy.hive-controller.enable || deploy.allSwarmServices";
|
|
||||||
description = ''
|
|
||||||
Whether this host runs the hive tier's collector. Not a switch:
|
|
||||||
every host that runs a hive
|
|
||||||
({option}`services.hyperhive.deploy.hive-controller.enable`) runs
|
|
||||||
one, and so does a host carrying all the swarm's shared services
|
|
||||||
({option}`services.hyperhive.deploy.allSwarmServices`), whose own
|
|
||||||
containers export to it.
|
|
||||||
|
|
||||||
Agents export to it unauthenticated at
|
|
||||||
{option}`services.hyperhive.otel.collector.host`, a name this
|
|
||||||
hive's resolver answers with a bridge address only its own
|
|
||||||
containers can reach. It forwards to the swarm's collector
|
|
||||||
({option}`services.hyperhive.deploy.swarm-otel.enable`), which holds
|
|
||||||
the upstream credential and writes the swarm's store, so neither an
|
|
||||||
agent nor this tier ever sees the credential.
|
|
||||||
|
|
||||||
⚠️ The collector is in the path of all telemetry. It runs on the
|
|
||||||
same host as the agents and restarts on failure; telemetry is not
|
|
||||||
the control plane, so degraded telemetry is not degraded
|
|
||||||
operation, but the export does not survive independently of
|
|
||||||
anything host-side.
|
|
||||||
'';
|
|
||||||
};
|
|
||||||
|
|
||||||
collector.host = lib.mkOption {
|
collector.host = lib.mkOption {
|
||||||
type = lib.types.str;
|
type = lib.types.str;
|
||||||
readOnly = true;
|
readOnly = true;
|
||||||
default = "hive-collector.hive";
|
# Total on a null swarm domain, as in ./swarm-otel-service.nix: the
|
||||||
|
# required-domain assertion should be what an operator sees.
|
||||||
|
default = "hive-collector.${
|
||||||
|
if config.services.hyperhive.swarm.domain == null then
|
||||||
|
"invalid"
|
||||||
|
else
|
||||||
|
config.services.hyperhive.swarm.domain
|
||||||
|
}";
|
||||||
|
defaultText = lib.literalExpression ''"hive-collector.''${services.hyperhive.swarm.domain}"'';
|
||||||
description = ''
|
description = ''
|
||||||
The name agents reach this hive's collector on. A literal, the same
|
The name agents reach this hive's collector on. The same string on
|
||||||
on every hive and in every swarm: each hive's own resolver answers
|
every hive: each hive's own resolver answers it with its own bridge
|
||||||
it with its own bridge IP, so an agent's build carries neither a
|
IP, so an agent's build carries no hive-specific address.
|
||||||
swarm domain nor a hive-specific address. The agent module's
|
|
||||||
`services.hyperhive.agent.otel.endpoint` default spells the same
|
|
||||||
name.
|
|
||||||
|
|
||||||
It sits outside every domain a hive or the swarm serves: a hive's
|
No hive can claim this name as its domain:
|
||||||
domain is `<hiveName>.<swarm.domain>`, so it never ends in `.hive`.
|
`nix/reserved-hive-fragments.nix` forbids `hive` in any hive name.
|
||||||
'';
|
'';
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -279,14 +274,12 @@ in
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
# Shared by every hive's otel tier and its agents. Read-only because the agent
|
# Shared by every hive's otel tier and its agents, so an agent's build
|
||||||
# module's `otel.endpoint` default spells the same literal, and a host that
|
# carries the same port whichever hive renders it.
|
||||||
# moved the port would leave its agents dialling the old one.
|
|
||||||
options.services.hyperhive.swarm.hiveOtel = {
|
options.services.hyperhive.swarm.hiveOtel = {
|
||||||
collectorPort = lib.mkOption {
|
collectorPort = lib.mkOption {
|
||||||
type = lib.types.port;
|
type = lib.types.port;
|
||||||
default = 4318;
|
default = 4318;
|
||||||
readOnly = true;
|
|
||||||
description = ''
|
description = ''
|
||||||
Port of every hive collector's OTLP/HTTP receiver, at that hive's
|
Port of every hive collector's OTLP/HTTP receiver, at that hive's
|
||||||
`services.hyperhive.network.bridgeIp`: the port agents dial at
|
`services.hyperhive.network.bridgeIp`: the port agents dial at
|
||||||
|
|
@ -314,7 +307,7 @@ in
|
||||||
'';
|
'';
|
||||||
};
|
};
|
||||||
|
|
||||||
config = lib.mkIf runsHive (
|
config = lib.mkIf config.services.hyperhive.otel.enable (
|
||||||
let
|
let
|
||||||
otel = config.services.hyperhive.otel;
|
otel = config.services.hyperhive.otel;
|
||||||
autheliaCfg = config.services.hyperhive.swarm.authelia;
|
autheliaCfg = config.services.hyperhive.swarm.authelia;
|
||||||
|
|
@ -920,7 +913,7 @@ in
|
||||||
# cleanly, retries forever, and reports nothing to anyone.
|
# cleanly, retries forever, and reports nothing to anyone.
|
||||||
assertion = senderAuth;
|
assertion = senderAuth;
|
||||||
message = ''
|
message = ''
|
||||||
This hive runs a telemetry collector (every hive does) but has no
|
services.hyperhive.otel.enable is true but this hive has no
|
||||||
identity to present to the swarm's collector:
|
identity to present to the swarm's collector:
|
||||||
|
|
||||||
services.hyperhive.otel.clientSecretFile = ${
|
services.hyperhive.otel.clientSecretFile = ${
|
||||||
|
|
@ -955,7 +948,7 @@ in
|
||||||
"none"
|
"none"
|
||||||
]);
|
]);
|
||||||
message = ''
|
message = ''
|
||||||
This hive runs a telemetry collector, which runs a journald
|
services.hyperhive.otel.enable is true, which runs a journald
|
||||||
receiver reading /var/log/journal for hive-c0re's own crash-watch
|
receiver reading /var/log/journal for hive-c0re's own crash-watch
|
||||||
log line — but services.journald.storage is
|
log line — but services.journald.storage is
|
||||||
"${config.services.journald.storage}" on this host.
|
"${config.services.journald.storage}" on this host.
|
||||||
|
|
|
||||||
|
|
@ -60,7 +60,7 @@ let
|
||||||
# Whether the swarm collector's client is actually registered here.
|
# Whether the swarm collector's client is actually registered here.
|
||||||
#
|
#
|
||||||
# Asked of the client list rather than re-derived from the conditions
|
# Asked of the client list rather than re-derived from the conditions
|
||||||
# that produce it (a non-empty audience set, authelia
|
# that produce it (`otel.enable`, a non-empty audience set, authelia
|
||||||
# being co-located). A second copy of that predicate is a second thing
|
# being co-located). A second copy of that predicate is a second thing
|
||||||
# to keep in step, and the two drifting is not a build failure: a rule
|
# to keep in step, and the two drifting is not a build failure: a rule
|
||||||
# naming an unregistered client is refused by authelia's *startup*
|
# naming an unregistered client is refused by authelia's *startup*
|
||||||
|
|
|
||||||
|
|
@ -1446,7 +1446,7 @@ let
|
||||||
#
|
#
|
||||||
# "Does the swarm have a collector" is not a question that can be false —
|
# "Does the swarm have a collector" is not a question that can be false —
|
||||||
# the swarm always has at least one instance of all the swarm level
|
# the swarm always has at least one instance of all the swarm level
|
||||||
# services — so gating the forwarder on `otel.collector.enable` or
|
# services — so gating the forwarder on `otel.enable` or
|
||||||
# `deploy.swarm-otel.enable` would ask about the wrong host and render no
|
# `deploy.swarm-otel.enable` would ask about the wrong host and render no
|
||||||
# forwarder wherever the answer happened to be no.
|
# forwarder wherever the answer happened to be no.
|
||||||
#
|
#
|
||||||
|
|
|
||||||
|
|
@ -35,7 +35,7 @@ in
|
||||||
{
|
{
|
||||||
# `enable` is `services.hyperhive.deploy.swarm-otel.enable` — see ./deploy.nix.
|
# `enable` is `services.hyperhive.deploy.swarm-otel.enable` — see ./deploy.nix.
|
||||||
# ⚠️ That is the SWARM collector. The per-hive one keeps its own
|
# ⚠️ That is the SWARM collector. The per-hive one keeps its own
|
||||||
# `services.hyperhive.otel.collector.enable` (./otel.nix) and is a different
|
# `services.hyperhive.otel.enable` (./otel.nix) and is a different
|
||||||
# option entirely — every hive runs that one.
|
# option entirely — every hive runs that one.
|
||||||
options.services.hyperhive.swarm.otel = {
|
options.services.hyperhive.swarm.otel = {
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -49,6 +49,10 @@ in
|
||||||
# host. Same switch, same rule, one attribute path.
|
# host. Same switch, same rule, one attribute path.
|
||||||
config.services.hyperhive.deploy.matrix.enable = lib.mkDefault deployCfg.allSwarmServices;
|
config.services.hyperhive.deploy.matrix.enable = lib.mkDefault deployCfg.allSwarmServices;
|
||||||
|
|
||||||
|
# The collector that feeds the pair above (note: no `swarm.` prefix,
|
||||||
|
# this is ./otel.nix's existing per-hive option).
|
||||||
|
config.services.hyperhive.otel.enable = lib.mkDefault deployCfg.allSwarmServices;
|
||||||
|
|
||||||
# The rest of the shared services, from the same switch and for the same
|
# The rest of the shared services, from the same switch and for the same
|
||||||
# reason.
|
# reason.
|
||||||
#
|
#
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue