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

Copy Markdown View Source

Arms a registry's invariant_events/0 for the duration of each test and fails the test if one of them fired.

defmodule Raxol.Payments.SettlementTest do
  use ExUnit.Case, async: false
  use Raxol.Core.Telemetry.InvariantSentinel, registry: Raxol.Payments.Telemetry
end

registry: is the only option and it is required: it names a module built with use Raxol.Core.Telemetry.Invariants. An invariant event is one that can only fire if Raxol itself is wrong. Before this mechanism existed, such an event was emitted, logged, and ignored -- which is how a turn that delivered zero content shipped while the library was warning about it on stderr. A guard that nothing asserts on is not a guard.

Why this module lives in lib/ and not test/support/

Dependent packages use it from THEIR tests, and a dependency's test/support tree is not on the consumer's load path -- only lib/ is compiled into the delivered application. ExUnit is therefore touched only inside functions that this module injects into, or that are called from, a test module (ExUnit.Callbacks.on_exit/1, ExUnit.Assertions.flunk/1). Nothing here requires ExUnit at runtime for a normal library consumer, and nothing here is executed by simply loading raxol_core.

Declaring an invariant on purpose

A test that DELIBERATELY drives a bad path declares it:

@tag expect_invariant: [[:raxol, :payments, :xochi, :unchecked_settlement]]

A single event may be given unwrapped (expect_invariant: [:raxol, :payments, :xochi, :unchecked_settlement]). The declaration is an ASSERTION, not a mute button: such a test also fails when the event does NOT fire. That is the point -- it converts "this test happens to drive a bad path" into "this test pins the bad path". Declaring an event the registry does not classify as :invariant is itself a failure, so the tag cannot drift away from the registry.

Only sound in async: false modules

:telemetry handlers are global and run in the EMITTING process, so a handler cannot tell which concurrently-running test caused an event: an invariant fired by test A would be reported against test B. Every module that uses this sentinel MUST therefore be async: false. A few hundred milliseconds of serial runtime is worth a guard that cannot misattribute.

Pid-ancestry filtering is deliberately NOT attempted: $ancestors does not survive the Task.Supervisor hops these systems use, so it would silently drop the events most worth catching.

How the events survive the test process

The handler runs in whichever process emitted the event, and the verdict is rendered from on_exit/1 (the only hook that runs after the test body), by which time the test process is already gone -- so messaging self() from the handler cannot work. Fired events are parked in a public ETS table owned by one long-lived collector process, keyed by the per-test handler id, and dropped when the verdict is rendered. Table and collector names are derived from the registry module, so two packages' sentinels cannot collide in one BEAM.

Summary

Functions

Attach a per-test handler for every invariant event in registry and register the end-of-test verdict. Called by the setup that __using__/1 injects.

Functions

arm(context, registry)

@spec arm(map(), module()) :: :ok

Attach a per-test handler for every invariant event in registry and register the end-of-test verdict. Called by the setup that __using__/1 injects.