DEV Community

Cover image for NestJS and OpenTelemetry: why we instrument the framework itself
Kamil Mysliwiec for NestJS

Posted on Edited on

NestJS and OpenTelemetry: why we instrument the framework itself

Update, November 2026: NestJS Observe can now share a trace with OpenTelemetry. With openTelemetry: true (@nestjs/observe 0.3.22 and later), the agent reads and sends W3C traceparent and follows an OpenTelemetry SDK running in the same app. The parts of this post about correlation and running both are rewritten for that.

When we started working on NestJS Observe, one of the first questions was straightforward: why not OpenTelemetry?

Fair question. OpenTelemetry is the industry standard, it's vendor-neutral, and a lot of smart people have been working on it full time for years. If you're standardising telemetry across a whole company and a dozen languages, it's the obvious choice, and further down I'll go through where it fits best, where Observe does, and how the two run together.

We made a different trade-off, on purpose. Here's what it was, what you get from it, what it costs, and how it works beside the OpenTelemetry you may already have.

OpenTelemetry in a NestJS app: the setup

The setup first, since that's what most people looking up NestJS and OpenTelemetry are after. The zero-code route needs two packages:

npm i @opentelemetry/api @opentelemetry/auto-instrumentations-node
Enter fullscreen mode Exit fullscreen mode

The instrumentation has to load before Nest does, so it goes on the node command rather than into main.ts. For a CommonJS build, which is what nest new generated up to Nest 11:

OTEL_SERVICE_NAME=orders-api \
OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318 \
node --require @opentelemetry/auto-instrumentations-node/register dist/main.js
Enter fullscreen mode Exit fullscreen mode

A project generated by nest new on Nest 12 builds ES modules, and for those OpenTelemetry needs its loader hook as well:

OTEL_SERVICE_NAME=orders-api \
OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318 \
node --experimental-loader=@opentelemetry/instrumentation/hook.mjs \
  --import @opentelemetry/auto-instrumentations-node/register dist/main.js
Enter fullscreen mode Exit fullscreen mode

That gives you spans for incoming and outgoing HTTP, Express and the common database drivers (pg, mysql2, mongodb, Mongoose). On NestJS 11 and earlier, @opentelemetry/instrumentation-nestjs-core adds Nest's own entry points as well. It doesn't cover NestJS 12 yet: support was added in September and reverted in October after it broke some bundled builds, so a Nest 12 app currently gets the HTTP and driver spans only. All of them go to whatever listens on port 4318, so you also need a backend. For a first look on your machine, Jaeger in a single container is enough:

docker run --rm -p 16686:16686 -p 4318:4318 jaegertracing/jaeger
Enter fullscreen mode Exit fullscreen mode

Open http://localhost:16686, make a request, and the trace is there. In production that container becomes a collector and a backend you operate, or a vendor that accepts OTLP.

That's the whole setup. The rest of this post is about what those traces show you, what they don't, and why we built a different kind of agent for Nest: one that works on its own, or beside the OpenTelemetry you already have.

What OpenTelemetry is good at

Credit first, because it's deserved.

It's a standard and not a product. You instrument once and send the data anywhere, and you can change backends without touching application code. It's polyglot, so your Go service, your Python worker and your Node API all speak the same protocol and share one trace. The library coverage is enormous: Redis, Kafka, the AWS SDK, gRPC, dozens of databases, somebody has already written the instrumentation. And it's yours. Run the collector in your own network and the data never leaves.

Those strengths are real, and this is not an argument against OpenTelemetry. The distinction is that it addresses a broader organizational problem than the immediate debugging problem a NestJS team may be facing.

The problem we wanted to solve

A developer has a Nest app in production. Something is slow. They want to know which of their classes is responsible, and they want to know in the next ten minutes, not after a sprint of infrastructure work.

Two things stand in the way.

The setup

A working OpenTelemetry pipeline for a Node.js service means the SDK and its auto-instrumentations, an exporter, usually a collector, and a backend to store and query. To be fair, the first half has gotten short, as the commands above show. Running the backend, keeping the versions in step and turning raw spans into something a team actually looks at is the part that isn't short.

