APM for tRPC and GraphQL Resolvers: Find the Slow Field Fast
Which resolver took 2.8 of your 3-second query? Per-field APM tracing reveals all.
The trace said 2,847ms. The GraphQL resolver APM data showed four root fields. Which one took 2.8 seconds?
I spent forty-five minutes clicking through Datadog spans before I found it. The orders field looked fast at the top level — 12ms to resolve the array. But each order had an items field. Each item had a product field. Each product had a vendor field. And vendor was calling an external API. Fifty orders. Fifty items. Fifty vendor API calls. Sequential. No batching.
The trace was all there. I just couldn't see it because the spans were nested six levels deep and I didn't know what I was looking for.
This is the tutorial I wish I'd had. Per-field APM tracing for GraphQL and tRPC, set up so you can actually find the slow resolver instead of guessing. N+1 detection. Per-field latency breakdowns. The kind of instrumentation that lets you answer "why is this query slow?" in thirty seconds instead of thirty minutes.
What You'll Have by the End
- OpenTelemetry instrumentation for GraphQL resolvers with per-field spans
- tRPC procedure tracing with database call visibility
- N+1 query detection that shows you the exact resolver pattern
- A dashboard showing P95 latency broken down by resolver
- Alerts when specific resolvers exceed latency thresholds
Most of this is configuration, not code. The hard part is knowing which knobs to turn. (And honestly? I got half of them wrong the first time.)
Prerequisites
- A GraphQL API (Apollo Server, TypeGraphQL, Pothos, Yoga) or tRPC router
- Node.js 18+ or Bun runtime
- JustAnalytics account with APM enabled (Pro plan)
- Basic familiarity with OpenTelemetry concepts — if "span" and "trace" sound foreign, read the observability glossary first
- Optional: Prisma or Drizzle for database tracing (raw SQL works too)
I'm assuming you have a GraphQL or tRPC API in production with real traffic. Tracing a local dev server is good for testing your setup, but you won't see real performance patterns until you've got production load.
Step 1: Install OpenTelemetry Dependencies
tRPC and GraphQL both need the same base instrumentation. Start here.
npm install @opentelemetry/api \
@opentelemetry/sdk-node \
@opentelemetry/auto-instrumentations-node \
@opentelemetry/exporter-trace-otlp-http
For GraphQL specifically, add the instrumentation package:
npm install @opentelemetry/instrumentation-graphql
For tRPC, there's no official instrumentation package (as of July 2026), but the auto-instrumentations-node package catches HTTP calls and you'll add manual spans for procedures. More on that in step 4.
Create your OpenTelemetry setup file. This needs to run before anything else in your application:
// instrumentation.ts
import { NodeSDK } from "@opentelemetry/sdk-node";
import { getNodeAutoInstrumentations } from "@opentelemetry/auto-instrumentations-node";
import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-http";
import { GraphQLInstrumentation } from "@opentelemetry/instrumentation-graphql";
const sdk = new NodeSDK({
serviceName: "your-api-service",
traceExporter: new OTLPTraceExporter({
url: "https://otel.justanalytics.app/v1/traces",
headers: {
Authorization: `Bearer ${process.env.JA_OTEL_KEY}`,
},
}),
instrumentations: [
getNodeAutoInstrumentations({
"@opentelemetry/instrumentation-fs": { enabled: false },
}),
new GraphQLInstrumentation({
depth: 10,
mergeItems: true,
allowValues: true,
}),
],
});
sdk.start();
That depth: 10 matters. It controls how many levels of nested resolvers get traced. If your schema is shallow, 5 is fine. If you've got deeply nested types (user → orders → items → product → vendor), bump it to 10 or higher.
Import this file first in your entry point:
// server.ts
import "./instrumentation";
// ... rest of your server code
If you're on Next.js, use the instrumentation.ts file in your project root — Next.js 15+ picks it up automatically. For a deeper dive on framework-specific setup, see our Next.js observability integration guide.
Step 2: Configure GraphQL Field-Level Tracing
The default GraphQL instrumentation creates one span per query. That's not useful — you need spans per field.
Apollo Server has built-in support for field-level tracing. Enable it in your server config:
import { ApolloServer } from "@apollo/server";
import { ApolloServerPluginInlineTrace } from "@apollo/server/plugin/inlineTrace";
const server = new ApolloServer({
typeDefs,
resolvers,
plugins: [
ApolloServerPluginInlineTrace(),
],
});
For TypeGraphQL or Pothos, the OpenTelemetry instrumentation handles field spans automatically once depth is configured correctly.
Now here's the part that bit me: resolver spans don't include database queries by default. Your trace will show user resolver → posts resolver → comments resolver, but not the actual SQL queries inside each resolver.
Add Prisma instrumentation to fix that:
npm install @prisma/instrumentation
// instrumentation.ts additions
import { PrismaInstrumentation } from "@prisma/instrumentation";
// Add to instrumentations array:
new PrismaInstrumentation(),
Now your traces show: posts resolver (15ms) → prisma.post.findMany (12ms). The 3ms difference is JavaScript overhead. More importantly, if posts spawns 50 separate prisma.author.findUnique calls, you'll see all 50 as child spans — that's your N+1.
Step 3: Detect N+1 Patterns in Traces
N+1 queries are the single most common GraphQL performance bug. Your schema looks like this:
type Post {
id: ID!
title: String!
author: User!
}
type Query {
posts(first: Int): [Post!]!
}
Your resolver looks like this:
const resolvers = {
Query: {
posts: () => prisma.post.findMany({ take: 50 }),
},
Post: {
author: (post) => prisma.user.findUnique({ where: { id: post.authorId } }),
},
};
Query 50 posts. Each post resolves its author. 50 author queries. N+1. Classic.
It's embarrassingly easy to write. I've shipped this exact bug more times than I'd like to admit.
In your trace, this shows up as:
posts (45ms)
├── Post.author (2ms)
├── Post.author (1ms)
├── Post.author (2ms)
... (47 more)
└── Post.author (1ms)
Individually, 2ms per author is fast. Collectively, they add up to 100ms+ because they run in sequence. (GraphQL field resolution is sequential within a type by default.)
JustAnalytics flags this pattern automatically in the APM view. Look for the "Repeated spans" warning — it shows when the same resolver executes more than 5 times under a single parent.
The fix is DataLoader:
import DataLoader from "dataloader";
const createLoaders = () => ({
user: new DataLoader(async (ids: string[]) => {
const users = await prisma.user.findMany({ where: { id: { in: ids } } });
const userMap = new Map(users.map((u) => [u.id, u]));
return ids.map((id) => userMap.get(id));
}),
});
const resolvers = {
Post: {
author: (post, _, ctx) => ctx.loaders.user.load(post.authorId),
},
};
After implementing DataLoader, the trace shows:
posts (22ms)
├── dataloader.batch (8ms)
│ └── prisma.user.findMany (6ms)
└── Post.author (resolved from cache, <1ms) x50
Fifty queries collapsed to one. The trace proves it worked. Teams that have set up SLO error budget alerts will see their latency budget stop bleeding once N+1s are fixed.
Step 4: Instrument tRPC Procedures
tRPC doesn't have nested resolvers like GraphQL, but it has the same problem: slow procedures with invisible internals.
Create a middleware that wraps every procedure in a span:
import { initTRPC } from "@trpc/server";
import { trace, SpanStatusCode } from "@opentelemetry/api";
const t = initTRPC.create();
const tracingMiddleware = t.middleware(async ({ path, type, next }) => {
const tracer = trace.getTracer("trpc");
return tracer.startActiveSpan(`trpc.${type}.${path}`, async (span) => {
try {
const result = await next();
span.setStatus({ code: SpanStatusCode.OK });
return result;
} catch (error) {
span.setStatus({ code: SpanStatusCode.ERROR });
span.recordException(error as Error);
throw error;
} finally {
span.end();
}
});
});
export const publicProcedure = t.procedure.use(tracingMiddleware);
Every procedure call — user.getById, posts.list, orders.create — now creates a span. Database calls within the procedure show as child spans (if you've instrumented Prisma/Drizzle).
For input/output tracing (careful with PII):
const tracingMiddleware = t.middleware(async ({ path, type, next, rawInput }) => {
const tracer = trace.getTracer("trpc");
return tracer.startActiveSpan(`trpc.${type}.${path}`, async (span) => {
// Log input shape, not values — avoid PII
span.setAttribute("trpc.input_keys", Object.keys(rawInput || {}).join(","));
const result = await next();
// Log output type
span.setAttribute("trpc.output_type", Array.isArray(result.data) ? "array" : typeof result.data);
return result;
});
});
I learned the hard way not to log full input/output objects. A customer's email address in your traces violates GDPR faster than you can say "data processor agreement." Stick to shapes and types. If you need to handle PII in your observability stack, our GDPR session replay PII masking guide covers the compliance angle in detail.
Step 5: Build Your Resolver Latency Dashboard
Raw traces are useful for debugging specific requests. Aggregate metrics tell you what's actually slow.
In JustAnalytics, navigate to APM → Services → your-api-service. The service map shows your GraphQL/tRPC endpoint with downstream dependencies.
Create a custom dashboard with these panels:
Panel 1: Top 10 Slowest Resolvers (P95)
- Query:
histogram_quantile(0.95, sum(rate(span_duration_bucket{span_name=~"graphql.resolve.*"}[5m])) by (span_name, le)) - Shows which fields consistently take longest
Panel 2: N+1 Candidates
- Query:
sum(rate(span_count{span_name=~"graphql.resolve.*"}[5m])) by (parent_span_name) > 10 - Surfaces resolvers that execute more than 10 times per request
Panel 3: Database Time vs Resolver Time
- Stacked bar showing how much of your resolver time is actual database work vs JavaScript overhead
- If database is 90%+, optimize queries. If it's 50%, look at your resolver logic.
Panel 4: Error Rate by Resolver
- Which fields are throwing exceptions?
- Correlate with error tracking to see full stack traces
Set alerts on Panel 1 — if any resolver's P95 exceeds 500ms, you want to know before users complain.
Step 6: Trace a Real Slow Query End-to-End
Theory's nice. Let's trace an actual slow query.
Say your users report the dashboard is slow. You've got this query:
query Dashboard {
me {
recentOrders(first: 20) {
items {
product {
vendor {
name
rating
}
}
}
}
}
}
Open JustAnalytics APM. Filter traces to span_name = "graphql.execute" and sort by duration descending. Click the slowest one.
The waterfall shows:
graphql.execute (3,412ms)
├── graphql.resolve me (4ms)
├── graphql.resolve recentOrders (89ms)
│ └── prisma.order.findMany (85ms)
├── graphql.resolve items (2ms) x20
├── graphql.resolve product (1ms) x47
├── graphql.resolve vendor (45ms) x47 ← HERE
│ └── http.get vendor-api.com (42ms) x47
└── graphql.resolve name (0ms) x47
Found it. The vendor field calls an external API. 47 times. Each call takes 42ms. That's 1,974ms just on vendor lookups.
I genuinely hate external API dependencies in nested resolvers. They're almost always the problem.
The fix options:
- Batch the API calls — if the vendor API supports bulk lookup, use DataLoader
- Cache vendor data — vendor info doesn't change often, cache for 5 minutes
- Make it async — if vendor isn't critical, resolve it lazily with
@defer(if your client supports it)
After implementing caching:
graphql.execute (412ms)
├── graphql.resolve vendor (3ms) x47
│ └── cache.get (1ms) x47
│ └── http.get vendor-api.com (42ms) x1 ← Single cache miss
3,412ms → 412ms. The trace shows exactly where the time went and proves your fix worked.
Common Errors and How to Fix Them
Error: "Spans not appearing in traces"
Check that instrumentation.ts imports before any other code. OpenTelemetry needs to patch modules before they're loaded. In Next.js, use the instrumentation.ts convention. In raw Node, use node --require ./instrumentation.js or import it first in your entry file.
Error: "GraphQL spans show but database spans don't"
Prisma instrumentation requires the preview feature flag. In your schema.prisma:
generator client {
provider = "prisma-client-js"
previewFeatures = ["tracing"]
}
Then regenerate: npx prisma generate.
Error: "Too many spans, traces are unreadable"
You're tracing scalar field resolution. Set ignoreResolveSpans: true in GraphQL instrumentation to skip fields that just return a property:
new GraphQLInstrumentation({
ignoreResolveSpans: true, // Skips trivial resolvers
depth: 10,
});
Error: "Trace latency numbers don't match real latency"
Spans measure time inside the function, not queue wait time. If your request sat in a connection pool queue for 500ms before executing, that's not in the span. Check your framework's metrics for request queue time — it's a separate metric from span duration.
Error: "N+1 detected but DataLoader doesn't help"
DataLoader batches within a single tick of the event loop. If your resolvers use await sequentially before calling the loader, batching doesn't happen. Collect all IDs first, then batch:
// Bad: sequential awaits prevent batching
for (const post of posts) {
post.author = await ctx.loaders.user.load(post.authorId);
}
// Good: parallel loading enables batching
const authors = await Promise.all(
posts.map((post) => ctx.loaders.user.load(post.authorId))
);
Next Steps
You've got per-field tracing for GraphQL and tRPC. Slow resolvers are visible. N+1s are flagged.
From here:
- Set latency budgets — define acceptable P95 per resolver and alert on breaches
- Add custom spans for business logic — trace that ML model call or external API
- Correlate with frontend — session replay shows what users experienced during slow queries
- Build a runbook — when X resolver is slow, check Y dependency first
Teams using JustBrowser for multi-account management can test GraphQL performance across different user contexts. And if your API handles paid traffic, ClickzProtect monitors for bot patterns that might be hammering your resolvers artificially — synthetic load that looks like real users but skews your P95 numbers.
The goal isn't perfect traces. It's finding the slow resolver in thirty seconds instead of thirty minutes. Everything else is optimization. And look — you'll still have slow days where you stare at a waterfall chart for twenty minutes wondering why nothing makes sense. That's normal. But at least you'll be staring at data instead of guessing.
Frequently Asked Questions
Can I trace tRPC procedures the same way as GraphQL resolvers?
Yes. tRPC procedures map 1:1 to OpenTelemetry spans. Each procedure call becomes a span with its input/output captured in attributes. The main difference is nesting — GraphQL has nested resolvers (user → posts → comments), while tRPC procedures are usually flat. For tRPC, focus on tracing database calls within procedures rather than the procedure chain itself. The OpenTelemetry middleware for tRPC handles span creation automatically.
How do I detect N+1 queries in GraphQL using traces?
Look for a parent span with many identical child spans executing sequentially. If your posts resolver spawns 50 separate author SELECT queries (one per post), the trace will show 50 database spans under a single resolver span. Tools like JustAnalytics group these and flag the pattern. The fix is usually a DataLoader or batch query — trace again after the fix to verify the 50 spans collapsed to 1-2.
Does tracing add latency to my resolvers?
Minimal. OpenTelemetry span creation adds microseconds — under 100μs per span in most implementations. The real cost is exporting: if you're synchronously shipping spans to a collector, that adds latency. Use async exporters with batching (the default in most SDKs). We've measured under 2% overhead on P99 latency for apps creating 50-100 spans per request. If you see higher overhead, check your exporter configuration.
Should I trace every resolver or just the slow ones?
Start with everything, sample aggressively. Trace all resolvers but sample 1-10% of requests in production. When you see a slow trace, you want full visibility — you don't want to discover the slow resolver wasn't instrumented. For high-traffic APIs (10K+ RPS), sample at 1% or less for cost reasons. For debugging specific issues, temporarily increase sampling to 100% on a single endpoint.
Try JustAnalytics
All-in-one observability in one under-5KB script: cookieless analytics + error tracking + APM + session replay + uptime + structured logs. Replaces GA4 + Sentry + Datadog + Pingdom + LogRocket. Free tier (100K events/mo), Pro $49/month ($39 annual).
Author at JustAnalytics.