Wymcp.Telemetry (Wymcp v0.6.2)

View Source

The catalogue of :telemetry events wymcp emits — every event name, its measurements, and its full metadata key set.

A consuming application attaches its own handlers to these events for monitoring, logging, and metrics. wymcp ships one handler of its own, Wymcp.Telemetry.Logger, which renders these events as structured Logger lines and is attached at boot unless you turn it off; this module owns the events, that one owns the lines.

The grammar

Every name is [:wymcp, component, event]. The component says what the event is about — a domain noun (:session, :wire), a consumer behaviour (:tool, :server), or a subsystem (:auth, :help). The event says what happened to it.

:reject in the final slot means the named component refused, and the component disambiguates which refusal: [:wymcp, :wire, :reject] is wymcp refusing a request at the HTTP boundary — a rejection, answered 400, 401, 403 or 404 — while [:wymcp, :server, :reject] is the consumer's own server refusing a session, which is answered 200 with a JSON-RPC internal_error after processing. One sense, two components. [:wymcp, :server, :reject] is legacy-only: the session it names exists only on the legacy lane. Whether a modern-lane replacement is owed is the legacy decommission's to decide, and until then the event is fully supported.

Metadata keys are additive: a later release may add a key to an event, so no attached handler breaks, and nothing here promises a key set will not grow. A key that is not on every event of a family is called out at the event that carries it — read those with Map.get/3, never Map.fetch!/2, from a handler attached across families. A value set is closed unless its entry says otherwise: error_kind's vocabulary may grow, and era's may not.

Every event carries system_time in its measurements — emit/4 adds it unconditionally — so an event listed with a measurement of its own carries both.

Events

  • [:wymcp, :wire, :reject] — wymcp refused a request at the HTTP boundary. Emitted by Wymcp.Response.record_rejection/5 at the moment the rejection is recorded, before anything is sent: it records wymcp's decision, not its delivery, so a client that disconnects mid-write still produced one of these.

    • Measurements: %{system_time: integer()}
    • Metadata: %{rejecter: module(), reason: Wymcp.Response.rejection_reason(), status: 400 | 401 | 403 | 404, message: String.t() | atom(), http_method: String.t(), method: term() | nil, message_id: term() | nil, era: :modern | :legacy}

    rejecter and reason are the pair Wymcp.Response.rejection_table/0 declares — together they identify the arm that refused, and Wymcp.Response.rejection_reason/0 names the condition the client can act on rather than the JSON-RPC code. message is the diagnostic the rejection answered with, as the site gave it — a consumer's Wymcp.Auth.authenticate/1 may hand over an atom, which the wire renders as its string. era is present only where the connection carries one: Wymcp.Plugs.Era runs partway down the POST chain and not at all on the GET and DELETE routes, so a rejection ahead of it names no lane and the key is absent rather than guessed. The key is legacy-only: it exists only because two eras do, and it goes at the legacy decommission. No session_id: the session id is bearer-equivalent, and the {rejecter, reason} pair is the fact.

    The same pair is on the connection as the rejection mark, which Wymcp.Response.rejection/1 reads — the second door, for a host application that already writes an access line and wants which rejecter refused the request on the line it already emits.

  • [:wymcp, :session, :start] — session created during initialize

    • Measurements: %{system_time: integer()}
    • Metadata: %{session_id: String.t(), client_info: map(), era: :legacy} — always :legacy: session machinery is the legacy lane

    Legacy-only: sessions exist only on the legacy lane, and the event goes at the legacy decommission.

  • [:wymcp, :session, :expired] — session terminated due to idle timeout

    • Measurements: %{system_time: integer()}
    • Metadata: %{session_id: String.t(), era: :legacy} — always :legacy: session machinery is the legacy lane

    Legacy-only: the idle timeout it reports is a session's, and the event goes at the legacy decommission.

  • [:wymcp, :tool, :start] — tool execution starting

    • Measurements: %{system_time: integer()}
    • Metadata: %{tool_name: String.t(), action: String.t() | nil, session_id: String.t() | nil, era: :modern | :legacy}

  • [:wymcp, :tool, :stop] — tool execution completed

    • Measurements: %{duration: integer(), system_time: integer()} (duration in native time units)
    • Metadata: `%{tool_name: String.t(), action: String.t()nil,
      session_id: String.t()nil, is_error: boolean(),

      error_kind: :dispatch | :tool | nil, era: :modern | :legacy, result_type: :complete | :input_required}`

    is_error mirrors the MCP result's isError flag for tool-returned errors, and error_kind classifies that error's origin: :dispatch — a gate rejected the call before the tool's action handler ran (wymcp's dispatch gate, or a hand-written run/2 classifying its own gate rejection); :tool — the tool ran and answered with an error. error_kind is nil exactly when is_error is false. Both keys are :stop-only, so read them with Map.get/3 from a handler attached to more than one tool event. The error_kind vocabulary may grow; match it with a fallback clause, never exhaustively.

    result_type says whether the call finished: :complete for a result the client can use, :input_required for a run that stopped at a question the client has not answered and will send the call again to answer. That stop is neither a success nor an error — the tool has not failed, it has not finished — so it carries is_error: false and error_kind: nil, and the invariant above holds unchanged. The legacy lane reports :complete on every stop: it is the value the spec tells a client to assume where the field is absent from the wire, and a run there never stops early. This vocabulary may grow too — a planned tasks extension adds a value of its own — so match it with a fallback clause as well.

  • [:wymcp, :tool, :error] — tool raised an exception

    • Measurements: %{duration: integer(), system_time: integer()}
    • Metadata: %{tool_name: String.t(), action: String.t() | nil, session_id: String.t() | nil, message_id: term() | nil, exception: String.t(), error: String.t(), era: :modern | :legacy, crash_reason: {term(), Exception.stacktrace()}}

