ForgeCredoChecks.TelemetryControlFlow (forge_credo_checks v0.8.0)

Copy Markdown View Source

Basics

This check is disabled by default.

Learn how to enable it via .credo.exs.

This check has a base priority of high and works with any version of Elixir.

Explanation

Telemetry handlers must be observation-only; using them as a control-flow bus is a silent-degradation smell.

Why

:telemetry handlers are meant to observe events (emit metrics, record spans, log) without influencing the system they observe. When a handler forwards a load-bearing signal to a process -- send/2 to a pid, a GenServer.call/cast, or any application GenServer client API -- the telemetry bus silently becomes part of the control path. The failure mode is quiet: a raising handler is auto-detached by the telemetry runtime, so the behavior it drove simply stops with no error and no restart. The idiomatic mechanism for delivering signals between processes is Phoenix.PubSub.

This check flags a :telemetry.attach/4 or :telemetry.attach_many/4 whose handler argument contains control-flow side effects: send/2, :erlang.send/2, GenServer.call/2,3, or GenServer.cast/2.

Detection covers two forms:

  1. Inline anonymous functions -- a fn literal passed directly as the handler argument.
  2. Same-file function captures -- &func_name/arity or &__MODULE__.func_name/arity where the captured function is defined in the same source file. The check resolves the function body and inspects it for control-flow calls.

Observation-only handlers (metric emission, span recording, logging) are not flagged regardless of form.

Known limits

  • Named MFA-tuple handlers are out of scope. :telemetry.attach/4 with a {Module, :function, config} tuple requires cross-file analysis to resolve and is not flagged.
  • Cross-module captures (&OtherModule.func/arity where OtherModule is not defined in the same file) are not resolved.
  • A handler passed as a bound variable (:telemetry.attach(name, event, handler_fn, config)) likewise carries no body at the call site and is not flagged.
  • Aliased or imported control-flow calls (e.g. alias GenServer, as: GS; GS.cast(...)) are not resolved; detection matches the canonical GenServer.*, send, and :erlang.send forms.
  • For same-file captures, if the function delegates to another function that performs control flow, only direct control-flow calls in the captured function's body are detected (no transitive resolution).

Bad

:telemetry.attach("governor", [:app, :dispatch], fn _, _, _, _ ->
  GenServer.cast(pid, :adjust_limit)
end, nil)

# Same-file capture with control flow in the referenced function
:telemetry.attach("broker", [:budget, :release],
  &__MODULE__.handle_release/4, nil)

defp handle_release(_event, _measures, %{pid: pid}, _config) do
  send(pid, :budget_released)
end

Good

# observation only
:telemetry.attach("metric", [:app, :dispatch], fn _event, measures, _meta, _conf ->
  :telemetry.execute([:app, :metric], measures, %{})
end, nil)

# cross-process signaling over PubSub, not the telemetry bus
Phoenix.PubSub.broadcast(MyApp.PubSub, "dispatch", {:dispatched, id})

Configuration

excluded_paths is a list of patterns (regexes or substrings) matched against each file's path; a matching file is skipped. Use it only as a shrinking migration bridge while moving an existing telemetry-as-bus site onto Phoenix.PubSub:

{ForgeCredoChecks.TelemetryControlFlow,
 excluded_paths: [~r"^lib/legacy/telemetry_bridge\.ex$"]}

Credo's # credo:disable-for-next-line escape hatch is honored automatically; use it to suppress a single intentional exception:

# credo:disable-for-next-line ForgeCredoChecks.TelemetryControlFlow
:telemetry.attach("intentional", [:app, :event], fn _, _, _, _ -> ... end, nil)

Check-Specific Parameters

Use the following parameters to configure this check:

:excluded_paths

Paths skipped entirely (regexes or substrings).

This parameter defaults to [].

General Parameters

Like with all checks, general params can be applied.

Parameters can be configured via the .credo.exs config file.