client-signals for Elixir

Elixir implementation of the shared client-signals contract.

Installation

Add :client_signals to your Mix dependencies when the package is published:

def deps do
  [
    {:client_signals, "~> 0.4"}
  ]
end

Requires Elixir 1.15 or newer.

Usage

signals = ClientSignals.detect_once()
headers = %{}

headers = ClientSignals.apply_headers(headers, signals)
Map.put(headers, "User-Agent", "my-cli/1.0 " <> ClientSignals.user_agent_suffix(signals))

Use a custom header prefix:

ClientSignals.apply_headers(headers, signals, "Acme")

API

Server-side: recording signals on request spans

If your application already depends on :plug and :opentelemetry_api (e.g. a Phoenix app), ClientSignals.Plug reads the Fly-Client-* headers off incoming requests and attaches them as fly.client.* attributes on the current OTel span:

# in your endpoint or router
plug ClientSignals.Plug

This module is only defined when both dependencies are present, so it has no effect on consumers that only use the header-generation API above.

The Plug emits the canonical [:client_signals, :request] telemetry event for requests whose matched route template falls under configured prefixes:

plug ClientSignals.Plug,
  service: "my-api",
  tracked_route_prefixes: ["/api/v1"],
  route_template_provider: {MyApp.ClientSignals, :route_template, []}

Add ClientSignals.PromExPlugin to the application's PromEx plugins to export fly_client_signals_requests_total:

def plugins do
  [
    ClientSignals.PromExPlugin
  ]
end

The telemetry event contains the bounded metadata keys service, route, operator, and agent. route combines the uppercase HTTP method with the matched route template. Unmatched requests under a configured prefix use "METHOD unmatched"; raw request paths are never forwarded.

The operator values are ci, agent, interactive, automated_unattributed, uninstrumented, and (on MCP routes only) mcp. The agent value is a known finite agent name or MCP client label, other, or none. Parent is deliberately not used for classification.

MCP routes

MCP clients do not send Fly-Client-* headers, but they do identify themselves through clientInfo.name, User-Agent, and X-Anthropic-Client. List the routes that serve MCP in :mcp_route_prefixes to infer a bounded client label for them:

plug ClientSignals.Plug,
  service: "my-mcp",
  tracked_route_prefixes: ["/mcp"],
  mcp_route_prefixes: ["/mcp"],
  route_template_provider: {MyApp.ClientSignals, :route_template, []}

On those routes a valid Fly-Client-Interactive header still wins for the metric labels. Otherwise an identified client is reported as operator: "mcp" with the client label (or other) as the agent. Independent of the metric, whenever a client is identified on an MCP route the Plug sets the fly.client.mcp_client and fly.client.mcp_source span attributes, even when the request also self-reports and even when the route is not in :tracked_route_prefixes. clientInfo.name is read from conn.body_params when the response is sent, so it only needs a JSON Plug.Parsers somewhere in the pipeline; an unfetched body is ignored. Do not list ordinary REST routes: Claude-User is also the User-Agent of Claude Code's web fetches.

On protocol versions up to 2025-11-25 clientInfo is sent only on initialize, so clients without a distinctive User-Agent (OpenCode, Gemini CLI, Cline, and others) are labelled on that call only. Compare such clients by initialize volume. See the stateless caveat in spec/request-metrics.md.

Without a Plug, use ClientSignals.MCP directly:

These values are inferred from caller-controlled input. Use them for estimates only, never for gating. The rules live in spec/mcp-clients.json; see docs/mcp-clients.md for their provenance.

The package owns the canonical telemetry event and PromEx metric definition. The route-template provider, service name, tracked route prefixes, PromEx supervision, and collector registration remain owned by each consuming service. A custom request_observer MFA remains supported. For Phoenix, the route-template provider can use Phoenix.Router.route_info/4 with the application's router.

Development

mix test
mix format --check-formatted
mix compile --warnings-as-errors