Tracing with OpenTelemetry and Apollo Studio
A single GraphQL endpoint can serve thousands of different operation shapes, and classic endpoint-based APM fails outright at that point. GraphQL observability needs resolver-level tracing that makes every single field call visible, combined with OpenTelemetry standardization and distributed tracing across federation gateways.
Table of Contents
- 1. Why classic APM fails for GraphQL
- 2. Fundamentals: spans, traces, and resolver-level tracing
- 3. OpenTelemetry instrumentation inside the GraphQL server
- 4. Apollo Studio tracing format vs. OpenTelemetry
- 5. Spotting N+1 problems through resolver traces
- 6. Field-level metrics and usage reporting
- 7. Distributed tracing across the federation gateway
- 8. Building dashboards and alerting
- 9. Apollo Studio vs. the OTel stack compared
- 10. Summary
- 11. FAQ
1. Why classic APM fails for GraphQL
Classic application performance monitoring groups latency data by URL path, a sensible approach for REST APIs with fixed endpoints like /api/products/:id. A GraphQL server, however, typically has exactly one endpoint, /graphql, behind which thousands of different operation shapes hide. GraphQL observability therefore needs a completely different granularity: not latency per URL, but latency per operation, per field, and ideally per individual resolver call within an operation.
The problem intensifies with nested queries: a single GraphQL request can internally trigger hundreds of resolver calls, each with its own latency, its own database access, and its own failure potential. Without GraphQL observability at the resolver level, a team only sees the total latency of a request, say 800 milliseconds, but not which of the maybe 40 called resolvers is responsible for 700 of those milliseconds. This missing resolution makes performance debugging on GraphQL APIs practically impossible without dedicated tooling support.
A further difference concerns error handling: GraphQL often still returns HTTP 200 on a partial failure, with an errors array alongside partially successful data. Classic APM that reacts only to HTTP status codes misses such partial failures entirely. GraphQL observability therefore has to evaluate the GraphQL response body itself, not just the transport layer.
2. Fundamentals: spans, traces, and resolver-level tracing
A trace represents the full lifetime of a single GraphQL request, a span represents a single unit of work inside that trace. In GraphQL observability, every executed resolver ideally corresponds to its own span, with field name, parent type, and execution duration as attributes, nested according to the query's actual resolution order.
This span hierarchy makes visible which resolvers ran in parallel and which ran sequentially, a decisive distinction for performance optimization. Two resolvers that could theoretically run in parallel but end up running sequentially because of a missing Promise.all batching strategy show up in the trace as consecutive rather than overlapping time windows, a pattern that would remain invisible without GraphQL observability at the span level.
3. OpenTelemetry instrumentation inside the GraphQL server
OpenTelemetry has established itself as the vendor-neutral standard for tracing data and is supported by both Apollo Server and most other GraphQL server implementations. Instrumentation happens through a plugin that automatically wraps every resolver call in its own span.
// otel-setup.js — configure OpenTelemetry for a GraphQL server
import { NodeSDK } from "@opentelemetry/sdk-node";
import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-http";
import { getNodeAutoInstrumentations } from "@opentelemetry/auto-instrumentations-node";
const sdk = new NodeSDK({
traceExporter: new OTLPTraceExporter({
url: "http://otel-collector:4318/v1/traces",
}),
instrumentations: [getNodeAutoInstrumentations()],
serviceName: "products-graphql-service",
});
sdk.start();
// Apollo Server plugin creating a span per resolved field
import { trace } from "@opentelemetry/api";
const tracer = trace.getTracer("graphql-resolvers");
const resolverTracingPlugin = {
async requestDidStart() {
return {
async executionDidStart() {
return {
willResolveField({ info }) {
const span = tracer.startSpan(`${info.parentType.name}.${info.fieldName}`);
const start = performance.now();
return (error) => {
span.setAttribute("graphql.field", info.fieldName);
span.setAttribute("duration_ms", performance.now() - start);
if (error) span.recordException(error);
span.end();
};
},
};
},
};
},
};
This instrumentation produces a dedicated span for every field call, with field name, parent type, and duration. For GraphQL observability on queries with many fields, this full field-level instrumentation should be applied selectively, for instance through sampling, so the instrumentation overhead itself doesn't become a performance problem.
4. Apollo Studio tracing format vs. OpenTelemetry
Alongside OpenTelemetry, Apollo Server also supports its own, older tracing format, sent directly to Apollo Studio and visualized there without a separate collector. For teams already using Apollo Studio as a schema registry, this path is often the fastest entry point into GraphQL observability, because tracing data and schema information converge in the same dashboard.
{
"duration_ns": 184320000,
"execution": {
"resolvers": [
{
"path": ["product", "category", "name"],
"parentType": "Category",
"fieldName": "name",
"startOffset": 4200000,
"duration": 1800000
}
]
}
}
The downside of this proprietary format: it only works within the Apollo ecosystem, while OpenTelemetry can merge database queries, HTTP calls to external services, and infrastructure metrics into the same trace. For GraphQL observability in a heterogeneous system landscape, where the GraphQL server is only one of many components, OpenTelemetry is therefore usually the more future-proof choice, because it continues the same trace across service boundaries, something the proprietary Apollo format alone cannot do.
5. Spotting N+1 problems through resolver traces
The classic N+1 problem, a parent field returns a list, and for every element a separate database query then runs instead of being batched, is unmistakably visible in the span trace: many nearly identical spans with the same field name appear directly one after another, instead of being merged into a single batched call.
# Query triggering N+1 without DataLoader batching
query CategoryProducts {
category(id: "5") {
products {
name
# Each product triggers its own resolver call for "manufacturer"
manufacturer { name country }
}
}
}
Without GraphQL observability at the resolver level, the team only sees high total latency for the query, not the actual root cause. With tracing enabled, a characteristic pattern shows up immediately: twenty nearly identical, sequential Manufacturer.name spans instead of a single batched DataLoader call. This visual pattern in the trace is often diagnosed faster than combing through database query logs.
6. Field-level metrics and usage reporting
Beyond individual traces, GraphQL observability needs aggregated per-field metrics over time: average latency, p95 and p99 percentiles, error rate, and call frequency. These metrics can be derived from collected spans and exported into a Prometheus-compatible format.
// Aggregate per-field latency into Prometheus histograms
import { Histogram } from "prom-client";
const fieldLatency = new Histogram({
name: "graphql_field_duration_seconds",
help: "Duration of individual GraphQL field resolution",
labelNames: ["parent_type", "field_name"],
buckets: [0.001, 0.005, 0.01, 0.05, 0.1, 0.5, 1],
});
function recordFieldDuration(parentType, fieldName, durationSeconds) {
fieldLatency.labels(parentType, fieldName).observe(durationSeconds);
}
These field-level metrics answer a question that pure tracing alone cannot: not "why was this one request slow" but "which field has been consistently the slowest across a thousand requests." For GraphQL observability as a basis for prioritizing optimization work, this aggregated view matters at least as much as individual traces.
7. Distributed tracing across the federation gateway
In a federated setup, a single client request passes through the gateway and then through several subgraph services. Without correctly propagated trace context, GraphQL observability falls apart into isolated, per-service traces with no discernible connection. The W3C Trace Context standard solves this by forwarding trace ID and span ID as HTTP headers to every downstream subgraph call.
# gateway-config.yaml — propagate trace context to every subgraph call
telemetry:
tracing:
otlp:
endpoint: http://otel-collector:4318/v1/traces
propagation:
trace_context: true # forwards traceparent/tracestate headers downstream
baggage: true
subgraphs:
products:
routing_url: https://products.internal.mironsoft.de/graphql
inventory:
routing_url: https://inventory.internal.mironsoft.de/graphql
With correctly configured propagation, a single client request shows up as one continuous trace with spans from the gateway and from every involved subgraph, nested in the actual call order. This end-to-end view is indispensable for GraphQL observability in federation architectures, since a latency problem in the gateway would otherwise be indistinguishable from a latency problem in a single subgraph.
8. Building dashboards and alerting
Raw traces and metrics only deliver value through dashboards that directly answer the questions a team typically asks: which operation slowed down in the last hour? Which subgraph causes the most errors? Grafana with Tempo as the trace backend and Prometheus for metrics is a common combination for self-hosted GraphQL observability.
{
"alert": {
"name": "GraphQL field p99 latency regression",
"condition": "histogram_quantile(0.99, graphql_field_duration_seconds{field_name=\"products\"}) > 0.5",
"for": "5m",
"severity": "warning",
"notify": ["#graphql-alerts"]
}
}
It is important to configure alerts at the field level rather than the overall endpoint level. An alert reacting only to the average latency of the entire /graphql endpoint dilutes the signal of a single, severely degraded field among thousands of fast, unproblematic ones. GraphQL observability with field-granular alerts detects regressions noticeably earlier than aggregated endpoint metrics.
9. Apollo Studio vs. the OTel stack compared
The choice between Apollo Studio's integrated tracing and a self-operated OpenTelemetry stack with Jaeger or Grafana Tempo depends on the team's existing observability ecosystem.
| Criterion | Apollo Studio tracing | OpenTelemetry + Jaeger/Tempo |
|---|---|---|
| Setup effort | Minimal, directly integrated | Higher, needs a collector and backend |
| Cross-service tracing | GraphQL layer only | Database, HTTP, infrastructure included |
| Data sovereignty | With Apollo | Fully self-controlled |
| Cost at high volume | Usage-based, can get expensive | Infrastructure cost, more predictable |
| Schema integration | Directly linked to schema registry | Requires separate correlation |
Mironsoft
GraphQL performance, tracing, and observability infrastructure
Finally making resolver performance visible?
We set up OpenTelemetry tracing for your GraphQL server, including field-level metrics, N+1 detection, and distributed tracing across federation gateways.
Tracing setup
Set up OpenTelemetry instrumentation with resolver-level spans
N+1 analysis
Examine existing resolvers for batching problems through traces
Dashboards & alerting
Build field-granular alerts and Grafana dashboards for your team
10. Summary
GraphQL observability needs a different granularity than classic endpoint-based APM, because a single GraphQL endpoint serves thousands of different operation shapes. Resolver-level tracing with OpenTelemetry makes every field call visible as its own span, exposes N+1 problems through characteristic span patterns, and delivers, through field-level metrics, an aggregated view of which fields stay consistently slow across many requests. Apollo Studio's integrated tracing format offers the faster entry point within the Apollo ecosystem, while a self-operated OpenTelemetry stack delivers cross-service visibility beyond the GraphQL layer.
In federated architectures, correctly propagated trace context across the gateway is not a minor detail but a prerequisite for GraphQL observability to remain meaningful at all, without it traces fall apart into isolated, unrelated fragments per service. Field-granular alerts instead of endpoint averages ensure regressions are caught before they hide inside aggregated metrics.
GraphQL Observability — The key facts at a glance
Resolver-level tracing
Every field call as its own span, nested according to actual resolution order.
N+1 detection
Many identical, sequential spans in the trace are the characteristic pattern of missing batching.
Federation tracing
W3C Trace Context must propagate across the gateway and every subgraph for a continuous view.
Field-granular alerts
Alerts per field instead of per endpoint catch regressions noticeably earlier.