# Observability patterns

Canonical URL: https://vercel-mcp-reference.vercel.app/observability/
Markdown: https://vercel-mcp-reference.vercel.app/observability.md
Audience: engineer, architect. MCP spec version: 2026-07-28. Last reviewed: 2026-08-26. Status: stable.

> **Deprecation notice:** the protocol's `logging` capability is deprecated as of 2026-07-28 (SEP-2577), with a window of at least twelve months, and `logging/setLevel` is removed outright (SEP-2575). The suggested migration is exactly what this page already teaches: stderr and platform runtime logs for records, OpenTelemetry for traces. The per-request `io.modelcontextprotocol/logLevel` details are covered in the structured-logging section below. Wire status: the pinned stack (`mcp-handler` 2.1.1 on `@modelcontextprotocol/server` 2.0.0) serves the 2026-07-28 contract natively over Streamable HTTP and falls back to stateless 2025-11-25 Streamable HTTP for legacy clients; the SDK `Client` defaults to that legacy handshake unless you opt in to modern version negotiation, so the examples' in-memory test suites exercise only the legacy path.

When something goes wrong in an MCP system, the failure rarely announces itself: a `tools/call` quietly returns an error result, a response stream breaks mid-request and the result is simply gone (the 2026-07-28 revision removed SSE redelivery, so the client's only move is to re-issue), a request that worked yesterday fails today on a fresh instance no one can reproduce. On Vercel you cannot attach to the process, because there is no process to attach to; your [server](https://vercel-mcp-reference.vercel.app/glossary/#server) is a stream of invocations across instances that come and go. Diagnosing it means being able to see the messages, time them, and correlate them across tiers and across invocations. MCP gives you natural seams (every message carries an `id`, a `method`, and a direction), and Vercel gives you the sinks: runtime logs per invocation, drains to forward them, and OpenTelemetry for traces. The same output-minimization discipline that keeps a server secure keeps its logs from leaking; that thread runs through this whole page.

## Structured logging

Anything a [Vercel Function](https://vercel-mcp-reference.vercel.app/glossary/#vercel-function) writes to standard output or standard error becomes a runtime log entry, captured and grouped per request: `console.log` lands as `info`, `console.error` as `error`. Build on that with **one structured JSON record per JSON-RPC frame**, with a stable shape: `id`, `method`, direction (inbound or outbound), and elapsed time. With protocol [sessions](https://vercel-mcp-reference.vercel.app/glossary/#session) removed in 2026-07-28, the durable join keys are the JSON-RPC `id` within an exchange, any server-minted handle your tools issue across exchanges, and trace context (next section); Vercel adds its own `requestId` and `invocationId` fields in the Logs tab, which join your protocol-level records to the invocation that produced them, its duration, and whether it started cold.

The limits are real and worth designing for: 256 log lines per request, 256 KB per line, 1 MB per request, and retention of 1 hour on Hobby, 1 day on Pro, and 3 days on Enterprise (30 days with Observability Plus). Two consequences: log compact single-line JSON rather than pretty-printed blobs, and treat the dashboard as a debugging window, not an archive. The archive is a drain (next section).

Two MCP-specific rules:

- **On stdio, log to stderr, never stdout.** In local [stdio](https://vercel-mcp-reference.vercel.app/glossary/#stdio-transport) development a server's `stdout` carries only MCP messages; one stray log line corrupts the framing and the client sees a dead server. Deployed over [Streamable HTTP](https://vercel-mcp-reference.vercel.app/glossary/#streamable-http-transport) this hazard disappears, which is one more small way the transport fits the platform. Under the deprecation this is also the destination of record: stderr **is** the migration target for server-side logging.
- **Protocol logging is now per-request and opt-in.** During the deprecation window a server that declares the `logging` capability can still emit structured records *to the client* via `notifications/message` (with `level`, `logger`, and `data`), but the client-set verbosity floor is gone with `logging/setLevel`: as of 2026-07-28 the level rides each request's `_meta` under `io.modelcontextprotocol/logLevel`, and servers **MUST NOT** emit `notifications/message` for requests that did not include it. Treat that as a design gift on serverless: log verbosity becomes request-scoped configuration, which is the only kind a stateless function can honor. The spec is blunt about content either way: log messages **MUST NOT** contain credentials, secrets, or personal information.

## Drains: getting telemetry out

Runtime logs answer "what just happened"; drains answer everything older than your retention window. A drain (available on Pro and Enterprise plans) forwards observability data to an external HTTPS endpoint or a native integration, one data type per drain. Six types exist: Logs (runtime, build, and static), Traces (OpenTelemetry format), Speed Insights, Web Analytics, Connect, and Audit Logs (Enterprise only); Logs and Traces are the two an MCP server cares about. Point them at your log pipeline and the Hobby-tier hour stops being your incident-response memory.

The receiving end is part of your attack surface, so secure it like one:

- **Verify the signature.** Vercel sends an `x-vercel-signature` header, an HMAC-SHA1 of the raw body keyed with the drain's secret; recompute it and compare in constant time before trusting a payload, or anyone who discovers the endpoint URL can feed fabricated records into your pipeline and your alerting.
- **The drain endpoint inherits your logs' sensitivity.** Whatever your functions log, the drain destination now stores; your redaction posture (below) travels with the data, and a third-party destination widens the audience for every mistake. Vercel can hide client IP addresses in drains team-wide; decide deliberately whether you need them.

See [Deployment](https://vercel-mcp-reference.vercel.app/deployment/) for where drains fit in project setup.

## Tracing across tiers

A single user action fans out: host to client to server to backend, with the middle hop crossing a [trust boundary](https://vercel-mcp-reference.vercel.app/glossary/#trust-boundary) on the public internet. W3C Trace Context is the standard way to follow it: a `traceparent` HTTP header carrying the trace id, the parent span id, and a sampling decision. Streamable HTTP makes this free in a way stdio never was: every MCP message is an HTTP request, so trace context rides ordinary headers with no protocol invention, from the host's outbound `tools/call` POST through your function and on to its downstream calls. The 2026-07-28 revision also standardizes the in-message form (SEP-414): `_meta` keys named `traceparent`, `tracestate`, and `baggage` carry the same W3C values inside the JSON-RPC message itself, which covers stdio and any intermediary that would drop unfamiliar headers. On this stack prefer the HTTP header and treat the `_meta` convention as the portable fallback; if both appear, they should agree.

```mermaid
flowchart LR
    host[Host + client] -->|"POST tools/call<br/>traceparent"| fn[Server function]
    fn -->|"traceparent"| backend[(Backend)]
    fn --> logs[Runtime logs]
    fn --> drain[Trace drain / OTel]
```

On Vercel the wiring is `@vercel/otel`: an `instrumentation.ts` at the project root whose `register()` calls `registerOTel({ serviceName })`. Next.js propagates inbound trace context automatically; outbound propagation is opt-in per destination via `instrumentationConfig.fetch.propagateContextUrls`, with `dontPropagateContextUrls` as the explicit deny list. Spans leave the platform through a trace drain or your OTel backend's integration.

Two behaviors that bite:

- **Sampling is an AND gate.** For a span to be emitted, the inbound `traceparent` sampling decision (if present) and Vercel's own sampling rules must both say yes. If your traces vanish, check the caller's sampler before your own config: an upstream not-sampled decision darkens the whole path.
- **Propagation is a trust decision.** `traceparent` seems harmless, but consistent ids handed to a third-party MCP server are correlation handles across your users' requests, and a compromised destination can lie in whatever spans it exports. Propagate to backends you operate; put everything else in `dontPropagateContextUrls`, and apply the same judgment to the `_meta` trace keys, which no URL deny list will catch for you. The boundary rule from [trust boundaries](https://vercel-mcp-reference.vercel.app/patterns/trust-boundaries/) applies to telemetry too: a trace may follow a request *into* a server, but no server should see another server's spans, the host's transcript, or context from a different conversation.

Within an exchange, the JSON-RPC `id` remains the protocol-native join key; log it as a span attribute so wire-level records and traces line up.

## Metrics for tool invocations

Aggregate signals tell you what no single log line can:

- **Per-tool invocation count, latency distribution, and error rate**, with error rate keyed off `isError` results. A tool returning `isError: true` is a tool execution failure, distinct from a JSON-RPC protocol error; count them separately, and dimension per tool, per server, and (for multi-tenant servers) per principal from the verified token, never from arguments (see [identity and principals](https://vercel-mcp-reference.vercel.app/security/identity-and-principals/)).
- **Cold and warm, split.** The slowest request you serve is the first one on a fresh instance after a deploy or an idle reclaim; averaged into warm `tools/call` latency it disappears. Vercel's function metrics expose cold start counts; keep the dimension.
- **Duration against `maxDuration`.** A call that hits the ceiling returns nothing at all, so you want to watch the distribution approach the cliff, not learn about it from timeouts. A p95 drifting toward the limit is the signal to reshape the tool as an [async job](https://vercel-mcp-reference.vercel.app/patterns/async-jobs/).
- **In-flight requests, cancellation rate, and notification volume** - the core operational trio from the [internals debugging notes](https://vercel-mcp-reference.vercel.app/internals/overview/#debugging-notes).

Where to compute them: the cheapest robust path is a custom OTel span per `tools/call` carrying `tool`, `isError`, and principal attributes, aggregated wherever your traces land; drains feed the same data to a metrics pipeline if you prefer logs-to-metrics. Vercel's dashboard gives you per-function duration, memory, and cold starts, but the platform does not know what a [tool](https://vercel-mcp-reference.vercel.app/glossary/#tool) is: the per-tool dimension only exists if your code emits it. One edge-side assist arrives with 2026-07-28: the required `Mcp-Method` and `Mcp-Name` headers on every Streamable HTTP POST mean the platform's own request logs and Firewall metrics can slice by method and tool name without touching the body.

## Redaction

Observability and least privilege pull in the same direction: **log enough to diagnose, never more than the caller is entitled to.** Do not log full tool arguments or results by default; arguments carry PII and secrets, and tool *outputs* can carry [prompt-injection payloads](https://vercel-mcp-reference.vercel.app/client-side/tool-result-rendering/) you do not want replayed into a log scraper, an alerting summary, or a downstream model. Log the tool name, the principal, the decision, the shape (sizes, counts, status), and a correlation id; log the payload only behind a deliberate debug flag that is off in production.

The mechanical pattern: a `redact` helper that masks a value to `****` plus a short suffix, and only when the value is long enough that the suffix identifies without revealing. `examples/facade-server` (in the repository) shows the posture end to end: an audit log that records which backend and which scope, and never keys or results.

Serverless sharpens three edges:

- **Every log line has an audience.** Runtime logs are visible to the whole team in the dashboard, and drains forward them to external services; a secret logged once fans out to every destination downstream. The spec's **MUST NOT** list for `notifications/message` (credentials, secrets, personal information) is the right bar for your platform logs too.
- **Never log the token.** With `withMcpAuth` every request arrives with a bearer token; the `Authorization` header, and everything on `AuthInfo` beyond the principal id and scopes, stays out of every record. See [Authentication](https://vercel-mcp-reference.vercel.app/security/checklist/#authentication).
- **Concurrency breaks ambient context.** With [Fluid compute](https://vercel-mcp-reference.vercel.app/glossary/#fluid-compute) running concurrent requests in one instance, a module-level "current user" interleaves principals in your audit trail, which corrupts precisely the record you would need in an incident. Carry request context explicitly; [serverless sessions](https://vercel-mcp-reference.vercel.app/internals/serverless-sessions/) covers why. See [Monitoring & audit](https://vercel-mcp-reference.vercel.app/security/checklist/#monitoring--audit).

An audit trail you cannot trust is overhead; one you can trust is a control.

## Related

- [MCP internals overview](https://vercel-mcp-reference.vercel.app/internals/overview/#debugging-notes) - the symptom-to-cause table your logs and metrics exist to answer
- [Serverless sessions](https://vercel-mcp-reference.vercel.app/internals/serverless-sessions/) - cold starts, instance reuse, and the concurrency hazard behind the redaction rules
- [Deployment](https://vercel-mcp-reference.vercel.app/deployment/) - configuring drains and the rest of the project-level observability hookup
- [Security checklist: Monitoring & audit](https://vercel-mcp-reference.vercel.app/security/checklist/#monitoring--audit) - the audit controls to verify before shipping
- [Tool result rendering](https://vercel-mcp-reference.vercel.app/client-side/tool-result-rendering/) - why tool output is untrusted in logs, not just in UIs
- `examples/async-jobs-server` (in the repository) - [progress notifications](https://vercel-mcp-reference.vercel.app/glossary/#progress-notification) as live observability of long-running work
- `examples/secure-tools-server` (in the repository) - output minimization as a logging discipline

## Bibliography

- Model Context Protocol Specification, *Logging*, version 2026-07-28 - <https://modelcontextprotocol.io/specification/2026-07-28/server/utilities/logging>
- Model Context Protocol Specification, *Tools*, version 2026-07-28 - <https://modelcontextprotocol.io/specification/2026-07-28/server/tools>
- Model Context Protocol Specification, *Deprecated Features*, version 2026-07-28 - <https://modelcontextprotocol.io/specification/2026-07-28/deprecated>
- Model Context Protocol Specification, *Changelog*, version 2026-07-28 (per-request log level, `_meta` trace-context conventions, required MCP headers) - <https://modelcontextprotocol.io/specification/2026-07-28/changelog>
- Vercel Documentation, *Runtime Logs* - <https://vercel.com/docs/logs/runtime>
- Vercel Documentation, *Working with Drains* - <https://vercel.com/docs/drains>
- Vercel Documentation, *Drains Security* - <https://vercel.com/docs/drains/security>
- Vercel Documentation, *Instrumentation (OpenTelemetry tracing)* - <https://vercel.com/docs/tracing/instrumentation>
- W3C, *Trace Context* - <https://www.w3.org/TR/trace-context/>
