hyperhive/nix/module-eval/agent-otel.nix
atlas 7fa13b592f module-eval: pin the severity mapping's direction and the panel
The direction is the part a reviewer cannot check by looking, so it is
asserted at both ends of the table and in both tiers' groups: an inverted
mapping still maps every value to something, and a case that only asks
whether a severity parser exists passes on the exact defect. The reader
that turns a rendered operator list back into a PRIORITY -> name function
lives in lib.nix, since both tiers need it.

The panel is asserted on its query rather than its title, because a panel
that keeps the title and loses the expression renders an empty graph that
looks exactly like zero prioless lines.
2026-09-20 14:23:56 +02:00

130 lines
5.1 KiB
Nix

# `checks.module-eval-agent-otel` — see ./lib.nix for the shared
# rationale (why this suite exists, naming convention, "evaluates
# not executes").
{
pkgs,
lib,
self,
nixosSystem,
}:
let
inherit
(import ./lib.nix {
inherit
pkgs
lib
self
nixosSystem
;
})
agent
journaldSeverityOf
journaldSeverityOverwritesText
runGroup
;
# The log path's three hops, one fixture each. Nothing carries a journal
# record end to end at eval time, so what these defend is the part no tier
# can check for itself: each hop's output is the next hop's input, and
# every mismatch between them is silent — a push accepted and routed
# nowhere, a receiver pointed at an empty directory, a pipeline that does
# not exist.
agentBridge = "http://10.42.0.1:4318";
agentOtel = agent {
otel.enable = true;
otel.endpoint = agentBridge;
};
# The same agent over the other wire protocol. An exporter's NAME is what
# selects it, so this is where a defined exporter and the pipeline's
# reference to it can drift apart.
agentOtelGrpc = agent {
otel.enable = true;
otel.endpoint = agentBridge;
otel.protocol = "grpc";
};
agentNoOtel = agent { };
agentSettings = machine: machine.services.opentelemetry-collector.settings;
cases = [
{
# The journald receiver's own default directory is the RUNTIME
# journal, and a container that stores persistently leaves that
# empty. At the default the forwarder validates, starts, reports
# healthy and ships nothing, so this one literal is the difference
# between the path working and silently not.
name = "the agent forwarder reads the persistent journal, not the runtime one";
ok = ((agentSettings agentOtel).receivers.journald.directory or null) == "/var/log/journal";
}
{
# ⚠️ The direction, and the only part of this mapping a reviewer cannot
# check by looking: syslog's PRIORITY counts DOWN in urgency (0 = emerg,
# 7 = debug) where the OTEL severity counts UP, so a copy wired across
# renders debug as critical while every line still arrives and every
# field is populated. Asserted at BOTH ENDS of the table — an inverted
# mapping still maps every value to something, so "a severity parser is
# configured" passes on the exact defect. The swarm tier's receiver gets
# the same case over in ./swarm-otel-core.nix; the two import one file,
# and these are what say they still do.
name = "the agent forwarder maps PRIORITY to severity the right way up";
ok =
let
sev = journaldSeverityOf (agentSettings agentOtel).receivers.journald;
in
sev "0" == "fatal"
&& sev "3" == "error"
&& sev "4" == "warn"
&& sev "6" == "info"
&& sev "7" == "debug";
}
{
# The other half of the same operator, and the half that looks already
# handled. Without `overwrite_text` the parser sets the severity NUMBER
# from the mapping and leaves the severity TEXT as the raw value it
# matched — so `severity_text` reaches the log store as the literal "6".
# That is a populated field which passes any check asking merely whether
# severity is set, and which nothing renders as a level. VictoriaLogs has
# no ingest parameter naming a level field, so the text is the whole
# interface.
name = "the agent forwarder writes a level name, not the raw priority digit";
ok = journaldSeverityOverwritesText (agentSettings agentOtel).receivers.journald;
}
{
# The hop's two ends: what it reads, and where what it reads goes.
# The endpoint is compared against the value the fixture handed the
# option rather than a literal spelled here, so an exporter that
# stopped reading the option fails instead of matching a constant
# that travelled beside it.
name = "the agent forwarder ships the journal to the endpoint its hive gave it";
ok =
let
s = agentSettings agentOtel;
p = s.service.pipelines.logs;
in
p.receivers == [ "journald" ]
&& p.exporters != [ ]
&& lib.all (e: (s.exporters ? ${e}) && s.exporters.${e}.endpoint == agentBridge) p.exporters;
}
{
# Presence control for the two cases above: with the switch off there
# is no collector in the container at all, so their passing is about
# the wiring rather than about a unit that renders regardless.
name = "an agent that has not opted into telemetry runs no collector";
ok = !agentNoOtel.services.opentelemetry-collector.enable;
}
{
# `otlp` and `otlphttp` are different components and the protocol
# option picks which one is defined. A pipeline left naming the other
# is a startup failure; an exporter no pipeline names is silence.
name = "the agent forwarder's exporter and its pipeline agree on the protocol";
ok =
let
s = agentSettings agentOtelGrpc;
in
(s.exporters ? otlp) && s.service.pipelines.logs.exporters == [ "otlp" ];
}
];
in
runGroup "agent-otel" cases