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
ClientSignals.detect/0computes fresh signals.ClientSignals.detect_once/0computes and caches process-wide signals.ClientSignals.headers_for/2returns a header map.ClientSignals.apply_headers/3merges signal headers into a map.ClientSignals.user_agent_suffix/1returns the client-signals User-Agent token.ClientSignals.operator/1returnsci,agent,interactive, orunknown; precedence is in that order.ClientSignals.sanitize_invoked_by/1andClientSignals.classify_parent_name/1are exposed for tests and advanced consumers that need the shared contract helpers.
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:
ClientSignals.MCP.classify_client/3takes the User-Agent,X-Anthropic-Client, andclientInfo.namevalues and returns%{client: label, source: source}.ClientSignals.MCP.classify_request/6adds the threeFly-Client-*header values and returns%{operator: operator, agent: agent}.ClientSignals.MCP.client_name/1extractsclientInfo.namefrom a decoded JSON-RPC request or its params.
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