# Logging and OpenTelemetry

The Zuplo MCP Gateway emits structured logs alongside its analytics events. Each
log entry is keyed by an `event` string, carries auto-attached fields
identifying the route and authenticated user, and is shaped to cross-reference
the [analytics dashboard](./analytics.mdx) one-to-one. This page explains the
log model — what's emitted, what's deliberately excluded, and how the
identifiers line up across analytics and logs.

For the list of supported destinations and how to enable a log plugin, see
[Logging](../../articles/logging.mdx) in the platform docs.

## Structured-first by design

The gateway writes every log entry in the structured form, with a stable `event`
key plus contextual fields:

```ts
context.log.info(
  { event: "mcp_auth_downstream_token_issued", subjectId, operationId },
  "Gateway issued an OAuth access token to an MCP client",
);
```

The `event` field is the searchable key. Names use snake-case throughout with
`mcp_` as the family prefix — `mcp_auth_downstream_token_issued`,
`mcp_auth_upstream_connection_established`, `mcp_capability_invoked`, and so on.
The human-readable message is for log readers; the `event` field and the
structured properties are what dashboards, alerts, and queries run against.

## The three audiences

Log entries serve three distinct audiences, and the gateway uses severity to
distinguish them:

- **Audit entries** (`info`) record OAuth lifecycle moments — token issuance,
  consent approval, upstream connection established, token revocation. They
  exist so a security or compliance team can answer "what auth events happened,
  when, and to whom?" without scanning request logs.
- **Visibility entries** (`debug` and `info`) record request acceptance,
  response shaping, and other engineering-facing operational detail. They exist
  for the platform team running the gateway.
- **Error entries** (`error`) include flattened error-chain fields — `errName`,
  `errMessage`, `causeName`, `causeMessage`, up to four cause levels — so the
  original failure context survives rethrows. The chain shape means a single log
  entry usually carries enough context to root-cause a failure without
  correlating multiple lines.

## Fields attached to every request log

Three identifying fields are attached to every log entry emitted during an MCP
request:

- `operationId` — the route identity, the same value used as the
  `virtualServerName` in analytics.
- `upstreamServerId` — the upstream id from the token exchange policy, populated
  once the upstream is resolved.
- `subjectId` — the authenticated user's stable subject id, populated once the
  bearer token is validated.

These fields make it trivial to filter a log provider by route, upstream, or
user without scanning message bodies. The same three identifiers also appear on
the corresponding analytics events, which is what lets an operator pivot from a
Portal analytics view to the underlying log entries with the same filter values.

Additional custom log properties can be attached from a policy or handler using
`context.log.setLogProperties()`. See
[Logging → Custom log properties](../../articles/logging.mdx#custom-log-properties)
for the platform-level reference.

## What the gateway never logs

The gateway is deliberately strict about credentials and secrets. The following
are never written to any log entry:

- Bearer tokens (downstream or upstream)
- OAuth authorization codes
- PKCE code verifiers
- Client secrets
- Raw signed JWTs (state, session, browser-ticket)
- Full redirect URIs (only the host is logged, via a `safeHost()` helper)
- Customer request bodies

If a credential or body needs inspection for debugging, it's done through
purpose-built tooling — the gateway's log surface is intentionally narrow, and
widening it would defeat the audit guarantees.

## Cross-referencing with analytics

The gateway uses the same identifiers in logs and analytics, so an operator can
pivot between the two with the same filter values:

- An analytics event's `reasonCode` matches the `reasonCode` field on the
  corresponding log entry (e.g., `missing_token`, `invalid_audience`,
  `connect_required`).
- The analytics `virtualServerName` is the log entry's `operationId`.
- The analytics `subjectId` is the log entry's `subjectId`.
- The analytics `upstreamServerName` is the log entry's `upstreamServerId`.

When the [Analytics](./analytics.mdx) dashboard surfaces an interesting slice —
say, a spike of `connect_required` reason codes for a specific upstream — the
same dimensions filter the log provider directly. The two data surfaces are
designed to reinforce each other, not duplicate each other.

## Routing the logs to your provider

The MCP Gateway logs flow through the standard Zuplo logging pipeline. Enabling
a log plugin in `modules/zuplo.runtime.ts` is independent of the MCP Gateway —
once the plugin is wired up, gateway logs appear alongside the rest of the
project's logs.

Supported destinations include
[AWS CloudWatch](../../articles/log-plugin-aws-cloudwatch.mdx),
[Datadog](../../articles/log-plugin-datadog.mdx),
[Dynatrace](../../articles/log-plugin-dynatrace.mdx),
[Google Cloud Logging](../../articles/log-plugin-gcp.mdx),
[Loki](../../articles/log-plugin-loki.mdx),
[New Relic](../../articles/log-plugin-new-relic.mdx),
[Splunk](../../articles/log-plugin-splunk.mdx),
[Sumo Logic](../../articles/log-plugin-sumo.mdx), and
[VMware Log Insight](../../articles/log-plugin-vmware-log-insight.mdx). For
destinations not in that list, the
[custom logging plugin pattern](../../articles/custom-logging-example.mdx)
applies the same way.

## OpenTelemetry

The gateway integrates with Zuplo's
[OpenTelemetry plugin](../../articles/opentelemetry.mdx) to export traces and
logs in OTLP format. Traces include spans for the request, every inbound policy
(`mcp-*-inbound`), the handler, and the upstream fetch; logs export the same
structured entries described above with their `event` field preserved.

Registering both plugins is a small addition to `modules/zuplo.runtime.ts`:

```ts title="modules/zuplo.runtime.ts"
import { RuntimeExtensions } from "@zuplo/runtime";
import { McpGatewayPlugin } from "@zuplo/runtime/mcp-gateway";
import { OpenTelemetryPlugin } from "@zuplo/runtime";

export function runtimeInit(runtime: RuntimeExtensions) {
  runtime.addPlugin(new McpGatewayPlugin());
  runtime.addPlugin(
    new OpenTelemetryPlugin({
      url: process.env.OTLP_TRACES_ENDPOINT,
      logUrl: process.env.OTLP_LOGS_ENDPOINT,
      authorization: process.env.OTLP_AUTHORIZATION,
    }),
  );
}
```

Grafana Cloud is one common destination — define the endpoint and credentials in
`.env`:

```bash
GRAFANA_OTLP_ENDPOINT=https://otlp-gateway-prod-<region>.grafana.net/otlp
GRAFANA_OTLP_INSTANCE_ID=<instance-id>
GRAFANA_OTLP_API_TOKEN=glc_<token>
```

Use the base OTLP endpoint that ends in `/otlp` — the runtime appends
`/v1/traces` and `/v1/logs` itself. Other OTLP-compatible destinations
(Honeycomb, Dynatrace, New Relic, Tempo, self-hosted Jaeger) work the same way;
substitute the endpoint and credential headers.

:::note

Metrics export isn't supported by the current OpenTelemetry plugin. The plugin
exports traces and logs only. For metrics, use a dedicated
[metrics plugin](../../articles/metrics-plugins.mdx).

:::

## Related

- [Analytics](./analytics.mdx) — the dashboard view of the same underlying
  events.
- [Logging](../../articles/logging.mdx) — Zuplo's general logging guide,
  including custom log properties and the full plugin list.
- [OpenTelemetry](../../articles/opentelemetry.mdx) — trace and log export
  configuration, including the available options on the plugin.
- [Metrics plugins](../../articles/metrics-plugins.mdx) — metrics destinations
  (separate from the OTel plugin).
