GraphQL Observability: Tracing with OpenTelemetry and Apollo Studio
AI generated
{ }
type
GraphQL · Observability · OpenTelemetry · Tracing
GraphQL Observability
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.

19 min read OpenTelemetry · Apollo Studio · Jaeger · Grafana Tempo GraphQL · Observability

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.

11. FAQ: GraphQL Observability

1Why isn't classic APM enough?
APM groups by URL path, GraphQL has one endpoint with thousands of operation shapes, without resolver granularity the cause stays invisible.
2What is a span?
A unit of work inside a trace, in GraphQL ideally a resolver call with field name and duration.
3How to spot N+1 in a trace?
Many identical, sequential spans with the same field name instead of a single batched call.
4Apollo Studio compatible with OTel?
No, its own proprietary format, OpenTelemetry is the more interoperable choice for cross-service visibility.
5Propagating trace context across the gateway?
Through W3C Trace Context headers forwarded to every subgraph call.
6Instrument every resolver?
Not always sensible, sampling or selective instrumentation for very field-heavy queries.
7Field metrics vs. traces?
Traces show a single request in detail, metrics aggregate across many requests per field.
8Why alerts per field?
Endpoint-wide alerts dilute the signal of a single degraded field among many fast ones.
9Does it capture partial failures?
Only with dedicated response body evaluation, classic APM systematically misses HTTP-200 partial failures.
10Best backend combination?
Grafana Tempo for traces plus Prometheus for metrics, well documented and OpenTelemetry-compatible.