None of those steps is mysterious, but together they are still operational work that competes with product work. That effort is one reason an application may have logs in production without having traces. Instrumentation only helps once a team has installed and maintained it.

The vocabulary

This is the more interesting one.

Generic Node.js instrumentation sees your app from the outside. An HTTP request came in, some middleware ran, a database driver got called, a response went out. OpenTelemetry's NestJS instrumentation adds the framework's entry points: on NestJS 11 and earlier it emits three kinds of span, one for application creation, one for the request context (Controller.method), and one for the route handler. On NestJS 12 it doesn't run at all yet.

That's useful, and it stops exactly where a Nest app starts being interesting. Your code isn't a handler. It's a controller that calls a service that calls two other services and a repository, behind a guard, inside an interceptor, after a pipe. When a request takes 1.8 seconds, the question is never "was it the handler". Of course it was the handler. The question is which provider.

To get that out of OpenTelemetry you mostly add spans by hand. A decorator on every method you care about, or tracer.startActiveSpan calls sprinkled through your service layer. Teams do this. It's a lot of code, it goes stale, and it only ever covers the methods somebody already suspected. Some vendors go further (Sentry's Nest SDK adds spans for guards, pipes and interceptors), but the providers underneath, where the time actually goes, still aren't in the tree unless you put them there.

What "from the inside" means

Nest is a dependency injection framework. Every provider (controller, service, repository, guard, interceptor, pipe) gets instantiated by the container. So for every single one of them there's a moment where the framework holds the instance before anybody else does.

Nest exposes that moment through the instrument option of NestFactory.create():

const app = await NestFactory.create(AppModule, {
  instrument: ObserveInstrument,
});
Enter fullscreen mode Exit fullscreen mode

