NestJS 與 OpenTelemetry:NestJS 團隊為何選擇從框架內部進行追蹤
NestJS and OpenTelemetry: why we instrument the framework instead
NestJS 團隊説明,NestJS Observe 透過 Nest 框架建立 provider 時取得的實例,為呼叫中的 provider 方法自動建立 span,呈現應用程式內部的呼叫層次。
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 exactly when it's the better fit.
We made a different trade-off, on purpose. Here's what it was, what you get from it, and what it costs.
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
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
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
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
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 something else.
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 built on OpenTelemetry 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,
});
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
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
ConsoleLoggerline written during a traced operation carries its trace id by default. Turn onforwardLogsand 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 vendor-neutrality is a hard requirement for you, that ends the conversation, and it should.
It's NestJS on Node.js and nothing else. A Go service in the same request path won't show up in the trace. We correlate across services with a request id header (x-request-id) and not with W3C traceparent. 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.
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));
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 you should use OpenTelemetry instead
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 conditions apply, OpenTelemetry is likely the better fit because its standardization and deployment model address those requirements directly.
When this trade-off is the better one
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.
Can you run both?
In principle, yes. The agent doesn't depend on OpenTelemetry and doesn't replace any of it, so you can install them side by side, which is a decent way to compare them on real traffic. Both hook the database drivers though, so try it on staging first instead of taking my word for it. They won't share a trace. Different ids, different propagation.
Should Observe speak OTLP, so that the Go service shows up in the same picture? 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.
If that trade-off does not fit, OpenTelemetry remains a strong standard choice. If it does, the setup is npx @nestjs/observe link, which installs the SDK, wires it into your app and writes the key into .env.
You can look at a real trace before deciding. The live demo needs no signup.
來源:DEV Community · dev.to