FastestMCP emits telemetry events for core runtime activity and creates OpenTelemetry spans for both server execution and connected-client operations.

That gives you two levels of observability:

  • :telemetry events for direct Elixir instrumentation
  • OpenTelemetry spans and trace propagation for distributed tracing systems

Telemetry Events

FastestMCP emits events around operation and auth work.

Common events include:

  • [:fastest_mcp, :operation, :start]
  • [:fastest_mcp, :operation, :stop]
  • [:fastest_mcp, :operation, :exception]
  • [:fastest_mcp, :auth, :start]
  • [:fastest_mcp, :auth, :stop]
  • [:fastest_mcp, :auth, :exception]

Attach handlers using standard :telemetry APIs:

handler_id = "fastest-mcp-docs"

:telemetry.attach_many(
  handler_id,
  [
    [:fastest_mcp, :operation, :start],
    [:fastest_mcp, :operation, :stop],
    [:fastest_mcp, :operation, :exception]
  ],
  fn event, measurements, metadata, _config ->
    IO.inspect({event, measurements, metadata})
  end,
  nil
)

The metadata includes fields such as the MCP method and server name, which is usually enough to build metrics and dashboards.

OpenTelemetry Spans

FastestMCP creates SERVER spans around operations, INTERNAL spans around provider delegation, and CLIENT spans around connected-client operations.

That is useful when you want traces that connect:

  • inbound HTTP or stdio requests
  • auth work
  • provider delegation
  • tool, resource, or prompt execution
  • Phoenix or application work that calls a remote MCP server

FastestMCP uses W3C trace context propagation through request metadata and headers, so trace context can move through the runtime without custom glue in every handler.

For connected clients, one public operation owns one CLIENT span across its complete lifetime, including asynchronous cancellation and timeouts. Tool-call MRTR continuations and task updates use bounded child spans; pagination and task polling annotate the aggregate operation rather than creating one span per page or poll. Cache hits remain visible as client spans while trace identifiers stay outside cache keys and cached values.

Client propagation injects only traceparent and tracestate into MCP params._meta. Existing nested metadata is preserved. On the server, that MCP metadata takes precedence over direct request metadata and transport headers, so the immediate MCP client span remains the remote server span's parent.

What FastestMCP Traces For You

FastestMCP already handles:

  • span naming from MCP method and component type
  • attributes for server name, MCP method, component key, transport, session, request id, and GenAI/MCP semantic fields
  • exception recording on failed spans
  • trace context extraction and injection
  • client cache-hit, pagination, task-poll, and task-status annotations
  • generic error status for tool results carrying isError: true

Resource URIs are recorded as attributes rather than embedded in span names, so high-cardinality resource values do not fragment trace dashboards. Failed spans also include error.type, and nil attributes are dropped before they reach the OpenTelemetry exporter.

Arguments, content, structured results, arbitrary MCP metadata, Authorization headers, tokens, and credentials are never recorded as span attributes or events.

Most applications do not need to call the internal tracing helper module directly. The runtime instrumentation is the default.

Telemetry vs Logging

Use Logging for human-readable events.

Use telemetry when you need:

  • counters
  • durations
  • traces
  • monitoring integrations
  • alerts and dashboards

Logging and telemetry complement each other, but they are not interchangeable.

Example Test

Telemetry is straightforward to verify in ExUnit:

:telemetry.attach_many(
  "fastest-mcp-test",
  [
    [:fastest_mcp, :operation, :start],
    [:fastest_mcp, :operation, :stop],
    [:fastest_mcp, :operation, :exception]
  ],
  fn event, measurements, metadata, pid ->
    send(pid, {:telemetry, event, measurements, metadata})
  end,
  self()
)

assert %{status: "ok"} == FastestMCP.call_tool(server_name, "ok", %{})
assert_receive {:telemetry, [:fastest_mcp, :operation, :start], _, _}
assert_receive {:telemetry, [:fastest_mcp, :operation, :stop], %{duration: duration}, _}
assert duration > 0

Why This Shape

FastestMCP does not make you choose between BEAM-native telemetry and modern trace pipelines.

The runtime emits standard :telemetry events for local Elixir integrations and layers OpenTelemetry spans on top for tracing systems. That keeps the runtime observable without forcing one instrumentation style on every user.