instrument.instanceDecorator is called with every instance the container creates: classes it constructs, factory results, useValue providers, even global enhancers. @nestjs/observe returns a Proxy around each one that wraps methods lazily on first access and keeps their names and reflect-metadata intact, so Nest's own reflection still works. Calls run with the proxy as this, which is why this.pricing.quote() inside a service shows up as a child span. For your own classes there's no patching of module internals, no preload and no load order to worry about, because the framework simply hands us the instances. (Database drivers are a different story. pg, mysql2, mssql and mongodb we do patch, like everybody else, because no framework owns them. Outbound HTTP we don't patch at all: it's read from Node's diagnostics_channel.)

From then on, every method you call on a provider during a request, job or message becomes a span, named the way you named it:

POST /orders                          1,812 ms
  AuthGuard.canActivate                   3 ms
  OrdersController.create             1,806 ms   self 1 ms
    OrdersService.create              1,805 ms   self 6 ms
      CustomersService.findById          12 ms
        SELECT customers                 11 ms
      PricingService.quote               38 ms
      PaymentsService.charge          1,749 ms
        POST api.stripe.com           1,747 ms
Enter fullscreen mode Exit fullscreen mode

Nobody wrote a span for PricingService.quote. Nobody had to guess ahead of time that it might matter. And since the tree is complete, self time (a span's duration minus its children) can be computed instead of guessed: OrdersController.create took 1.8 seconds and did one millisecond of actual work, and the trace says so. (When children run in parallel, say under Promise.all, their durations overlap and a parent's true self time is unknowable. Observe shows 0 there rather than inventing a number.)

That is the capability we were trying to make easy. It is not a claim that the tracing model is universally better; it follows from being inside the framework rather than observing it from the outside.

The same position is why the rest works without config. HTTP, microservices and GraphQL come in through hooks in Nest's adapters, the ones that also give us the route template rather than the raw URL. For BullMQ, Bull, @nestjs/schedule and WebSocket gateways we hook the Nest integration packages directly, which is manageable because we maintain those too. Either way the agent knows what a queue consumer is, what a @Cron() handler is, what a gateway's @SubscribeMessage() is, what a GraphQL resolver is, and records each as what it is, not as an HTTP request that looks a bit weird.

The pipeline

We didn't want the second half of the setup either. The agent encodes finished traces into a shared buffer, and a worker thread picks up the batch every few seconds, gzips it and POSTs it to our API, off the thread that serves your requests. No collector to run, no exporter to configure. Two env variables, a module in AppModule, and the instrument option.

It isn't free. Every property read on an instrumented provider goes through a proxy trap, and every traced call allocates a bit of async context. If that matters at your volume, tracesSampleRate samples at the head of the request, and sampled-out requests skip the span bookkeeping.

A product, not just a pipeline

An OpenTelemetry backend stores spans and lets you query them. What you do with them afterwards, grouping errors, deciding what pages someone, tracking the fix, is a second product you pick or something you build. Observe ships those parts with the tracing:

  • Error grouping. Occurrences are grouped into defects, split into handled and unhandled, and shown with the failing source lines, when the defect was first and last seen, and the release it first appeared in. A new unhandled one sends an email, on every plan.
  • Logs. Every ConsoleLogger line written during a traced operation carries its trace id by default. Turn on forwardLogs and the lines are collected too, redacted in your process, and shown under the trace they were written in.
  • Releases and regressions. Every request and job records the release that served it, and each release is compared with the one before it on error rate, latency and throughput. "What changed in the code" lists the queries, outbound calls and methods whose behaviour moved across the deploy, so a regression comes with a starting point.
  • Alerts. Rules on latency, error rate, job failures and queue wait, log patterns, new error groups, runtime metrics, and silence (an app or a cron that stopped reporting), delivered to email, Slack, a webhook, or straight into an issue.
  • Issues and incidents. An issue links to the error, request or trace it came from, and opens with an incident record: how many users and which customers were affected, a timeline from the release onwards, and a summary you can paste into the postmortem. Resolve it with the release that shipped the fix, and Observe watches that operation's telemetry to tell you whether the fix held.
  • An MCP server. Claude Code, or any MCP client, can ask the same questions directly: find_regressions, diff_release, get_trace, get_trace_logs, instead of you copying dashboard data into a prompt.

Error grouping, tracing and the new-error email are on every plan, Free included; log forwarding and alert rules start on Pro, and MCP access depends on the plan too. None of it is impossible with OpenTelemetry. It's the backend you'd choose, the alerting you'd configure and the glue you'd write, and keep running.

This part of the trade-off also has a clear cost.

What it costs

You're choosing a product, not a standard. The data goes to Observe. There's no OTLP export today and no OTLP ingest. You can't point an existing collector at us and you can't point our agent at your existing backend. If your spans have to be vendor-neutral, Observe's can't be. What it can share with OpenTelemetry is the trace itself, which I'll come to below.

It's NestJS on Node.js and nothing else. A Go service in the same request path won't show up in Observe's view of the trace. By default we correlate across services with a request id header (x-request-id). Between Nest services that works on its own over fetch and undici, and over axios or HttpModule on Node 22.12 and later. The default generator also adopts an incoming x-request-id, so an id set by your load balancer carries through. To a service that isn't running our agent it means nothing, and that's what the openTelemetry option is for: with it on, the agent speaks W3C traceparent as well, so a Go service instrumented with OpenTelemetry and the Nest one share a trace id. The Go service's spans stay in your OpenTelemetry backend, though.

Library coverage is narrower than OTel's, though less narrow than "four drivers" suggests. At the driver level, the agent records database queries through pg, mysql2, mssql and mongodb (so TypeORM, Drizzle, MikroORM, Mongoose and Prisma's pg and SQL Server adapters are covered) and outbound HTTP. Everything else you register as a Nest provider is instrumented the same way as your own classes, and that's how most apps wire up their Redis and Kafka clients. So an ioredis client injected into a service shows up as Redis.get under the method that called it, without anyone writing a span. For code that lives outside the container entirely, a client created with new inside a method or a plain function, there's a manual API:

await this.tracer.createSpan('pdf.render', () => renderInvoicePdf(order));
Enter fullscreen mode Exit fullscreen mode

Yes, that's exactly the manual work I complained about above. It's just a much smaller surface.

And it's a hosted service. If your telemetry can't leave your network, this isn't for you.

When OpenTelemetry should be your standard

You run several languages and want one trace across all of them. You already operate a collector and a backend and they work. You need to keep telemetry on infrastructure you control. You depend heavily on libraries we don't instrument and their spans matter to you. You want to be able to change vendors without touching code.

