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 byWymcp.Response.record_rejection/5at 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}
rejecterandreasonare the pairWymcp.Response.rejection_table/0declares — together they identify the arm that refused, andWymcp.Response.rejection_reason/0names the condition the client can act on rather than the JSON-RPC code.messageis the diagnostic the rejection answered with, as the site gave it — a consumer'sWymcp.Auth.authenticate/1may hand over an atom, which the wire renders as its string.erais present only where the connection carries one:Wymcp.Plugs.Eraruns 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. Nosession_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/1reads — 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.- Measurements:
[: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.
- Measurements:
[: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.
- Measurements:
[: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}
- Measurements:
[:wymcp, :tool, :stop]— tool execution completed- Measurements:
%{duration: integer(), system_time: integer()}(durationin 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_errormirrors the MCP result'sisErrorflag for tool-returned errors, anderror_kindclassifies that error's origin::dispatch— a gate rejected the call before the tool's action handler ran (wymcp's dispatch gate, or a hand-writtenrun/2classifying its own gate rejection);:tool— the tool ran and answered with an error.error_kindisnilexactly whenis_errorisfalse. Both keys are:stop-only, so read them withMap.get/3from a handler attached to more than one tool event. Theerror_kindvocabulary may grow; match it with a fallback clause, never exhaustively.result_typesays whether the call finished::completefor a result the client can use,:input_requiredfor 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 carriesis_error: falseanderror_kind: nil, and the invariant above holds unchanged. The legacy lane reports:completeon 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.- Measurements:
[: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()}}
- Measurements:
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 withtool_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]witherror_kind: :dispatch— reconciling the two streams will show:tool-level rows with no:calledrow, and that is the reason. A raise inside help also drops it — the call still surfaces as[:wymcp, :tool, :error]withtool_name: "help"(the target tool echo is lost;actionstill 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/actionecho the requested target exactly as sent, even when they name nothing (a probe for a nonexistent target is itself signal);levelis which answer level the parameter shape addressed;is_erroris whether the answer was an error answer.
- Measurements:
[:wymcp, :auth, :error]— the consumer's auth module raised. The 401 the client receives is a rejection and emits[:wymcp, :wire, :reject]withreason: :auth_erroras 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_methodis the conn's HTTP verb ("POST","GET","DELETE") — as this library observed it, so an upstream rewriter such asPlug.Headreports its rewritten verb.message_idandmethodcome from the parsed request body and areniloff POST;http_methodis what distinguishes a GET or DELETE from a POST whose body did not parse. This event carries noera: the auth check is an era-invariant wire check and runs before era classification exists on the connection.- Measurements:
[:wymcp, :server, :reject]— the consumer'sWymcp.Server.init/2returned{:error, reason}; the session is terminated and thenotifications/initializedrequest 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}—reasonis the raw term the callback returned;erais always:legacy(session machinery)
Legacy-only:
Wymcp.Server.init/2runs 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.- Measurements:
[:wymcp, :server, :error]— the consumer'sWymcp.Server.init/2raised, 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()}}—exceptionis the exception struct name for a raise, or"exit"/"throw"for the other kinds;erais 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.
- Measurements:
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.