Zuplo OpenTelemetry
- Applies to
- Gatewayruntime for API, AI, and MCP gateways
- Hosting
- Managed EdgeManaged DedicatedSelf-Hosted
Zuplo ships with an OpenTelemetry plugin (@zuplo/otel) that instruments your
API and exports traces and logs in OpenTelemetry format. The quickest way to use
it is Zuplo's built-in tracing: add the plugin and your traces are stored by
Zuplo and shown in the portal's Observability tab, with no collector to run
or backend to host. Built-in tracing is available on every plan. You can also
export to your own OpenTelemetry backend (an Enterprise feature), or to both at
once.
Tracing
Tracing helps you monitor performance, identify bottlenecks, and troubleshoot issues in your Zuplo API. The OpenTelemetry plugin automatically instruments your API, so you get timings for each request along with spans for policies, handlers, and subrequests. It supports trace propagation (W3C headers by default), so you can follow a request from the client through to your backend.
What's Traced?
By default, when the OpenTelemetry plugin is enabled, the following is traced:
- Request: The entire request lifecycle is traced, including the time taken to process the request and send the response.
- Inbound Policies: The time taken to execute all inbound policies as well as each policy is traced.
- Handler: The request handler is traced.
- Outbound Policies: The time taken to execute all outbound policies as well as each policy is traced.
- Subrequests: Any use of
fetchwithin your custom policies or handlers is traced. - MCP operations: Every MCP message that the MCP Server handler or the MCP Gateway handles gets a span that follows the OpenTelemetry semantic conventions for MCP. See MCP tracing.
- AI Gateway calls: Every call the AI Gateway makes to an LLM provider gets a GenAI inference span with the model, token usage, and cost. See AI Gateway tracing.
Limitations
One important limitation to keep in mind is that the clock will only increment
when performing I/O operations (for example when calling fetch, using the
Cache APIs, etc.). This is a limitation imposed as a security measure due to
Zuplo's serverless, multi-tenant architecture. In practice this shouldn't impact
your ability to trace, as virtually any code that isn't I/O bound is fast.
Setup
Add the OpenTelemetryPlugin in your zuplo.runtime.ts file. Where it sends
data is up to you: Zuplo's built-in storage, your own backend, or both.
Send Traces to Zuplo
Add the plugin with no configuration. It sends traces to Zuplo and names the service after your project:
zuplo.runtime.ts
When you created your project, Zuplo added this plugin with a sampling ratio already set.
Deploy your project and open the Observability tab to see traces.

