Wymcp. Telemetry
(Wymcp v0.8.3)
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 (:wire, :method), a consumer
behaviour (:tool), or a subsystem (:auth, :help). The event says
what happened to it.
:reject in the final slot means the named component refused:
[:wymcp, :wire, :reject] is wymcp refusing a request at the HTTP
boundary — a rejection, answered 400,
401, 403, 404, 413, 414 or 415.
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.
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 | 413 | 414 | 415, message: String.t() | atom(), http_method: String.t(), method: term() | nil, message_id: term() | nil}
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.The same pair is on the connection as the rejection mark, which
Wymcp.Response.rejection/1reads — the second door, for a host that already writes an access line and wants which rejecter refused the request on the line it already emits.- Measurements:
[:wymcp, :tool, :start]— tool execution starting- Measurements:
%{system_time: integer()} Metadata:
%{tool_name: String.t(), action: String.t() | nil}
- 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, is_error: boolean(), error_kind: :dispatch | :tool | nil, 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. 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]— the tool faulted: itsrun/2raised, exited or threw, and the call was answered as anisErrorresult under fault containment- Measurements:
%{duration: integer(), system_time: integer()} Metadata:
%{tool_name: String.t(), action: String.t() | nil, message_id: term() | nil, exception: String.t(), error: String.t(), crash_reason: {term(), Exception.stacktrace()}}—exceptionis the exception struct name for a raise, or"exit"/"throw"for the other kinds
crash_reasonis the pair a handler reporting to an error service needs; the two strings beside it cannot rebuild it.- 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).
[: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 fault 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, is_error: boolean()}` — 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 as this library observed it, so an upstream rewriter such asPlug.Headreports its rewritten verb.message_idandmethodcome from the parsed request body and arenilwhere none parsed.- Measurements:
[:wymcp, :method, :error]— a method faulted: a raise, exit or throw escaped the module answering the request, and method containment answered a-32603envelope carrying the request's id in its place. The two fault events above each report a consumer's own code; this one reports whatever escaped them, wymcp's own included — an encode of a stored value, a framework bug.- Measurements:
%{system_time: integer()} Metadata:
%{method: String.t() | nil, message_id: term() | nil, exception: String.t(), error: String.t(), crash_reason: {term(), Exception.stacktrace()}}—exceptionis the exception struct name for a raise, or"exit"/"throw"for the other kinds;erroris the fault rendered throughWymcp.Bound.render/1
methodis the body's"method"string exactly as the client sent it.- 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 message that is no request — one
classified :unknown — still has the id it arrived with.
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, method.error) and nothing else.
No event names a session or a protocol era: the server holds no session, and it speaks one revision.