HTTP 200 Is Not Health
HTTP 200 Is Not Health
The dashboards for a REST service map cleanly onto status codes: 4xx is the client, 5xx is you, latency is one number per endpoint. GraphQL collapses all of that into one status code that stays 200 while the service falls apart underneath:
{
"data": { "product": { "name": "Bottle", "reviews": null } },
"errors": [{ "path": ["product", "reviews"],
"extensions": { "code": "SUBGRAPH_ERROR" } }]
}
That response is a 200 with a broken field in it. If your alerting watches status codes, a subgraph can be down for twenty minutes before anyone notices, and the first signal will be a user complaining that reviews are missing. The same blindness applies to latency: an operation that normally takes 80ms can take 2.4s and still be a green 200, because the slowness lives in one nested resolver that no endpoint-level metric sees.
Nullability sharpens the difference between a cosmetic failure and a real one. A null on an optional field is a hole a client can hide behind a placeholder; a null on a non-null field bubbles up and nulls its entire parent, so one failing subgraph can blank a whole product card. Both arrive as a 200, both are invisible in an access log, and error counts that ignore which path went null will tell you something broke without telling you what the user lost.
GraphQL therefore needs four instruments, each answering a question the others cannot:
| Instrument | Question it answers |
|---|---|
| Metrics | how often, how fast, how many failed — across all traffic |
| Traces | why this one request was slow, hop by hop |
| Logs with context | what was happening around this failure |
| Field-level timings | which resolver is the hot spot right now |
Start with error taxonomy, because it is the cheapest win: an error counter labelled with extensions.code and a phase (parse, validate, auth, resolve) turns "errors are up" into "auth failures tripled after the token rollout", and those two facts have completely different runbooks.
Instruments That Fit GraphQL
Instruments That Fit GraphQL
Instrument at three seams: the operation, the resolver, and the subgraph fetch. Each seam has a natural span and a natural metric.
import { metrics } from '@opentelemetry/api';
const meter = metrics.getMeter('graphql-api');
const fieldMs = meter.createHistogram('graphql.field.duration', { unit: 'ms' });
const opErrors = meter.createCounter('graphql.operation.errors');
export function withFieldTiming(parentType, fieldName, resolve) {
const coord = parentType.name + '.' + fieldName; // bounded: schema size is bounded
return (parent, args, ctx, info) => {
const start = performance.now();
try {
const value = resolve(parent, args, ctx, info);
return value;
} finally {
// record on the synchronous path; wrap promises with .finally() when async
fieldMs.record(performance.now() - start, { field: coord });
}
};
}
// errors, labelled by code and phase — never by message text
didEncounterErrors({ errors }) {
for (const e of errors) {
opErrors.add(1, {
code: e.extensions?.code ?? 'UNKNOWN',
phase: phaseOf(e), // parse | validate | auth | resolve
operation: info.operationName ?? 'anonymous',
});
}
}
Labels are the design decision. field: "Product.reviews" has a cardinality bounded by your schema — a few thousand at most. query: rawDocument, userId, or traceId are unbounded: each becomes a time series, and the metrics backend either runs out of memory or starts sampling away exactly the data you wanted. Operation name is safe because clients name their operations; anonymous documents need a bucket of their own.
Traces carry the detail metrics cannot. One span per operation, child spans per subgraph fetch and per slow resolver, and trace context propagated into every subgraph so the whole fan-out renders as one tree. That single view answers the question metrics can only hint at: the operation p95 is high because reviews waited 1.8s on a cold connection pool, and you can see it without correlating three dashboards by eye.
Attach operationName and the top-level field list to every log line alongside the request id. When a user says "the product page is slow", one grep on the operation name returns the traces, and one trace returns the subgraph.
Alerts That Name a Runbook
Alerts That Name a Runbook
A useful alert tells the on-call engineer which document to open. Four GraphQL-specific signals earn their pager entries:
- Error-entry rate per operation — count of responses containing
errors[]entries, split by phase. A spike inauthmeans tokens; a spike inresolvemeans a subgraph or a query shape. This is the alert that replaces "HTTP 5xx", which in GraphQL barely moves. - p95 latency per operation name, not per endpoint. There is one endpoint; the operation is the real unit of work, and one pathological document hides inside the aggregate otherwise.
- Subgraph fetch error and timeout rate, labelled by subgraph. Federation's value is partial failure — the gateway keeps serving when one owner is sick — and that value becomes a silent degradation unless you alert on the per-subgraph series.
- Rejection counters: cost-budget rejections and persisted-query not-found storms. The first means a client shipped an unbounded document; the second usually means a manifest shipped without the queries clients send. Both fail in ways no 5xx ever shows.
Add plan-cache hit rate as a leading indicator: a sudden drop means new document shapes are arriving at the gateway, which predicts planning latency and often precedes a client release incident.
What not to do. Do not alert on raw error messages (unbounded, and they change with wording); do not page on a single rejected operation; do not build a dashboard whose panels are top-20 queries by raw text — use operation names and field paths so the panels stay stable across releases.
The dashboard that earns its screen space is small: error rate split by phase, p95 by operation, per-subgraph latency and error rate, top ten slowest fields, and the rejection counters. Every panel should answer "which layer do I open?" — validation, auth, resolver, subgraph, or gateway — because that is the only distinction that changes what the on-call does next.
Practice Problems
Add timing to every resolver in a schema so the hot field shows up on a dashboard without reading traces. Your wrapper must record duration for sync and async resolvers, use labels bounded by the schema, and never let a thrown error skip the recording.
Example:
Input: const resolvers = { Query: { product: (_, { id }) => db.product.get(id) }, Product: { reviews: (p) => db.review.byProduct(p.id) }, }; // after: graphql.field.duration has one series per parentType.fieldName
Output: // labels = "Query.product", "Product.reviews" → bounded by schema size // both sync throws and rejected promises still record a duration // cardinality: no user id, no raw query, no argument values
Coordinates come from the static schema, so the number of time series equals the number of resolvers rather than the number of requests. Recording in a finally-style path (and on promise settlement) keeps the histogram complete exactly when failures happen — those are the durations you most need to see.
Optimal Solution — O(1) overhead per resolver call; O(R) setup for R resolvers time, O(R) label series, bounded by schema size space
Walk the resolver map once at startup, wrap each resolver with a timing function that records on both return and throw paths, and key the metric on the static coordinate.
import { metrics } from '@opentelemetry/api';
const hist = metrics.getMeter('graphql')
.createHistogram('graphql.field.duration', { unit: 'ms', description: 'Resolver time' });
export function instrumentResolvers(resolvers) {
const wrap = (obj, typeName) =>
Object.fromEntries(Object.entries(obj).map(([field, fn]) => {
if (typeof fn !== 'function') return [field, wrap(fn, field)];
const coord = typeName + '.' + field;
return [field, (parent, args, ctx, info) => {
const t0 = performance.now();
const done = (ok) => () =>
hist.record(performance.now() - t0, { field: coord, outcome: ok ? 'ok' : 'error' });
try {
const value = fn(parent, args, ctx, info);
return value && typeof value.then === 'function'
? value.then(done(true), (e) => { done(false)(); throw e; })
: (done(true)(), value);
} catch (e) {
done(false)();
throw e;
}
}];
}));
return Object.fromEntries(
Object.entries(resolvers).map(([type, fields]) => [type, wrap(fields, type)]),
);
} Edge Cases:
- Resolvers returning thenables but not native promises — detect thenables, not just Promise instances
- A thrown error must still record, otherwise the slowest, failing field vanishes from the histogram
- Parent-type resolvers nested under Query/Product are the same coordinate space — keep naming consistent
Quiz
1. A subgraph starts failing for one field. What do you see on a dashboard that tracks only HTTP status codes?
2. Which metric label is safe to use in a high-traffic GraphQL server?
3. Why separate validation errors from resolver errors in the error metric?
4. What does a distributed trace give you that field-level metrics do not?
Flashcards
Question
Why status codes fail GraphQL
Click to reveal answer
Answer
A failed field still returns HTTP 200 with errors[] and nulls — alert on error entries by phase instead.
Question
Cardinality rule
Click to reveal answer
Answer
Label with operation name, field path, subgraph, and error code. Never raw documents, user ids, or IPs.
Question
Error phases to separate
Click to reveal answer
Answer
parse | validate | auth | resolve — each implies a different runbook and a different owner.
Question
Field-level timing
Click to reveal answer
Answer
Histogram per parentType.fieldName; bounded by schema size, and it exposes the hot resolver without reading traces.
Question
Federation-specific signals
Click to reveal answer
Answer
Per-subgraph error/timeout rate, plan-cache hit rate, cost rejections, persisted-query not-found storms.
Revision Notes
Key Takeaways
- 1. Measure at three seams: operation, resolver, and subgraph fetch — each has its own metric and span
- 2. Error codes plus phases turn a single "errors up" alert into a diagnosis
- 3. Bounded labels and per-operation percentiles are what keep the dashboard truthful under load
Interview Tips
- • Start from a failure story (green 200, null reviews) and derive the instruments — interviewers want to see you reason from the blind spot
- • Defend your label set explicitly: name the two labels you refuse to use and the cardinality reason why
Cheat Sheet
Seams: operation → subgraph fetch → resolver. One span at each, one metric at each.
Core series
graphql.operation.duration histogram { operation, rootType, hasErrors }
graphql.operation.errors counter { code, phase, operation }
graphql.field.duration histogram { parentType, fieldName }
graphql.subgraph.duration histogram { subgraph, operation }
graphql.plan.cache counter { outcome: hit|miss }
Labels: operation name, field path, subgraph, error code. Never raw query text, user id, IP, or variable values.
Alert on: error entries per operation (by phase), p95 by operation, per-subgraph error/timeout rate, plan-cache hit drop, cost rejections, persisted-query not-found storms.
Triage order: phase → operation → trace → subgraph → field path.