# `ConduitMcp.Telemetry`
[🔗](https://github.com/nyo16/conduit_mcp/blob/v0.11.0/lib/conduit_mcp/telemetry.ex#L1)

Telemetry integration for ConduitMCP.

ConduitMCP uses the `:telemetry` library for instrumentation. This module
documents all telemetry events emitted by the library and provides helper
functions for setting up event handlers.

## Events

All events are prefixed with `[:conduit_mcp]`.

### Request Events

#### `[:conduit_mcp, :request, :stop]`

Emitted when an MCP request completes, whether successfully or with an error.

**Measurements:**

  * `:duration` (integer) - Request processing duration in native time units.
    Use `System.convert_time_unit/3` to convert to other units.

**Metadata:**

  * `:method` (String.t | nil) - The MCP method that was called
    (e.g., `"initialize"`, `"tools/list"`, `"tools/call"`); `nil` when the
    request's `"method"` is missing or not a string
  * `:server_module` (module) - The MCP server module handling the request
  * `:status` (:ok | :error) - Whether the request succeeded or failed
  * `:era` (:modern | :legacy) - The request's protocol era (see
    `ConduitMcp.Era`)
  * `:protocol_version` (String.t | nil) - The modern request's `_meta`
    protocol version, or the legacy session's negotiated version; `nil`
    when neither is known
  * `:traceparent`, `:tracestate`, `:baggage` (String.t | nil) - W3C trace
    context from the request's `_meta`, already validated (`traceparent`
    well-formed, `tracestate`/`baggage` at most 512 bytes of printable
    ASCII); `nil` when absent or invalid

**Example:**

    :telemetry.attach(
      "log-mcp-requests",
      [:conduit_mcp, :request, :stop],
      fn _event, measurements, metadata, _config ->
        duration_ms = System.convert_time_unit(measurements.duration, :native, :millisecond)

        IO.inspect(%{
          method: metadata.method,
          status: metadata.status,
          duration_ms: duration_ms
        }, label: "MCP Request")
      end,
      nil
    )

### Tool Execution Events

#### `[:conduit_mcp, :tool, :execute]`

Emitted when a tool is executed via the `tools/call` method.

**Measurements:**

  * `:duration` (integer) - Tool execution duration in native time units

**Metadata:**

  * `:tool_name` (String.t | nil) - Name of the tool that was executed;
    `nil` when the client's `"name"` is missing or not a string
  * `:server_module` (module) - The MCP server module
  * `:status` (:ok | :error) - Whether the tool executed successfully (an
    MRTR `input_required` round counts as `:ok`)

**Example:**

    :telemetry.attach(
      "track-tool-performance",
      [:conduit_mcp, :tool, :execute],
      fn _event, measurements, metadata, _config ->
        duration_ms = System.convert_time_unit(measurements.duration, :native, :millisecond)

        if duration_ms > 1000 do
          Logger.warning("Slow tool execution: #{metadata.tool_name} (#{duration_ms}ms)")
        end
      end,
      nil
    )

### Resource Events

#### `[:conduit_mcp, :resource, :read]`

Emitted when a resource is read via the `resources/read` method.

**Measurements:**

  * `:duration` (integer) - Resource read duration in native time units

**Metadata:**

  * `:uri` (String.t | nil) - URI of the resource that was read; `nil` when
    the client's `"uri"` is missing or not a string
  * `:server_module` (module) - The MCP server module
  * `:status` (:ok | :error) - Whether the read succeeded or failed (an
    MRTR `input_required` round counts as `:ok`)

**Example:**

    :telemetry.attach(
      "track-resource-reads",
      [:conduit_mcp, :resource, :read],
      fn _event, measurements, metadata, _config ->
        duration_ms = System.convert_time_unit(measurements.duration, :native, :millisecond)
        Logger.info("Resource read: uri=#{metadata.uri} duration=#{duration_ms}ms")
      end,
      nil
    )

### Prompt Events

#### `[:conduit_mcp, :prompt, :get]`

Emitted when a prompt is retrieved via the `prompts/get` method.

**Measurements:**

  * `:duration` (integer) - Prompt retrieval duration in native time units

**Metadata:**

  * `:prompt_name` (String.t | nil) - Name of the prompt that was retrieved;
    `nil` when the client's `"name"` is missing or not a string
  * `:server_module` (module) - The MCP server module
  * `:status` (:ok | :error) - Whether the prompt retrieval succeeded (an
    MRTR `input_required` round counts as `:ok`)

**Example:**

    :telemetry.attach(
      "track-prompt-usage",
      [:conduit_mcp, :prompt, :get],
      fn _event, _measurements, metadata, _config ->
        Logger.info("Prompt requested: #{metadata.prompt_name}")
      end,
      nil
    )

### MRTR Events

#### `[:conduit_mcp, :mrtr, :input_required]`

