Raxol.Core.Telemetry.Invariants (Raxol Core v2.7.0)

Copy Markdown View Source

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.InvariantSentinel fails 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]]
end

That 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

classification()

@type classification() :: :invariant | :peer | :operational

How a fired event should be read.

event()

@type event() :: [atom()]

A telemetry event name, or (for a dynamic family) its prefix.

Functions

classify(event, events, dynamic)

@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.

scan_lib!(lib_path)

@spec scan_lib!(Path.t()) :: [event()]

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.