otel: forward each agent container's journal to its hive collector

An agent container writes a complete journal — 991 MB and nine days deep
on this hive — that nothing outside it can read: the host-side
per-container journal directory is an id-mapped bind mount, and journald
writes nothing into it. So the reader has to run inside the container,
and the path it would push to did not exist.

Three tiers, one vertical slice, because any two of them alone are
silent:

- the agent container gains an `opentelemetry-collector` with a
  `journald` receiver aimed at its own journal and an exporter aimed at
  the same base address every in-process producer already exports to.
- the hive collector gains `service.pipelines.logs`. Without it the
  `otlp` receiver answers 404 on `/v1/logs` — measured, and
  indistinguishable from a route that was never meant to exist.
- the swarm collector gains a per-hive `logs/<hive>` pipeline beside
  `metrics/<hive>`. Without it the push is accepted, answered 200, and
  routed nowhere.

The receiver's `directory` is stated rather than inherited, and that is
the load-bearing line: its default is the RUNTIME journal
(`/run/log/journal`), which in an agent container is empty. Left at the
default this whole path validates, starts, reports healthy and forwards
nothing. The assertion beside it covers the same silence from the other
end — a `volatile` or `none` journald storage empties the directory the
receiver reads.

Attribution follows the tier that can prove it. The forwarder stamps
`agent`, which no host-side reader could supply; `hive` is deliberately
left to the swarm tier, which upserts it from whichever receiver
accepted the sample, precisely so the label comes from something the
sender cannot write.

No `units` allowlist, unlike the swarm tier's journald receiver. That
one needs one because the host's journal also holds an operator's own
session; a container's journal is the harness and what the harness
spawns. Measured volume is 20827 entries / 6.3 MB per agent per day,
with nothing logging below `info` — so the receiver's `info` default
filters nothing and there is no bill to justify a knob.

Agent containers only, per the ruling on the issue: swarm services need
one forwarder per service container and get re-measured once this works.

Part of #3940.
This commit is contained in:
atlas 2026-09-11 02:14:08 +02:00 committed by mara
commit 68711796ef
3 changed files with 156 additions and 2 deletions

View file

@ -532,6 +532,30 @@ in
];
exporters = [ swarmName ];
};
# The second signal this tier carries, and the reason it exists
# is that an agent's journal is unreadable from here: the
# host-side per-container journal directory is an id-mapped bind
# mount journald never writes into, so each agent container runs
# its own forwarder (nix/agent-modules/otel.nix) and pushes over
# the `otlp` receiver above. Without this pipeline that receiver
# answers 404 on `/v1/logs` — measured, and indistinguishable
# from a route that was never meant to exist.
#
# One receiver, two pipelines: `otlp` accepts both signals and
# each pipeline takes the one it names. The exporter needs no
# change either — its `endpoint` is a base, and otlphttp appends
# `/v1/logs` to it exactly as it appends `/v1/metrics`.
service.pipelines.logs = {
receivers = [ "otlp" ];
# `resourcedetection` and NOT `deltatocumulative`: that one is
# a metrics-only processor, and naming it here is a startup
# failure rather than a no-op. Host identity applies to both
# signals for the same reason — a line is only comparable
# across hives once it says which machine collected it.
processors = [ "resourcedetection" ];
exporters = [ swarmName ];
};
}
// lib.optionalAttrs senderAuth {
extensions.${authName} = {

View file

@ -1255,6 +1255,28 @@ in
exporters = exporterNames;
};
}
# A hive's logs, and the receiver is the SAME one its metrics
# arrive on: a receiver is not per-signal, so `otlp/${h}`
# feeds this pipeline and `metrics/${h}` above with no second
# port, credential or audience. Which is also why the `hive`
# label stays trustworthy for logs without any new mechanism
# — it still comes from which receiver accepted the sample.
#
# The senders are agent containers, one forwarder each
# (nix/agent-modules/otel.nix), via their own hive's collector.
# Without this pipeline that whole path terminates here: the
# receiver accepts the push, answers 200, and the records go
# nowhere.
// lib.optionalAttrs collectLogs (
lib.mapAttrs' (
h: _:
lib.nameValuePair "logs/${h}" {
receivers = [ "otlp/${h}" ];
processors = [ "resource/${h}" ];
exporters = logExporterNames;
}
) hivePorts
)
# Logs fan out exactly as metrics do: the swarm's store when it
# runs, the operator's upstream when one is configured, both
# when both. The local store is a destination rather than the