Emitted when a modern-era `tools/call`, `resources/read` or `prompts/get`
answers with an `InputRequiredResult` (see "Multi round-trip requests
(MRTR)" in `ConduitMcp.Server`) — after the input requests passed the
client-capability check and the state was sealed. Not emitted for a
rejected round (`-32021`, `-32603`).

**Measurements:**

  * `:count` (integer) - Number of input requests in the result

**Metadata:**

  * `:method` (String.t) - `"tools/call"`, `"resources/read"` or
    `"prompts/get"`
  * `:server_module` (module) - The MCP server module
  * `:round` (pos_integer) - Which `input_required` round of the exchange
    this is: `1` for a first request, `n + 1` for a retry whose verified
    `requestState` came from round `n`

Neither the input requests, the state nor the `requestState` token is
included.

### Subscription Events

A modern `subscriptions/listen` stream (see `ConduitMcp.Subscriptions`)
emits these instead of `[:conduit_mcp, :request, :stop]`: its duration is
how long the stream stayed open, which would swamp a latency metric. A
listen refused before its stream opened (HTTP 406 or 503) emits
neither.

#### `[:conduit_mcp, :subscription, :start]`

Emitted once the stream's SSE response has started, just before the
acknowledgment is written.

**Measurements:**

  * `:system_time` (integer) - `System.system_time/0` at the start
  * `:resource_subscriptions` (integer) - Number of resource URIs honoured

**Metadata:**

  * `:filters` ([atom]) - The filter fields honoured, a subset of
    `:tools_list_changed`, `:prompts_list_changed`,
    `:resources_list_changed`, `:resource_subscriptions` and `:task_ids`
    (`ConduitMcp.Ext.Tasks` enabled only)
  * `:server_module` (module) - The MCP server module

#### `[:conduit_mcp, :subscription, :stop]`

Emitted when the stream ends.

**Measurements:**

  * `:duration` (integer) - How long the stream was open, in native time
    units

**Metadata:**

  * `:filters` ([atom]) - As on `:start`
  * `:server_module` (module) - The MCP server module
  * `:reason` (atom) - `:client_closed` (the client went away),
    `:max_lifetime` (`:max_stream_lifetime` reached), `:token_expired`
    (the bearer token's `exp` passed), `:idle_timeout`
    (`:listen_idle_timeout` without a notification) or `:superseded` (a
    newer listen of the same principal took its place at
    `:max_listens_per_principal`). Every reason but
    `:client_closed` ended with `notifications/cancelled` and the
    graceful-closure result.

#### `[:conduit_mcp, :subscription, :drop]`

Emitted by `ConduitMcp.Subscriptions.publish/2` for each stream it skips
because the stream's mailbox already holds
`ConduitMcp.Subscriptions.max_queue/0` messages (a client that stopped
reading). That stream misses the notification; the others still get it.

**Measurements:**

  * `:message_queue_len` (integer) - The stream's mailbox length

**Metadata:**

  * `:topic` (atom) - The topic kind: `:tools`, `:prompts`, `:resources`,
    `:resource` or `:task` (never a URI, task id or principal)

Neither event carries the resource URIs, the listen request id or the
principal.

### Rate Limiting Events

#### `[:conduit_mcp, :rate_limit, :check]`

Emitted when a rate limit check is performed.

**Measurements:**

  * `:duration` (integer) - Rate limit check duration in native time units

**Metadata:**

  * `:key` (String.t) - The rate limit key (e.g., IP address or user ID)
  * `:status` (:allow | :deny) - Whether the request was allowed or denied
  * `:count` (integer) - Current request count (only when status is :allow)
  * `:retry_after` (integer) - Seconds until the limit resets (only when status is :deny)

**Example:**

    :telemetry.attach(
      "track-rate-limits",
      [:conduit_mcp, :rate_limit, :check],
      fn _event, _measurements, metadata, _config ->
        if metadata.status == :deny do
          Logger.warning("Rate limited: key=#{metadata.key}")
        end
      end,
      nil
    )

### Message Rate Limiting Events

#### `[:conduit_mcp, :message_rate_limit, :check]`

Emitted when a message-level rate limit check is performed.

**Measurements:**

  * `:duration` (integer) - Rate limit check duration in native time units

**Metadata:**

  * `:key` (String.t) - The rate limit key (e.g., `"msg:user:123"` or `"msg:127.0.0.1"`)
  * `:status` (:allow | :deny) - Whether the message was allowed or denied
  * `:method` (String.t | nil) - The MCP method being rate limited
  * `:count` (integer) - Current message count (only when status is :allow)
  * `:retry_after` (integer) - Seconds until the limit resets (only when status is :deny)

**Example:**

    :telemetry.attach(
      "track-message-rate-limits",
      [:conduit_mcp, :message_rate_limit, :check],
      fn _event, _measurements, metadata, _config ->
        if metadata.status == :deny do
          Logger.warning("Message rate limited: key=#{metadata.key} method=#{metadata.method}")
        end
      end,
      nil
    )

### Authentication Events

#### `[:conduit_mcp, :auth, :verify]`

Emitted when authentication verification is performed.

**Measurements:**

  * `:duration` (integer) - Authentication verification duration in native time units

**Metadata:**

  * `:strategy` (:bearer_token | :api_key | :function | :oauth) - Auth strategy used
    (the deprecated `:custom` strategy reports `:function`)
  * `:status` (:ok | :error) - Whether authentication succeeded
  * `:reason` (atom) - Failure reason, only present when status is `:error`. Always an
    atom, never verifier or token text: `:invalid_credential` / `:invalid_return` from
    `ConduitMcp.Plugs.Auth`; for `:oauth`, one of the set in the "Telemetry" section of
    `ConduitMcp.Plugs.OAuth`

**Example:**

    :telemetry.attach(
      "track-auth-failures",
      [:conduit_mcp, :auth, :verify],
      fn _event, _measurements, metadata, _config ->
        if metadata.status == :error do
          Logger.warning("Auth failed: strategy=#{metadata.strategy} reason=#{inspect(metadata.reason)}")
        end
      end,
      nil
    )

### Cancellation Events

#### `[:conduit_mcp, :request, :cancelled]`

Emitted when a `notifications/cancelled` is recorded for a request in flight
in the caller's scope (see `ConduitMcp.Cancellation`).

**Measurements:**

  * `:count` (integer) - Always `1`

**Metadata:**

  * `:request_id` (String.t | integer) - The cancelled request's JSON-RPC id
  * `:scope` (String.t) - The caller's cancellation scope (`"session:..."`,
    `"principal:..."` or `"ip:..."`)
  * `:reason` (String.t | nil) - The client's reason, clamped and stripped of
    control characters. Client-supplied: never use it as a metric tag.

#### `[:conduit_mcp, :cancellation, :cleanup]`

Emitted on every `ConduitMcp.Cancellation.cleanup/1` pass.

**Measurements:**

  * `:removed` (integer) - Expired cancellation rows deleted
  * `:in_flight_removed` (integer) - In-flight rows of dead request
    processes deleted

**Metadata:** none (`%{}`).

### Janitor Events

Emitted after each periodic sweep of the tables `ConduitMcp.Application`
owns.

#### `[:conduit_mcp, :session, :cleanup]`

From `ConduitMcp.Session.Janitor` sweeping expired sessions.

**Measurements:** `:removed` (integer) - Sessions evicted.

**Metadata:** `:store` (module) - The session store swept.

#### `[:conduit_mcp, :cancellation, :janitor]`

From the cancellation janitor (a `ConduitMcp.Session.Janitor` over
`ConduitMcp.Cancellation`). Its `:removed` is the same count the run's
`[:conduit_mcp, :cancellation, :cleanup]` reports, so count runs with it
rather than summing both.

**Measurements:** `:removed` (integer) - Cancellation rows evicted.

**Metadata:** `:store` (module) - `ConduitMcp.Cancellation`.

#### `[:conduit_mcp, :tasks, :cleanup]`

From `ConduitMcp.Tasks.Janitor` sweeping terminal tasks.

**Measurements:** `:removed` (integer) - Tasks evicted.

**Metadata:** none (`%{}`).

### Client Events

Emitted by `ConduitMcp.Client`. Metadata comes from a fixed allowlist and
never carries `_meta`, tokens or raw error data, nor tool arguments or
results unless the host opts in to payload capture (below). Every client
event's metadata has:

  * `:client` (term) - the client's `:name`
  * `:server_id` (String.t) - the client's `:server_id`
  * `:transport` (atom) - the transport kind (`:stdio`, `:http`,
    `:in_process`)
  * `:era` (:modern | :legacy | nil) - the negotiated era; `nil` before
    the first handshake

The `request`, `tool_call` and `input_request` spans, `policy.decision`
and the `hook` events are emitted **in the calling process**, so tracing
bridges nest them under the caller's span, and `tool.rejected` in the
process that fetched the tool list (a caller, or a cache refresh task).
The other client events are emitted inside the client's connection
process: their handlers must be fast.

#### `[:conduit_mcp, :client, :request, :start | :stop | :exception]`

One span per request sent (each round of a multi-round-trip call is its
own request).

**Measurements:** `:start` has `:monotonic_time` and `:system_time`;
`:stop` and `:exception` have `:duration` (native units) and
`:monotonic_time`.

**Metadata:** the common keys, plus `:method` (String.t). `:stop` adds
`:status` (:ok | :error), `:error_kind` (a `ConduitMcp.Client.Error` kind
or `nil`) and `:request_id` (integer, or `nil` if never sent).
`:exception` (a raise in an `:on_progress` callback) adds `:kind`.

#### `[:conduit_mcp, :client, :tool_call, :start | :stop | :exception]`

One span per `ConduitMcp.Client.call_tool/4`, around all its rounds.

**Measurements:** as for `request`; `:stop` adds `:rounds` (integer).

**Metadata:** the common keys, plus `:tool_name` (String.t, sanitized).
`:stop` adds `:status`, `:error_kind` and `:is_error` (boolean, or `nil`
when no result came back). With payload capture on, `:start` adds
`:arguments` and `:stop` adds `:result`; with it off (the default)
neither key is present.

##### Payload capture

Off by default (TL-2). A client started with `telemetry:
[capture_payloads: [redact: &MyApp.redact/1, max_bytes: 4_096]]` adds
`:arguments` to `:start` and `:result` to `:stop`. `:redact` is required
(`capture_payloads: true` is rejected when the client starts) and runs in
the calling process, before any handler sees the value:

  * `:arguments` - `redact.(args)`, where `args` are the arguments the
    caller passed to `ConduitMcp.Client.call_tool/4` (on a governed
    client, before pre-hooks: the span opens before governance runs);
  * `:result` - `redact.(map)`, where `map` is the returned
    `ConduitMcp.Client.ToolResult` as `%{"content" => ..., "isError" =>
    ..., "structuredContent" => ...}` (no `_meta`; after post-hooks and
    shaping on a governed client), or `nil` when the call returned an
    error or a task.

Each value is what `redact` returned when it JSON-encodes within
`:max_bytes` (default 4 096); `{:truncated, byte_size}` when it is larger
(never a partial value); `:redaction_failed` when `redact` raises, throws
or exits, or returns something that cannot be JSON-encoded. A
`ConduitMcp.Client.Secret` refuses to encode, so one left in place by
`redact` yields `:redaction_failed`, never the secret. No hash or digest
of a payload is ever emitted: low-entropy arguments are brute-forceable
from one.

#### `[:conduit_mcp, :client, :connection, :transition]`

The connection changed state (see `ConduitMcp.Client.Connection`).

**Measurements:** `:system_time`.

**Metadata:** the common keys, plus `:from` (atom, `nil` at start), `:to`
(atom), `:reason` (atom - a closed-set tag of the cause, `nil` when none)
and `:attempt` (integer - consecutive reconnect attempts).

#### `[:conduit_mcp, :client, :request, :cancel]`

The connection cancelled an in-flight request.

**Measurements:** `:count` (always `1`).

**Metadata:** the common keys, plus `:method`, `:request_id` and
`:reason` (:timeout | :caller_down | :caller_timeout).

#### `[:conduit_mcp, :client, :response, :dropped]`

A response arrived for a request that is no longer pending (late, or
cancelled).

**Measurements:** `:count` (always `1`).

**Metadata:** the common keys, plus `:request_id` (integer, or `nil` for
an id the client never issued).

#### `[:conduit_mcp, :client, :notification]`

A server notification reached the connection.

**Measurements:** `:count` (always `1`).

**Metadata:** the common keys, plus `:method` (String.t, sanitized;
server-supplied, so never use it as a metric tag).

#### `[:conduit_mcp, :client, :log, :dropped]`

A server `notifications/message` was dropped: the connection's
`:log_rate_limit` (50 per second, bursts of 100 by default) was spent.
Entries within the budget reach subscribers and `handle_log/3` with every
string sanitized and the level clamped to the RFC 5424 set.

**Measurements:** `:count` (always `1`).

**Metadata:** the common keys.

#### `[:conduit_mcp, :ext_tasks, :cleanup]`

From the `ConduitMcp.Ext.Tasks` janitor (a `ConduitMcp.Session.Janitor`,
started only when the experimental tasks extension is enabled) evicting
expired tasks.

**Measurements:** `:removed` (integer) - Tasks evicted.

**Metadata:** `:store` (module) - The `ConduitMcp.Ext.Tasks.Store` swept.

#### `[:conduit_mcp, :client, :cache, :refresh, :start | :stop]`

The connection refreshed one cached list (`ConduitMcp.Client.Cache`): on
demand, or after a `list_changed` notification. The fetch itself runs in
a task; these events bracket it.

**Measurements:** `:start` has `:monotonic_time` and `:system_time`;
`:stop` has `:duration` (native units), `:monotonic_time`, `:count`
(items kept), `:pages` and `:rejected` (tools dropped at intake); the
counts are 0 when the refresh failed.

**Metadata:** the common keys, plus `:kind` (:tools | :prompts |
:resources | :resource_templates) and `:trigger` (:demand |
:list_changed). `:stop` adds `:status` (:ok | :error) and `:error_kind`.

#### `[:conduit_mcp, :client, :tool, :rejected]`

A tool a server listed was dropped at intake (see `ConduitMcp.Client`,
"Listing and the cache").

**Measurements:** `:count` (always `1`).

**Metadata:** the common keys, plus `:tool_name` (String.t | nil - the
name with every character outside `[A-Za-z0-9_-]` replaced by `_`;
server-supplied, never a metric tag) and `:reason` (:invalid_name |
:invalid_definition | :duplicate_name | :invalid_x_mcp_header, or
:collision when a `ConduitMcp.Client.Hub` or `ConduitMcp.Client.Adapters`
toolset left the tool out because its exposed name collided).

#### `[:conduit_mcp, :client, :tool, :definition_changed]`

A refreshed tool list changed the definition (the
`ConduitMcp.Client.Fingerprint`) of a tool the client already knew.

**Measurements:** `:count` (always `1`).

**Metadata:** the common keys, plus `:tool_name` (String.t, sanitized;
server-supplied, never a metric tag).

#### `[:conduit_mcp, :client, :policy, :decision]`

`ConduitMcp.Client.Pipeline` decided a governed tool call, resource read
or prompt get (`ConduitMcp.Client.Policy`), after any approval.

**Measurements:** `:count` (always `1`).

**Metadata:** the common keys, plus:

  * `:target` (:tool | :resource | :prompt)
  * `:tool_name` (String.t, sanitized; tools only; never a metric tag)
  * `:decision` (:allow | :ask | :deny) - the policy's decision
  * `:source` (:deny | :ask | :allow | :pin | :default)
  * `:reason` (atom) - a `ConduitMcp.Client.Policy.Decision` reason, or
    `:no_policy` when a `{:named, name}` policy is not stored
  * `:rule_id` (term) - the deciding rule's id, `nil` for the pin and the
    default (operator-chosen, so not a metric tag)
  * `:approval` (:grant | :once | :always | :denied | :halted | :failed |
    nil) - how an `:ask` was settled: a live grant, the approver's
    answer, or `:failed` (a raise, exit, timeout or bad return); `nil`
    when the decision was not `:ask`
  * `:principal` (term) - the call's `:principal`, only when given

#### `[:conduit_mcp, :client, :hook, :stop]`

A `ConduitMcp.Client.Hooks` hook returned within its contract.

**Measurements:** `:duration` (native units) and `:monotonic_time`.

**Metadata:** the common keys, plus `:hook_id` (the hook's `:id`),
`:kind` (the hook event: :pre_tool_call | :post_tool_call |
:tool_call_failed | :on_tools_listed | :on_resource_read |
:on_prompt_get), `:status` (:cont | :rewrite | :deny | :halt | :recover)
and, for tool hooks, `:tool_name` (sanitized).

#### `[:conduit_mcp, :client, :hook, :exception]`

A hook raised, threw, exited, timed out or returned outside its contract;
the call failed closed, the result was withheld, or (with
`on_error: :ignore`) the hook was skipped. What the hook raised is never
included.

**Measurements:** as for `hook.stop`.

**Metadata:** as for `hook.stop`, with `:reason` (:raise | :exit |
:timeout | :bad_return) instead of `:status`.

#### `[:conduit_mcp, :client, :input_request, :start | :stop | :exception]`

One span per server-to-client input request the client's
`ConduitMcp.Client.Handler` answers (`sampling/createMessage`,
`elicitation/create`, `roots/list`), in either era: in the calling
process for a modern multi-round-trip input request, in the
connection's handler task for a legacy server request. The span covers
the safety checks and the handler callback; neither the request's
params nor the answer is included.

**Measurements:** as for `request`.

**Metadata:** the common keys, plus `:method` (one of the three
methods above, or `nil` for any other). `:stop` adds `:status` (:ok when
an answer is sent, :error when an error is); `:exception` (the handler
raised, threw or exited) adds `:kind`.

#### `[:conduit_mcp, :client, :transport, :mailbox]`

The connection's mailbox holds more than 1 000 messages: it is not
keeping up with what the server sends (a stdio port or an HTTP stream
has no back-pressure). Sampled once every 64 transport messages, so a
backlog emits it repeatedly until it drains.

**Measurements:** `:message_queue_len` (integer).

**Metadata:** the common keys.

### Hub and Adapter Events

Emitted by `ConduitMcp.Client.Hub` and `ConduitMcp.Client.Adapters`. The
hub events carry `:hub` (the hub's `:name`) instead of the client keys
above; `adapter.strict_fallback` carries `:client` (the hub's or client's
name).

#### `[:conduit_mcp, :client, :hub, :server_failed]`

A hub server's client exited (it crashed, or its supervisor gave up).
Emitted in the hub's manager process, which starts it again after a
jittered backoff.

**Measurements:** `:count` (always `1`) and `:delay` (milliseconds until
the restart).

**Metadata:** `:hub`, `:server_id` (String.t), `:attempt` (integer -
consecutive failures) and `:reason` (atom - a closed-set tag of the exit
reason, `:other` when it is not an atom or tagged tuple).

#### `[:conduit_mcp, :client, :hub, :server_restarted]`

The hub started a failed server's client again.

**Measurements:** `:count` (always `1`).

**Metadata:** `:hub`, `:server_id` and `:attempt`.

#### `[:conduit_mcp, :client, :hub, :collision]`

Two or more tools mangled to the same exposed name, so none of them is
offered (HB-1). Emitted in the caller that built the toolset, once per
distinct tool list (each tool also gets a `tool.rejected` with reason
`:collision`).

**Measurements:** `:count` (the number of colliding tools).

**Metadata:** `:hub`, `:exposed_name` (String.t) and `:colliders`
(`[{server_id, tool_name}]`, tool names sanitized; never a metric tag).

#### `[:conduit_mcp, :client, :adapter, :strict_fallback]`

`ConduitMcp.Client.Adapters.tools/3` sent an OpenAI tool with
`"strict": false` because its schema cannot be converted to strict mode
(`strict: :auto`).

**Measurements:** `:count` (always `1`).

**Metadata:** `:client`, `:provider` (`:openai`), `:api` (:chat |
:responses), `:tool_name` (the exposed name; never a metric tag) and
`:reason` (:unsupported_keyword, or the strict-mode limit exceeded:
:max_properties | :max_depth | :max_chars | :max_enum_values |
:max_enum_chars).
#### `[:conduit_mcp, :client, :oauth, op, :start | :stop | :exception]`

The client's OAuth work (`ConduitMcp.Client.OAuth`), one span per
operation, `op` one of:

  * `:discovery` - Protected Resource Metadata and authorization-server
    metadata fetched and validated;
  * `:registration` - the client identity chosen (pre-registered, CIMD,
    or a dynamic registration request);
  * `:token_exchange` - an authorization code redeemed, or a
    client-credentials token requested;
  * `:refresh` - a refresh-token grant;
  * `:step_up` - a 403 `insufficient_scope` handled (emitted in the
    connection process).

The other spans run in the process doing the work: an HTTP request
process, the connection's authorization task, or the caller of
`ConduitMcp.Client.OAuth`.

**Measurements:** as for `request`.

**Metadata:** never a token, code, `state`, verifier, secret, URL or
issuer:

  * `:client` (term) - the client's `:name`, `nil` for a standalone
    `ConduitMcp.Client.OAuth.Config`
  * `:server_id` (String.t | nil) - the client's `:server_id`
  * `:principal` (term) - the configured `:principal` (host-supplied;
    `nil` when unused)

`:token_exchange` adds `:grant` (:authorization_code |
:client_credentials). `:stop` adds `:status` (:ok | :error) and `:reason`
(a `ConduitMcp.Client.OAuth.Error` reason atom, or `nil`);
`:registration`'s `:stop` adds `:method` (:pre_registered | :cimd | :dcr
| nil). `:exception` adds `:kind`.

#### `[:conduit_mcp, :client, :oauth, :authorization_required]`

The user must authorize: the client is about to call
`on_authorization_required` (emitted in the connection process, once per
episode, and for each step-up).

**Measurements:** `:count` (always `1`).

**Metadata:** `:client`, `:server_id`, `:principal` as above, and
`:reason` (:unauthorized | :step_up).

### Key reference

Every event with the exact keys it carries. "Always" keys are in every
emission; "Sometimes" keys only where the event's section says. No
emission carries a key outside these two columns: a test pins each
emitted key set to this table, which is generated from the same list as
`events/0`.

| Event | Measurements | Metadata (always) | Metadata (sometimes) |
|---|---|---|---|
| `[:conduit_mcp, :request, :stop]` | `:duration` | `:method`, `:server_module`, `:status`, `:era`, `:protocol_version`, `:traceparent`, `:tracestate`, `:baggage` | - |
| `[:conduit_mcp, :tool, :execute]` | `:duration` | `:tool_name`, `:server_module`, `:status` | - |
| `[:conduit_mcp, :resource, :read]` | `:duration` | `:uri`, `:server_module`, `:status` | - |
| `[:conduit_mcp, :prompt, :get]` | `:duration` | `:prompt_name`, `:server_module`, `:status` | - |
| `[:conduit_mcp, :rate_limit, :check]` | `:duration` | `:key`, `:status` | `:count`, `:retry_after` |
| `[:conduit_mcp, :message_rate_limit, :check]` | `:duration` | `:key`, `:status`, `:method` | `:count`, `:retry_after` |
| `[:conduit_mcp, :auth, :verify]` | `:duration` | `:strategy`, `:status` | `:reason` |
| `[:conduit_mcp, :request, :cancelled]` | `:count` | `:request_id`, `:scope`, `:reason` | - |
| `[:conduit_mcp, :cancellation, :cleanup]` | `:removed`, `:in_flight_removed` | - | - |
| `[:conduit_mcp, :cancellation, :janitor]` | `:removed` | `:store` | - |
| `[:conduit_mcp, :session, :cleanup]` | `:removed` | `:store` | - |
| `[:conduit_mcp, :tasks, :cleanup]` | `:removed` | - | - |
| `[:conduit_mcp, :mrtr, :input_required]` | `:count` | `:method`, `:server_module`, `:round` | - |
| `[:conduit_mcp, :client, :request, :start]` | `:monotonic_time`, `:system_time` | `:client`, `:server_id`, `:transport`, `:era`, `:method` | - |
| `[:conduit_mcp, :client, :request, :stop]` | `:duration`, `:monotonic_time` | `:client`, `:server_id`, `:transport`, `:era`, `:method`, `:status`, `:error_kind`, `:request_id` | - |
| `[:conduit_mcp, :client, :request, :exception]` | `:duration`, `:monotonic_time` | `:client`, `:server_id`, `:transport`, `:era`, `:method`, `:kind` | - |
| `[:conduit_mcp, :client, :tool_call, :start]` | `:monotonic_time`, `:system_time` | `:client`, `:server_id`, `:transport`, `:era`, `:tool_name` | `:arguments` |
| `[:conduit_mcp, :client, :tool_call, :stop]` | `:duration`, `:monotonic_time`, `:rounds` | `:client`, `:server_id`, `:transport`, `:era`, `:tool_name`, `:status`, `:error_kind`, `:is_error` | `:result` |
| `[:conduit_mcp, :client, :tool_call, :exception]` | `:duration`, `:monotonic_time` | `:client`, `:server_id`, `:transport`, `:era`, `:tool_name`, `:kind` | - |
| `[:conduit_mcp, :client, :input_request, :start]` | `:monotonic_time`, `:system_time` | `:client`, `:server_id`, `:transport`, `:era`, `:method` | - |
| `[:conduit_mcp, :client, :input_request, :stop]` | `:duration`, `:monotonic_time` | `:client`, `:server_id`, `:transport`, `:era`, `:method`, `:status` | - |
| `[:conduit_mcp, :client, :input_request, :exception]` | `:duration`, `:monotonic_time` | `:client`, `:server_id`, `:transport`, `:era`, `:method`, `:kind` | - |
| `[:conduit_mcp, :client, :connection, :transition]` | `:system_time` | `:client`, `:server_id`, `:transport`, `:era`, `:from`, `:to`, `:reason`, `:attempt` | - |
| `[:conduit_mcp, :client, :request, :cancel]` | `:count` | `:client`, `:server_id`, `:transport`, `:era`, `:method`, `:request_id`, `:reason` | - |
| `[:conduit_mcp, :client, :response, :dropped]` | `:count` | `:client`, `:server_id`, `:transport`, `:era`, `:request_id` | - |
| `[:conduit_mcp, :client, :notification]` | `:count` | `:client`, `:server_id`, `:transport`, `:era`, `:method` | - |
| `[:conduit_mcp, :client, :log, :dropped]` | `:count` | `:client`, `:server_id`, `:transport`, `:era` | - |
| `[:conduit_mcp, :client, :transport, :mailbox]` | `:message_queue_len` | `:client`, `:server_id`, `:transport`, `:era` | - |
| `[:conduit_mcp, :subscription, :start]` | `:system_time`, `:resource_subscriptions` | `:filters`, `:server_module` | - |
| `[:conduit_mcp, :subscription, :stop]` | `:duration` | `:filters`, `:server_module`, `:reason` | - |
| `[:conduit_mcp, :subscription, :drop]` | `:message_queue_len` | `:topic` | - |
| `[:conduit_mcp, :ext_tasks, :cleanup]` | `:removed` | `:store` | - |
| `[:conduit_mcp, :client, :cache, :refresh, :start]` | `:monotonic_time`, `:system_time` | `:client`, `:server_id`, `:transport`, `:era`, `:kind`, `:trigger` | - |
| `[:conduit_mcp, :client, :cache, :refresh, :stop]` | `:duration`, `:monotonic_time`, `:count`, `:pages`, `:rejected` | `:client`, `:server_id`, `:transport`, `:era`, `:kind`, `:trigger`, `:status`, `:error_kind` | - |
| `[:conduit_mcp, :client, :tool, :rejected]` | `:count` | `:client`, `:server_id`, `:transport`, `:era`, `:tool_name`, `:reason` | - |
| `[:conduit_mcp, :client, :tool, :definition_changed]` | `:count` | `:client`, `:server_id`, `:transport`, `:era`, `:tool_name` | - |
| `[:conduit_mcp, :client, :policy, :decision]` | `:count` | `:client`, `:server_id`, `:transport`, `:era`, `:target`, `:decision`, `:source`, `:reason`, `:rule_id`, `:approval` | `:tool_name`, `:principal` |
| `[:conduit_mcp, :client, :hook, :stop]` | `:duration`, `:monotonic_time` | `:client`, `:server_id`, `:transport`, `:era`, `:hook_id`, `:kind`, `:status` | `:tool_name` |
| `[:conduit_mcp, :client, :hook, :exception]` | `:duration`, `:monotonic_time` | `:client`, `:server_id`, `:transport`, `:era`, `:hook_id`, `:kind`, `:reason` | `:tool_name` |
| `[:conduit_mcp, :client, :hub, :server_failed]` | `:count`, `:delay` | `:hub`, `:server_id`, `:attempt`, `:reason` | - |
| `[:conduit_mcp, :client, :hub, :server_restarted]` | `:count` | `:hub`, `:server_id`, `:attempt` | - |
| `[:conduit_mcp, :client, :hub, :collision]` | `:count` | `:hub`, `:exposed_name`, `:colliders` | - |
| `[:conduit_mcp, :client, :adapter, :strict_fallback]` | `:count` | `:client`, `:provider`, `:api`, `:tool_name`, `:reason` | - |
| `[:conduit_mcp, :client, :oauth, :discovery, :start]` | `:monotonic_time`, `:system_time` | `:client`, `:server_id`, `:principal` | `:grant` |
| `[:conduit_mcp, :client, :oauth, :discovery, :stop]` | `:duration`, `:monotonic_time` | `:client`, `:server_id`, `:principal`, `:status`, `:reason` | `:grant`, `:method` |
| `[:conduit_mcp, :client, :oauth, :discovery, :exception]` | `:duration`, `:monotonic_time` | `:client`, `:server_id`, `:principal`, `:kind` | `:grant` |
| `[:conduit_mcp, :client, :oauth, :registration, :start]` | `:monotonic_time`, `:system_time` | `:client`, `:server_id`, `:principal` | `:grant` |
| `[:conduit_mcp, :client, :oauth, :registration, :stop]` | `:duration`, `:monotonic_time` | `:client`, `:server_id`, `:principal`, `:status`, `:reason` | `:grant`, `:method` |
| `[:conduit_mcp, :client, :oauth, :registration, :exception]` | `:duration`, `:monotonic_time` | `:client`, `:server_id`, `:principal`, `:kind` | `:grant` |
| `[:conduit_mcp, :client, :oauth, :token_exchange, :start]` | `:monotonic_time`, `:system_time` | `:client`, `:server_id`, `:principal` | `:grant` |
| `[:conduit_mcp, :client, :oauth, :token_exchange, :stop]` | `:duration`, `:monotonic_time` | `:client`, `:server_id`, `:principal`, `:status`, `:reason` | `:grant`, `:method` |
| `[:conduit_mcp, :client, :oauth, :token_exchange, :exception]` | `:duration`, `:monotonic_time` | `:client`, `:server_id`, `:principal`, `:kind` | `:grant` |
| `[:conduit_mcp, :client, :oauth, :refresh, :start]` | `:monotonic_time`, `:system_time` | `:client`, `:server_id`, `:principal` | `:grant` |
| `[:conduit_mcp, :client, :oauth, :refresh, :stop]` | `:duration`, `:monotonic_time` | `:client`, `:server_id`, `:principal`, `:status`, `:reason` | `:grant`, `:method` |
| `[:conduit_mcp, :client, :oauth, :refresh, :exception]` | `:duration`, `:monotonic_time` | `:client`, `:server_id`, `:principal`, `:kind` | `:grant` |
| `[:conduit_mcp, :client, :oauth, :step_up, :start]` | `:monotonic_time`, `:system_time` | `:client`, `:server_id`, `:principal` | `:grant` |
| `[:conduit_mcp, :client, :oauth, :step_up, :stop]` | `:duration`, `:monotonic_time` | `:client`, `:server_id`, `:principal`, `:status`, `:reason` | `:grant`, `:method` |
| `[:conduit_mcp, :client, :oauth, :step_up, :exception]` | `:duration`, `:monotonic_time` | `:client`, `:server_id`, `:principal`, `:kind` | `:grant` |
| `[:conduit_mcp, :client, :oauth, :authorization_required]` | `:count` | `:client`, `:server_id`, `:principal`, `:reason` | - |

## Common Use Cases

### Logging Request Activity

    defmodule MyApp.MCPTelemetry do
      require Logger

      def attach_handlers do
        events = [
          [:conduit_mcp, :request, :stop],
          [:conduit_mcp, :tool, :execute],
          [:conduit_mcp, :resource, :read],
          [:conduit_mcp, :prompt, :get],
          [:conduit_mcp, :rate_limit, :check],
          [:conduit_mcp, :message_rate_limit, :check],
          [:conduit_mcp, :auth, :verify]
        ]

        :telemetry.attach_many(
          "my-app-mcp-logger",
          events,
          &handle_event/4,
          nil
        )
      end

      def handle_event([:conduit_mcp, :request, :stop], measurements, metadata, _config) do
        Logger.info("MCP request",
          method: metadata.method,
          status: metadata.status,
          duration_ms: convert_duration(measurements.duration)
        )
      end

      def handle_event([:conduit_mcp, :tool, :execute], measurements, metadata, _config) do
        Logger.info("Tool executed",
          tool: metadata.tool_name,
          status: metadata.status,
          duration_ms: convert_duration(measurements.duration)
        )
      end

      def handle_event([:conduit_mcp, :resource, :read], measurements, metadata, _config) do
        Logger.info("Resource read",
          uri: metadata.uri,
          status: metadata.status,
          duration_ms: convert_duration(measurements.duration)
        )
      end

      def handle_event([:conduit_mcp, :prompt, :get], measurements, metadata, _config) do
        Logger.info("Prompt retrieved",
          prompt: metadata.prompt_name,
          status: metadata.status,
          duration_ms: convert_duration(measurements.duration)
        )
      end

      def handle_event([:conduit_mcp, :rate_limit, :check], measurements, metadata, _config) do
        Logger.info("Rate limit check",
          key: metadata.key,
          status: metadata.status,
          duration_ms: convert_duration(measurements.duration)
        )
      end

      def handle_event([:conduit_mcp, :message_rate_limit, :check], measurements, metadata, _config) do
        Logger.info("Message rate limit check",
          key: metadata.key,
          method: metadata.method,
          status: metadata.status,
          duration_ms: convert_duration(measurements.duration)
        )
      end

      def handle_event([:conduit_mcp, :auth, :verify], measurements, metadata, _config) do
        Logger.info("Auth verification",
          strategy: metadata.strategy,
          status: metadata.status,
          duration_ms: convert_duration(measurements.duration)
        )
      end

      defp convert_duration(native) do
        System.convert_time_unit(native, :native, :millisecond)
      end
    end

### Metrics Collection

    defmodule MyApp.MCPMetrics do
      use Supervisor
      import Telemetry.Metrics

      def start_link(opts) do
        Supervisor.start_link(__MODULE__, opts, name: __MODULE__)
      end

      def init(_opts) do
        children = [
          {Telemetry.Metrics.ConsoleReporter, metrics: metrics()}
        ]

        Supervisor.init(children, strategy: :one_for_one)
      end

      defp metrics do
        [
          # Total requests by method and status
          counter("conduit_mcp.request.stop.count",
            tags: [:method, :status],
            description: "Total MCP requests"
          ),

          # Request duration distribution
          distribution("conduit_mcp.request.stop.duration",
            unit: {:native, :millisecond},
            tags: [:method],
            description: "MCP request duration"
          ),

          # Tool execution count
          counter("conduit_mcp.tool.execute.count",
            tags: [:tool_name, :status],
            description: "Tool execution count"
          ),

          # Tool duration summary
          summary("conduit_mcp.tool.execute.duration",
            unit: {:native, :millisecond},
            tags: [:tool_name],
            description: "Tool execution duration"
          ),

          # Resource read count
          counter("conduit_mcp.resource.read.count",
            tags: [:status],
            description: "Resource read operations"
          ),

          # Resource read duration
          distribution("conduit_mcp.resource.read.duration",
            unit: {:native, :millisecond},
            description: "Resource read duration"
          ),

          # Prompt retrieval count
          counter("conduit_mcp.prompt.get.count",
            tags: [:prompt_name, :status],
            description: "Prompt retrieval count"
          ),

          # Prompt retrieval duration
          summary("conduit_mcp.prompt.get.duration",
            unit: {:native, :millisecond},
            tags: [:prompt_name],
            description: "Prompt retrieval duration"
          ),

          # Rate limit check count
          counter("conduit_mcp.rate_limit.check.count",
            tags: [:status],
            description: "Rate limit checks"
          ),

          # Rate limit check duration
          distribution("conduit_mcp.rate_limit.check.duration",
            unit: {:native, :millisecond},
            description: "Rate limit check duration"
          ),

          # Message rate limit check count
          counter("conduit_mcp.message_rate_limit.check.count",
            tags: [:status, :method],
            description: "Message rate limit checks"
          ),

          # Message rate limit check duration
          distribution("conduit_mcp.message_rate_limit.check.duration",
            unit: {:native, :millisecond},
            description: "Message rate limit check duration"
          ),

          # Auth verification count
          counter("conduit_mcp.auth.verify.count",
            tags: [:strategy, :status],
            description: "Authentication attempts"
          ),

          # Auth verification duration
          distribution("conduit_mcp.auth.verify.duration",
            unit: {:native, :millisecond},
            tags: [:strategy],
            description: "Authentication verification duration"
          )
        ]
      end
    end

### Performance Alerts

    :telemetry.attach(
      "mcp-performance-alerts",
      [:conduit_mcp, :tool, :execute],
      fn _event, %{duration: duration}, %{tool_name: tool}, _config ->
        duration_ms = System.convert_time_unit(duration, :native, :millisecond)

        cond do
          duration_ms > 5000 ->
            Logger.error("Critical: Tool #{tool} took #{duration_ms}ms")

          duration_ms > 1000 ->
            Logger.warning("Warning: Tool #{tool} took #{duration_ms}ms")

          true ->
            :ok
        end
      end,
      nil
    )

## See Also

- [`:telemetry` documentation](https://hexdocs.pm/telemetry/)
- [`Telemetry.Metrics`](https://hexdocs.pm/telemetry_metrics/)
- [Phoenix Telemetry](https://hexdocs.pm/phoenix/telemetry.html)

# `attach_default_handlers`

```elixir
@spec attach_default_handlers() :: :ok | {:error, :already_exists}
```

Attaches default logging handlers for all ConduitMCP telemetry events.

This is a convenience function for quick setup during development.
The handlers will log events at the `:debug` level.

## Example

    # In your application.ex
    def start(_type, _args) do
      ConduitMcp.Telemetry.attach_default_handlers()
      # ... rest of supervision tree
    end

To detach later:

    ConduitMcp.Telemetry.detach_default_handlers()

# `detach_default_handlers`

```elixir
@spec detach_default_handlers() :: :ok | {:error, :not_found}
```

Detaches the default telemetry handlers.

## Example

    ConduitMcp.Telemetry.detach_default_handlers()

# `events`

```elixir
@spec events() :: [[:conduit_mcp, ...]]
```

Returns all telemetry event names emitted by ConduitMCP, in the order of
the moduledoc's "Key reference" table.

## Example

    iex> [:conduit_mcp, :client, :tool_call, :stop] in ConduitMcp.Telemetry.events()
    true

---

*Consult [api-reference.md](api-reference.md) for complete listing*
