Declares the :telemetry events a package emits, each classified as exactly
one of :invariant, :peer or :operational, and provides the source
scanner that keeps such a declaration honest.
Why this exists
A telemetry event is only a guard if something fails when it fires.
Raxol.AgentClientProtocol.Session detected a turn that produced zero
session/update frames for a non-empty prompt, logged a warning AND emitted
an event -- and the bug shipped anyway, because no test asserted on the
event and the package did not even depend on :telemetry, so the emit site's
Code.ensure_loaded?/1 guard was permanently false. Classification turns
"the system noticed and told nobody" into "the suite fails":
:invariant-- can only fire if Raxol itself is wrong. Enforced:Raxol.Core.Telemetry.InvariantSentinelfails any test in which one fires unless the test declares it with@tag expect_invariant:.:peer-- caused by a remote party misbehaving (a chain RPC, a chat API, a peer agent, a malformed inbound frame). Not enforced.:operational-- normal life: cache hits, policy decisions, lifecycle, retries, user API misuse, filesystem and clock reality. Not enforced.
The criterion, applied literally: if a peer, the network, the chain, the
filesystem, the clock, or a user can cause it, it is NOT an invariant. Err
toward :peer/:operational. A false invariant makes the suite flaky, and a
flaky guard gets deleted, which is strictly worse than a missed one.
Why this module lives in lib/ and not test/support/
Dependent packages use this from THEIR test files, and a dependency's
test/support tree is not on a consumer's load path -- only its lib/ is
compiled into the delivered application. Nothing here requires ExUnit at
runtime: this module never touches it, and
Raxol.Core.Telemetry.InvariantSentinel touches it only inside functions it
injects into (or is called from) a test module.
Usage
defmodule Raxol.Payments.Telemetry do
use Raxol.Core.Telemetry.Invariants,
events: %{
[:raxol, :payments, :xochi, :unchecked_settlement] => :invariant,
[:raxol, :payments, :spend] => :operational
},
dynamic: [[:raxol, :earn, :buyer, :queue]]
endThat injects events/0, invariant_events/0, dynamic_families/0 and
classification/1.
events: must be written as a literal map of literal event names, because
the registry's whole value is being readable from source: a computed map
could not be checked at compile time, and a duplicate key in a computed map
would silently collapse rather than raise.
Dynamic families
Some events build their final segment at runtime
([:raxol, :earn, :buyer, :queue, suffix]). A source scan cannot enumerate
those names, so the family prefix is declared in dynamic: instead. An entry
is either a bare family (classified :operational) or a
{family, classification} pair. A dynamic family may NOT be :invariant:
the sentinel cannot subscribe to a name it cannot spell.
Completeness is asserted, not trusted
scan_lib!/1 parses a package's lib/ for the event literals actually
passed to an emit call, so a per-package registry test can fail when a new
event is added without anyone classifying it. That test is the load-bearing
half of the mechanism.
Summary
Types
How a fired event should be read.
A telemetry event name, or (for a dynamic family) its prefix.
Functions
Resolve event against a static registry map and a list of
{family, classification} dynamic entries.
Parse every .ex file under lib_path and return the sorted unique
[:raxol, ...] event literals that appear as the FIRST ARGUMENT of an emit
call.
Types
@type classification() :: :invariant | :peer | :operational
How a fired event should be read.
@type event() :: [atom()]
A telemetry event name, or (for a dynamic family) its prefix.
Functions
@spec classify(event(), %{required(event()) => classification()}, [ {event(), classification()} ]) :: classification() | nil
Resolve event against a static registry map and a list of
{family, classification} dynamic entries.
Exact matches win; otherwise the longest matching family prefix does, so a narrower family can refine a broader one.
Parse every .ex file under lib_path and return the sorted unique
[:raxol, ...] event literals that appear as the FIRST ARGUMENT of an emit
call.
An emit call is any call -- local, or qualified by any module -- named
execute, span, emit_telemetry or emit_event whose first argument is a
literal [:raxol, ...] list of atoms. The rule is deliberately
module-agnostic: this repo emits through :telemetry.execute/3 directly, and
also through wrappers (Raxol.Core.Telemetry.TraceContext.execute/3, a
package's own Telemetry.span/3) that a :telemetry-only rule would miss,
leaving a correctly classified event looking like a phantom registry row.
In the other direction, :telemetry.attach_many/4 takes event names too, but
subscribing is not emitting, and a registry that demanded classification of
events nobody emits would be noise that gets the completeness test disabled.
A first argument given as a module attribute (@telemetry_event [:raxol, ...] then :telemetry.execute(@telemetry_event, ...)) is resolved
against the literal attribute definitions in the same file.
The scan is AST-based on purpose: an event named in a moduledoc or a comment
is prose, not an emit site. Event names assembled at runtime
([:raxol, :earn, :buyer, :queue, suffix]) are not literals, so they never
appear here -- declare them as dynamic: families instead.
Raises when lib_path is not a directory or holds no .ex files, so a moved
source tree fails loudly instead of letting a completeness test pass on an
empty scan.