From ece21b0f7f590c479ccd66cea72c5f65dfe6ef5a Mon Sep 17 00:00:00 2001 From: atlas Date: Sun, 23 Aug 2026 19:32:45 +0200 Subject: [PATCH] feat(#3554): let a hive-owned service declare a scrape target --- nix/host-modules/otel.nix | 59 +++++++++++++++++++++++++++++++++++++-- 1 file changed, 57 insertions(+), 2 deletions(-) diff --git a/nix/host-modules/otel.nix b/nix/host-modules/otel.nix index b8e95529..a57af75e 100644 --- a/nix/host-modules/otel.nix +++ b/nix/host-modules/otel.nix @@ -193,6 +193,41 @@ in ''; }; + scrapeTargets = lib.mkOption { + type = lib.types.attrsOf lib.types.str; + default = { }; + example = lib.literalExpression ''{ hive-forge = "127.0.0.1:3000"; }''; + description = '' + Prometheus exposition endpoints this hive's collector scrapes, as + ` = ":"`. + + **A service declares its own entry, from its own module, under its + own `enable`** — the same rule as the swarm tier's option of the + same name, and for the same reason: an entry exists only where the + service that named it runs, so the scraper and its target are + co-located by construction rather than by luck. + + ⚠️ **This tier, not the swarm one, is where a HIVE-owned target + belongs.** The two are not interchangeable: a swarm service does + not belong to a hive, so its samples must never acquire a `hive` + label — which is why that scraper lives one tier up. Putting a + hive-owned target there would either mislabel it or leave it + unattributed. + + Scraping is unauthenticated on purpose. This receiver is a + prometheus-to-OTLP converter sitting next to what it reads, in the + same trust position as the agents already pushing to this + collector; the authenticated hop is the collector's own export + onward, which is one hop for the whole hive rather than one per + target. + + Empty by default, and that is the shipped case — no scrape + receiver is emitted at all, because a `prometheus` receiver with + nothing to scrape is a config that renders, starts and collects + nothing. + ''; + }; + clientSecretFile = lib.mkOption { type = lib.types.nullOr lib.types.str; default = @@ -312,7 +347,20 @@ in # prove this config STARTS, never mind that a sample arrives. validateConfigFile = true; settings = { - receivers.otlp.protocols.http.endpoint = listen; + receivers = { + otlp.protocols.http.endpoint = listen; + } + # Only when a service has actually declared a target. An enabled + # `prometheus` receiver with an empty `scrape_configs` renders, + # validates and starts perfectly while reading nothing — and the + # empty set is the default, so that inert shape would be what + # most hives deploy. + // lib.optionalAttrs (otel.scrapeTargets != { }) { + prometheus.config.scrape_configs = lib.mapAttrsToList (job: target: { + job_name = job; + static_configs = [ { targets = [ target ]; } ]; + }) otel.scrapeTargets; + }; # One destination, and it is the swarm's collector. This tier # holds no upstream credential and writes no store: it receives @@ -352,7 +400,14 @@ in service.extensions = lib.optional senderAuth authName; service.pipelines.metrics = { - receivers = [ "otlp" ]; + # EXTENDED, not replaced. Assigning here instead of appending + # would drop `otlp` — the hive would stop receiving from its + # own agents while still rendering a config the collector + # starts cleanly on, so nothing would report the loss. The + # `optional` matters too: a pipeline naming a receiver that no + # longer exists is a startup failure, which `validateConfigFile` + # below turns into a build failure rather than a broken deploy. + receivers = [ "otlp" ] ++ lib.optional (otel.scrapeTargets != { }) "prometheus"; exporters = [ swarmName ]; }; }