Each trace is tagged with the account, project, deployment, and environment it
ran in, plus the request ID (the zp-rid value) that also appears on your
logs, so you can move between a request's logs and its trace. How
long traces are kept depends on your plan — see
data retention.
Traces reach Zuplo only from deployed environments. During local development
(zuplo dev) there is no Zuplo ingest to send to, so the plugin logs a startup
warning and skips the Zuplo destination. Any other destinations you configure
still run.
Export to Your Own Backend
Enterprise Feature
Exporting to your own OpenTelemetry backend is available as an add-on as part of an enterprise plan. If you would like to purchase this feature, please contact us at sales@zuplo.com or reach out to your account manager.
Most enterprise features can be used in a trial mode for a limited time. Feel free to use enterprise features for development and testing purposes.
To send traces to an OpenTelemetry service such as
Honeycomb, Middleware,
Dynatrace, Jaeger, or
others, configure an exporter with the
service's url and headers. It's common for providers to use a header for
authorization. Configuring an exporter sends traces there instead of Zuplo.
OpenTelemetry Protocol
The Zuplo OpenTelemetry plugin only supports sending data in JSON format. Not all OpenTelemetry services support the JSON format. If you are using a service that doesn't support JSON, you will need to use a tool like the OpenTelemetry Collector that can convert the JSON format to the format required by your service.
zuplo.runtime.ts
Sampling and Post-Processing
The plugin supports additional options for advanced use cases, including head sampling and post-processing of spans before export.
zuplo.runtime.ts
Logs and Traces Together
To export logs as well as traces, use the top-level traceUrl, logUrl, and
headers properties instead of the exporter object. The plugin supports both
shapes, but they're mutually exclusive: exporter configures tracing only,
while traceUrl and logUrl configure tracing and logging together with a
shared set of headers. See Logging for how to emit log records.
zuplo.runtime.ts
Send Traces to Zuplo and Your Own Backend
You aren't limited to one destination. To deliver traces to Zuplo and your own
backend at the same time, add a span processor for each. Zuplo is represented by
a ZuploSpanExporter. The processors batch and flush independently, so a slow
or failing backend doesn't hold up the other:
zuplo.runtime.ts
MCP Tracing
When the OpenTelemetry plugin is registered, the MCP Server handler and the MCP Gateway record a span for every MCP request and notification they handle. The spans follow the OpenTelemetry semantic conventions for MCP, so a backend that understands GenAI and MCP telemetry recognizes the span names, kinds, and attributes without any mapping.
There's nothing extra to enable. Register the plugin as described in Setup and MCP spans appear in the Observability tab and in any backend you export to. The plugin's sampling settings apply to MCP spans the same way they apply to the rest of the trace.
All MCP spans use the instrumentation scope mcp, whether they come from an MCP
Server route or the MCP Gateway, so one filter selects every MCP span in a
project.
Span Names and Kinds
Each span is named after the JSON-RPC method, plus the tool or prompt name when there is one:
| Operation | Span name | Example |
|---|---|---|
| Tool call | tools/call {tool name} | tools/call get_weather |
| Prompt request | prompts/get {prompt name} | prompts/get summarize |
| Resource read | resources/read | resources/read |
| Lists | {method} | tools/list, resources/list |
| Lifecycle and notifications | {method} | initialize, server/discover |
| Unknown method or invalid JSON | _OTHER | A request with a malformed body |
Every method that MCP defines up to protocol revision 2026-07-28 keeps its
name, including server/discover and subscriptions/listen. Only a method MCP
doesn't define becomes _OTHER.
The tool or prompt name becomes part of the span name only when the server
actually has that tool or prompt. A call to a tool that doesn't exist produces a
span named tools/call, and the name the client sent is still recorded in
gen_ai.tool.name. This keeps span names bounded, so a client can't create a
new span name for every request. Resource URIs never appear in span names. On
the MCP Gateway, span names don't include the tool or prompt name; read it from
the gen_ai.tool.name or gen_ai.prompt.name attribute.
The MCP Server handler records a SERVER span. The MCP Gateway records two
spans for each message it proxies: a SERVER span for the message as the
gateway received it from the MCP client, and a CLIENT span, its child, for the
message as the gateway forwarded it to the upstream MCP server.
Span Attributes
| Attribute | Recorded on | Description |
|---|---|---|
mcp.method.name | Every span | The JSON-RPC method as the client sent it, for example tools/call. Unknown methods keep their original value here. |
mcp.protocol.version | Every span | The MCP protocol version of the request, for example 2025-06-18. A version Zuplo doesn't recognize is recorded as unknown. The MCP Gateway CLIENT span records it only when the client sent a version. |
mcp.session.id | Stateful sessions | The Mcp-Session-Id of the request. On the MCP Gateway, an initialize span records the session ID the upstream assigns. |
mcp.resource.uri | Resource operations | The resource URI of resources/read, resources/subscribe, and resources/unsubscribe. |
gen_ai.operation.name | Tool calls | Always execute_tool. |
gen_ai.tool.name | Tool calls | The name of the tool the client called. |
gen_ai.prompt.name | Prompt requests | The name of the prompt the client requested. |
gen_ai.tool.call.arguments | Tool calls (opt-in) | The tool call arguments as a JSON string. See Capture tool call content. |
gen_ai.tool.call.result | Tool calls (opt-in) | The tool call result as a JSON string. See Capture tool call content. |
jsonrpc.request.id | Requests | The JSON-RPC id. Notifications have no ID, so their spans don't carry it. |
jsonrpc.protocol.version | Non-standard requests | The JSON-RPC version, recorded only when it isn't 2.0. |
rpc.response.status_code | Error responses | The JSON-RPC error code, as a string, for example -32602. |
error.type | Failed operations | Why the operation failed. See Errors. |
network.protocol.name | Every span | http. |
network.transport | Every span | tcp. |
client.address | SERVER spans | The IP address of the MCP client. |
server.address | MCP Gateway CLIENT span | The host name of the upstream MCP server. |
server.port | MCP Gateway CLIENT span | The port of the upstream MCP server. |
Values the MCP client chooses, such as tool names, resource URIs, request IDs, and error messages, are cut to 256 characters.
Errors
An MCP operation can fail in several ways. The span records each one with an
error.type attribute and an error status:
| Outcome | error.type | Span status |
|---|---|---|
The tool returns a result with isError: true | tool_error | Error |
| The server answers with a JSON-RPC error | The error code, such as -32603 | Error, with the error message |
| The operation throws an exception | The exception name, such as TypeError | Error, with the exception recorded as a span event |
| MCP Gateway: the upstream answers with an HTTP error and no JSON-RPC body | The HTTP status code, such as 502 | Error |
| MCP Gateway: the client cancels before the response arrives | cancelled | Error |
| MCP Gateway: a policy sends a different response | response_replaced | Error |
| MCP Gateway: the upstream ends the stream without a response | no_response | Error |
Errors caused by the client's request aren't errors of the server, so SERVER
spans record rpc.response.status_code for them but no error.type and no
error status. That covers parse errors (-32700), invalid requests (-32600),
unknown methods (-32601), invalid parameters, including a call to a tool that
doesn't exist (-32602), unknown resources (-32002), and requests the
transport rejects with an HTTP 4xx status, such as an unsupported protocol
version. On the MCP Gateway CLIENT span, every JSON-RPC error counts as an
error, because it's the upstream's answer to a request the gateway sent.
A tool that fails still returns HTTP 200 with an isError result, so the
request's entry span reports success while the tools/call span reports
tool_error. Filter on the MCP span to find failed tool calls.
Trace Structure
MCP spans sit inside the request's trace, under the handler span. On an MCP
Server route, the tool's own route runs inside the tools/call span, so the
policies, handler, and outbound fetch calls of that route appear as its
children:
Code
On the MCP Gateway, the CLIENT span holds the call to the upstream MCP server,
including a retry after the upstream answers 401:
Code
The MCP Gateway also passes the trace on to the upstream MCP server. It adds the
CLIENT span's traceparent and tracestate to the forwarded message's
params._meta, alongside the standard HTTP traceparent header, so an upstream
server that reads MCP trace context continues the same trace. The gateway adds
them only to sampled requests, and forwards the original bytes unchanged when
the body is larger than 256 KB or carries a body digest header such as
Content-Digest.
Trace Context From MCP Clients
MCP clients can send trace context inside the request's params._meta
(traceparent and tracestate), separate from the HTTP headers. Zuplo uses it
as follows:
- When
params._meta.traceparentbelongs to the same trace as the HTTP request, the MCP span becomes a child of the client's span, and the span it would otherwise have had as a parent is attached as a link. - When it belongs to a different trace, the MCP span stays in the HTTP request's
trace and links to the client's span. The link has the attribute
mcp.context.sourceset toparams._meta.
The HTTP request's sampling decision always applies: trace context in
params._meta can't turn sampling on or off. Baggage in params._meta isn't
propagated to the requests that tool routes make.
Capture Tool Call Content
By default, MCP spans don't record what a tool received or returned. To record
tool call content, set telemetry.captureToolCallContent to true in the
handler options of an MCP Server route or an MCP Gateway route.
On an MCP Server route:
config/routes.oas.json
On an MCP Gateway route:
config/routes.oas.json
With this option on, each sampled tools/call span records:
gen_ai.tool.call.arguments: the arguments the client sent. On an MCP Server route, they're recorded once they pass validation. A call without arguments records{}.gen_ai.tool.call.result: the tool result, for successful calls only. A result withisError: trueisn't recorded.
Both are JSON strings capped at 8,192 characters.
On the MCP Gateway, both the SERVER and the CLIENT span of a forwarded
tools/call carry the attributes, and the result is the one the upstream MCP
server returned. A response the gateway builds itself, such as a request to
connect the upstream, is never recorded as a tool result. With the option on,
the gateway reads tools/call responses up to 1 MiB, instead of the usual 64
KiB, so it can record larger results. A result in a JSON response, or in the
event-stream event that carries it, larger than 1 MiB still reaches the client
but isn't recorded. The gateway turns capture on only for the boolean true.
Any other value, including the string "true", leaves it off.
Tool arguments and results often contain personal or otherwise sensitive data.
Turning on captureToolCallContent sends that data to Zuplo's trace storage and
to any backend you export to. When a tool handler throws, the exception message
and stack are recorded on the span whether this option is on or off, so keep
sensitive data out of the errors your tool handlers throw.
Query MCP Spans in Your Backend
Because the attributes follow the semantic conventions, you can query MCP activity directly in any backend that receives your traces. For example:
- Find slow tool calls: filter on
gen_ai.operation.name = execute_tooland sort by duration, grouped bygen_ai.tool.name. - Find failing tools: filter on
error.type = tool_error. - Compare protocol versions in use: group by
mcp.protocol.version. - Follow one client session: filter on
mcp.session.id. - Compare gateway overhead with upstream latency: compare a
SERVERspan's duration with itsCLIENTchild's duration.
The OpenTelemetry semantic conventions also define the
mcp.server.operation.duration and mcp.client.operation.duration metrics. The
OpenTelemetry plugin exports traces and logs, not metrics, so use MCP span
durations to measure latency.
Performance and Sampling
Tracing isn't free. The plugin instruments every request: it opens spans for the
request, each policy, the handler, and any fetch subrequests, and holds them
in memory until the request finishes. Spans are exported after the response is
sent, so tracing adds little to any single response time, but the CPU and memory
it consumes per request reduce the headroom available for the next one. On
high-throughput APIs, that shows up as lower peak throughput and higher tail
latency.
Sample traces on any API that runs at a high request rate — high-RPS production
traffic, latency-sensitive service-to-service calls, or an API you're about to
load test. A ratio of 0.1, which keeps 10% of
traces, is the standard starting point: enough traffic to see how the API
behaves, without paying the export cost on every request. Tracing every request
is a reasonable default during development and for APIs at moderate request
rates.
Sample a Percentage of Traces
Set sampling.headSampler.ratio to the fraction of traces to keep. The ratio
applies to every destination, including Zuplo's built-in storage. When you
created your project, Zuplo set it up this way: every request traced in your
working copy, 10% everywhere else.
modules/zuplo.runtime.ts
Set ratio to a constant to use one value everywhere. Zuplo populates
ZUPLO_ENVIRONMENT_STAGE, which reads working-copy in your working copy and
production, preview, or local elsewhere, so local development
(zuplo dev) samples at 10% along with deployed environments. See
system environment variables
for the full list.
By default, traces whose root span ends in an error are exported whatever the
ratio, so errors stay debuggable on a heavily sampled API. Supplying your own
sampling.tailSampler replaces that behavior.
Sampling cuts the volume of trace data exported and stored, which lowers export overhead and backend cost. The per-request instrumentation still runs on every request, so sampling reduces the cost of tracing rather than removing it.
Disable Tracing
Sampling leaves some per-request work in place. Where you need none of it — measuring the gateway's raw capacity, or squeezing the last of the headroom out of a high-RPS environment — turn tracing off instead.
To remove the overhead entirely, don't register the plugin. Gate it on an environment variable so you can control tracing per environment without changing code:
zuplo.runtime.ts
Set TRACING_ENABLED to true on the environments you want traced and leave it
unset everywhere else. Tracing is off unless you opt in, so an environment built
for maximum throughput — or for a load test — carries no tracing overhead until
you ask for it.
Where the plugin doesn't run, traces stop appearing in the Observability tab for that environment. Request analytics and logs come from a separate pipeline, so they aren't affected.
Account for Logging Too
Logging costs compute and memory per request the same way tracing does. On a high-throughput API, log the events you'd act on rather than everything that happens, and keep custom log properties small. Sampling traces while logging verbosely on every request moves the cost rather than removing it. See Performance Under High Load.
Logging
The plugin can also export logs in OpenTelemetry format. Logs are sent to your
own endpoint configured with the logUrl property (see
Logs and Traces Together); Zuplo's built-in storage
covers traces today, with managed logs and metrics planned for future releases.
To emit OpenTelemetry logs from your handlers and policies, use the
context.log object:
Code
You can also set additional custom log properties using
context.log.setLogProperties!:
Code
After setting a custom property, all subsequent log messages will include that
property. These logs are exported to the configured log endpoint in
OpenTelemetry format: the log message is in the message field and the custom
properties are in the attributes field.
Custom Tracing
You can add custom tracing to your Zuplo API using the OpenTelemetry API. The example below shows how to implement tracing in a custom policy.
Code
This will result in the following spans:
Code