If two of those apply, OpenTelemetry should be the standard across your system, because its standardization and deployment model address those requirements directly. Unless the telemetry can't leave your network, that no longer rules Observe out for the Nest services in it: you can run both, in one trace.

Where Observe fits

Your backend is NestJS, or mostly NestJS. You don't have tracing today, and the real reason is that nobody had the week. The question you keep asking is "which of our classes is slow", and route-level data keeps not answering it. You'd rather run one less piece of infrastructure.

For that team, the practical alternative may not be a carefully tuned OpenTelemetry pipeline; it may be continuing without tracing. Observe is intended for the gap between deciding that tracing would help and having the time to operate a full pipeline.

There's a second team it fits now, too: one that already runs OpenTelemetry, where the Nest services show up in its traces as a route and a few driver calls. For them, Observe is the provider-level view of those same traces.

Can you run both?

Yes, and they now share the trace. It's one option, off by default, so nothing changes for an app that doesn't turn it on:

ObserveModule.forRoot({
  // ...
  openTelemetry: true,
});
Enter fullscreen mode Exit fullscreen mode

With it on, a request that arrives with a W3C traceparent, from a service instrumented with OpenTelemetry in any language or from a mesh or load balancer that traces, is reported under that trace's id. An unrelated x-request-id, like the one Envoy stamps on every request, gives way to it. Calls out of the service carry a traceparent beside x-request-id, with the call's own span as the parent, so the next service's spans nest under it. The caller's sampling decision travels too: a request your OpenTelemetry setup sampled out is still recorded in Observe, and stays sampled out downstream. As with x-request-id, that covers fetch and undici on every supported Node version, and clients built on node:http on Node 22.12 and later.

If the Nest app runs the OpenTelemetry SDK itself, started with the zero-code command from the top of this post for example, Observe reports each request under the trace id of the span the SDK opened for it. That holds when the request starts the trace, and when the SDK propagates B3 rather than W3C. On outbound calls Observe leaves traceparent to an SDK that writes one, so a request has one trace id in both tools and every call carries exactly one traceparent. The SDK keeps its HTTP and driver spans, and Observe adds the provider-level ones. It doesn't matter which of the two loads first, since Observe attaches through NestFactory.create().

What's shared is the trace id, not the spans. Your OpenTelemetry backend shows the other services' part of a trace, and Observe shows the Nest service's, under the same id. Observe writes that id as a UUID, so 4bf92f3577b34da6a3ce929d0e0e4736 in Jaeger is 4bf92f35-77b3-4da6-a3ce-929d0e0e4736 in Observe.

A few limits. It covers HTTP: microservice transports, gRPC and queue jobs carry the trace id as they did before. It only works while the agent is reporting to Observe: it turns on with the first batch Observe accepts, and pauses while your credentials are refused or once batches have failed to get through for a minute, with a line in the log either way. Requests in between are traced as if the option were off. And both tools hook the database drivers. We've tested the pair with pg; with other drivers, try it on staging first instead of taking my word for it.

Sharing the id isn't the same as one picture, though. Should Observe speak OTLP, so that the Go service's spans and the Nest service's end up in the same view? We get asked this a lot. It's not there today and I'd rather say that plainly than hint at something.

To sum up

OpenTelemetry answers "how does an organisation standardise telemetry". We wanted to answer "why is this NestJS endpoint slow, and can I know by lunch".

Being the framework's own team meant we could put the hooks we needed into Nest itself. They're public, and anyone can build on them; we built a product around them. Nest hands us every provider the moment it creates it, and we used that to make the trace look like your code and the setup a few lines long. Everything else about the trade-off, the lock-in and the narrower coverage, comes from choosing depth in one framework over breadth across all of them.

And you don't have to pick one. Observe works on its own, or beside the OpenTelemetry you already have, in the same trace. Either way the setup is npx @nestjs/observe link, which installs the SDK, wires it into your app and writes the key into .env. If OpenTelemetry is already there, add openTelemetry: true to forRoot().

You can look at a real trace before deciding. The live demo needs no signup.

Top comments (0)