For the three tool events, action is the raw "action" string from the call arguments — what the client actually sent — or nil when the arguments carried none (a help call's arguments carry the target action, which is echoed here). session_id on those three events and on [:wymcp, :help, :called] is legacy-only: only a legacy-lane call has a session, and the key goes at the legacy decommission.

  • [:wymcp, :help, :called] — the help tool answered a call (including error answers), emitted after the answer is resolved so the metadata carries the outcome. The authoritative introspection record; the same call also emits the generic tool events above with tool_name: "help". Two cases drop this event. A call rejected by help's arguments gate never resolves a target, so it emits nothing here and surfaces only as [:wymcp, :tool, :stop] with error_kind: :dispatch — reconciling the two streams will show :tool-level rows with no :called row, and that is the reason. A raise inside help also drops it — the call still surfaces as [:wymcp, :tool, :error] with tool_name: "help" (the target tool echo is lost; action still carries the target action, per the note above).

    • Measurements: %{system_time: integer()}
    • Metadata: %{tool: String.t() | nil, action: String.t() | nil, level: :index | :tool | :action, session_id: String.t() | nil, is_error: boolean(), era: :modern | :legacy}tool/action echo the requested target exactly as sent, even when they name nothing (a probe for a nonexistent target is itself signal); level is which answer level the parameter shape addressed; is_error is whether the answer was an error answer.

  • [:wymcp, :auth, :error] — the consumer's auth module raised. The 401 the client receives is a rejection and emits [:wymcp, :wire, :reject] with reason: :auth_error as well; this event says the consumer's module is broken, which is a different fact.

    • Measurements: %{system_time: integer()}
    • Metadata: %{auth_module: module(), exception: String.t(), error: String.t(), message_id: term() | nil, method: String.t() | nil, http_method: String.t(), crash_reason: {term(), Exception.stacktrace()}}

    http_method is the conn's HTTP verb ("POST", "GET", "DELETE") — as this library observed it, so an upstream rewriter such as Plug.Head reports its rewritten verb. message_id and method come from the parsed request body and are nil off POST; http_method is what distinguishes a GET or DELETE from a POST whose body did not parse. This event carries no era: the auth check is an era-invariant wire check and runs before era classification exists on the connection.

  • [:wymcp, :server, :reject] — the consumer's Wymcp.Server.init/2 returned {:error, reason}; the session is terminated and the notifications/initialized request answered with a JSON-RPC internal_error

    • Measurements: %{system_time: integer()}
    • Metadata: %{server: module(), session_id: String.t(), reason: term(), message_id: term() | nil, era: :legacy}reason is the raw term the callback returned; era is always :legacy (session machinery)

    Legacy-only: Wymcp.Server.init/2 runs on the legacy lane's session lifecycle. Whether a modern-lane replacement is owed is the legacy decommission's to decide, and until then the event is fully supported.

  • [:wymcp, :server, :error] — the consumer's Wymcp.Server.init/2 raised, exited, or threw; treated as a rejection (same session termination and internal_error answer)

    • Measurements: %{system_time: integer()}
    • Metadata: %{server: module(), session_id: String.t(), exception: String.t(), error: String.t(), message_id: term() | nil, era: :legacy, crash_reason: {term(), Exception.stacktrace()}}exception is the exception struct name for a raise, or "exit" / "throw" for the other kinds; era is always :legacy (session machinery)

    Legacy-only: the callback that raised runs on the legacy lane's session lifecycle. Whether a modern-lane replacement is owed is the legacy decommission's to decide, and until then the event is fully supported.

Keys that span events

message_id is the inbound message's "id" exactly as the client sent it, on every event that carries one. It is deliberately not the id a rejection envelope echoes — that is Wymcp.Response.rejection_id/1, which is nil on every message kind but a request, while an operator diagnosing a failure wants the id that was actually on the wire. It is named for the message rather than the request because a response message's id was minted by the server and echoed by the client, and it is still that message's id.

crash_reason is {reason, stacktrace} — the pair Elixir's Logger documents under that name, which is what a handler reporting to an error service needs and cannot rebuild from the exception and error strings beside it. A raise's reason is the exception struct and an exit's reason rides as it is; a throw's value rides as {:nocatch, value}, the shape Logger itself reports a throw under. It rides the three fault events (tool.error, auth.error, server.error) and nothing else.

era (era) names the lane that served the request. It is on every event above except the two that run before era classification exists: [:wymcp, :auth, :error] never carries it, and [:wymcp, :wire, :reject] carries it only where the connection had been classified. A tool called in-process, on a Wymcp.Context.t/0 that never reached the wire, had no lane to name; those calls report :modern. That covers the caller, not the vocabulary — the value set stays :modern | :legacy on every path, and carries no growth clause. Read era with Map.get/3 from any handler attached across event families.

era is legacy-only: it exists only because two eras do, and the key goes at the legacy decommission.

Summary

Functions

emit(component, event, measurements \\ %{}, metadata \\ %{})