OpenTelemetry (OTel) is a vendor-neutral specification and set of libraries for producing telemetry - traces, metrics, and logs - in a common data model, so instrumentation code doesn't have to be rewritten for every APM vendor's proprietary agent.
Observability Basics already introduced the three pillars and showed OTel snippets for each one; this page goes one level deeper into OTel itself - how it's actually structured internally, why the API and SDK are split into separate packages, and how a single request's identity threads through every span it touches.
Understanding this model changes how you read OTel's documentation and code: most confusion about "why isn't my span showing up" or "why do I need both of these packages" traces back to not yet having this structure in mind.
OpenTelemetry defines telemetry as a set of signals (traces, metrics, logs) produced through a stable, vendor-neutral API, processed and exported by a separately-configured SDK, so instrumentation and backend choice are decoupled.
Insight: Without a shared model, every APM vendor requires its own proprietary instrumentation, locking your code to a specific backend and duplicating effort across every service and language you run.
When to Use This Model: Instrumenting a new service for distributed tracing, deciding between auto- and manual instrumentation, wiring an exporter/collector pipeline, and debugging why spans from two services aren't linking into one trace.
Limitations/Trade-offs: The SDK adds startup overhead and a real dependency surface; the API/SDK split is easy to get backwards (pulling in the SDK where only the API is needed); unconstrained span attributes and full sampling get expensive fast at scale.
Related Topics: metrics that matter (the RED method), SLOs and error budgets, APM tool selection, the broader observability model.
Telemetry, in OTel's vocabulary, comes in three signals: traces (the path and timing of one operation across a system), metrics (aggregated numbers over time, like request counts or latency histograms), and logs (discrete, timestamped events).
Before OpenTelemetry, each of these typically required a separate, vendor-specific SDK - a Datadog tracer, a Prometheus client, a proprietary log shipper - each with its own instrumentation API, meaning code written for one backend had to be rewritten to switch to another.
OTel's core contribution is standardizing the shape of this data and the API used to produce it, while leaving where it goes as a pluggable, swappable concern.
The foundational unit of tracing is the span: a single named operation with a start time, an end time, a set of key-value attributes, and a status.
A trace is not a flat list of spans - it's a tree, where each span (except the root) has exactly one parent, forming a hierarchy that mirrors how the work was actually nested.
That shape - one root span, children fanning out for each sub-operation, some children spanning entirely different services - is what lets a tracing UI reconstruct "where did the 180ms actually go" instead of just "this request took 180ms."
A resource is metadata describing what produced the telemetry - service name, version, deployment environment - attached once per process rather than repeated on every span, so a backend can group and filter telemetry by service without every span carrying redundant fields.
The split between the API (@opentelemetry/api) and the SDK (@opentelemetry/sdk-node) is the single most important structural decision in OTel, and it's deliberate rather than incidental.
Library authors - the people writing a database driver or an HTTP client that other people will import - depend only on the API package.
The API is a stable, mostly no-op interface: calling tracer.startSpan() with no SDK registered simply does nothing and returns immediately, at negligible cost.
Application authors register an SDK once, at their own entry point, which is what actually turns those no-op calls into real spans that get processed and exported somewhere.
This split exists so that a library's instrumentation doesn't force a specific backend, exporter, or sampling policy onto every application that imports it - the library just describes what happened; the application decides what to do with that description.
Instrumentation comes in two flavors, and they compose rather than compete.
Auto-instrumentation works by patching well-known modules (http, express, common database drivers) at module-load time, wrapping their internals to automatically create spans for requests, queries, and outbound calls without any code changes in your own handlers.
This is why the instrumentation-registering file has to be the very first import in your entry point - if express is imported before the auto-instrumentation package has patched it, the patch has nothing left to intercept.
Manual instrumentation is you explicitly calling tracer.startActiveSpan() around a piece of business logic the auto-instrumentation can't see, because it has no way to know your domain: "captured a payment," "applied a discount," "reconciled an order."
// context propagation, illustrated: the child span inherits the parent's trace id// automatically because startActiveSpan runs the callback inside that span's contexttracer.startActiveSpan("captureOrder", async (parent) => { // any span started inside this callback becomes a child of `parent`, // even across an awaited async boundary - this is what "active context" means await tracer.startActiveSpan("chargeCard", async (child) => { child.end(); }); parent.end();});
Context propagation is the mechanism that keeps a trace coherent across process boundaries: the active span's identifiers are serialized into an outbound HTTP header (traceparent, per the W3C Trace Context standard) on the way out, and deserialized back into an active context on the receiving service, which is exactly what lets a trace span two completely independent Node processes and still render as one connected tree.
Sampling decides which traces actually get recorded and exported, and it's a real engineering trade-off, not a footnote: recording every span for every request at high traffic is expensive to store and query, but sampling too aggressively means the one trace you needed during an incident was never captured.
Head-based sampling decides at the start of a trace, cheaply, before knowing whether anything will go wrong; tail-based sampling waits until a trace completes and can decide "keep this one, it was slow or errored" with much better signal, at the cost of needing to buffer spans somewhere (usually a collector) before the sampling decision is made.
Cardinality is as much a concern for spans as it is for logs: attaching a raw user ID, a full request body, or any effectively-unbounded value as a span attribute inflates storage and query cost in most backends' pricing models, which is why OTel's semantic conventions push toward a fixed, well-known vocabulary of attribute names (http.status_code, db.system) rather than free-form fields.
The collector - a standalone process that receives OTLP telemetry, can batch, filter, and re-export it to one or more backends - decouples your application from any single vendor even further: your service exports to a local collector over OTLP, and the collector's configuration (not your application code) decides whether that data ends up in Datadog, Honeycomb, Grafana Tempo, or several of them at once.
This is also where OTel's evolution is heading: the Logs signal is maturing to sit alongside traces and metrics under the same resource and context model, so a log line, a span, and a metric about the same request increasingly share the same trace_id without a separate correlation mechanism bolted on afterward.
Sampling Strategy
Strength
Weakness
Best Fit
Head-based (fixed rate)
Simple, cheap, decided instantly at trace start
Can miss the exact slow/failed traces you actually want
High-volume services where a representative sample is enough
Tail-based (post-hoc)
Can guarantee errored/slow traces are always kept
Requires buffering spans in a collector before deciding; more infrastructure
Services where every incident-relevant trace matters
Always-on (no sampling)
Nothing is ever missed
Storage/query cost scales linearly with traffic
Low-traffic services or short-lived debugging windows
"OpenTelemetry is an APM product I install." It's an instrumentation standard and a set of libraries for producing telemetry; you still need to point an exporter at a backend (a collector, a SaaS APM, an open-source store) to actually view anything.
"Auto-instrumentation captures everything I need." It covers well-known libraries and protocols (HTTP, common DB drivers), but has no visibility into your own business logic - domain-specific operations need manual spans to show up in a trace at all.
"Importing @opentelemetry/api configures exporters or starts sending data." The API alone is a stable, mostly no-op interface; nothing is recorded or exported until an application registers an SDK, which is a deliberate design choice to keep libraries backend-agnostic.
"More spans always means better observability." Past a point, excessive span creation and high-cardinality attributes add storage and query cost without adding proportional debugging value - the same cardinality discipline that applies to logs applies to spans.
"Traces make logs and metrics unnecessary." Each signal answers a different question - traces show timing and causal structure, metrics show trends and aggregates, logs carry specific arbitrary detail - and OTel's model is explicitly designed to correlate all three, not replace two of them with the third.
A vendor-neutral specification and set of libraries for producing traces, metrics, and logs in a common data model, so instrumentation code isn't locked to a specific observability backend.
Why are the API and SDK separate npm packages instead of one?
So library authors can instrument their code (a database driver, an HTTP client) without forcing every application that imports it to also pull in a specific exporter, sampler, or backend configuration. The API is stable and mostly no-op by itself; the SDK is what an application registers once to actually turn instrumentation into recorded, exported telemetry.
How is a "trace" actually structured internally?
As a tree of spans, not a flat list - each span (except the root) has exactly one parent, forming a hierarchy that mirrors how the underlying work was nested, including work that crossed into entirely different services.
How does context actually propagate across two separate Node processes?
The active span's identifiers are serialized into an outbound header (traceparent, per the W3C Trace Context standard) when your service makes an outbound call, and the receiving service deserializes that header back into an active context before creating its own child span - this is what lets a single trace span multiple independent processes.
Why does the instrumentation-registration file have to be imported first?
Because auto-instrumentation works by patching modules (like express or http) at the moment they're loaded - if the module being patched is imported before the instrumentation package has run, the patch has nothing left to intercept, and no spans get created for that module.
When do I need manual instrumentation instead of relying on auto-instrumentation?
Whenever the operation you care about is business logic rather than a well-known library call - auto-instrumentation only knows about protocols and drivers it's specifically built to patch, so it has no way to know that "apply a discount" or "reconcile an order" is a meaningful unit of work worth its own span.
What's the trade-off between head-based and tail-based sampling?
Head-based sampling is cheap and simple but decides before knowing whether a trace turned out to be interesting, so it can miss exactly the slow or errored traces you'd want during an incident. Tail-based sampling waits until a trace completes and can reliably keep the interesting ones, but requires buffering spans in a collector, which adds infrastructure.
Is running OpenTelemetry always worth the overhead it adds?
Not automatically - the SDK adds startup time and a nontrivial dependency footprint, and unconstrained instrumentation (too many spans, high-cardinality attributes) can add real cost without proportional insight. It pays off most clearly once a service has enough downstream calls or complexity that "where did the time go" isn't answerable from logs alone.
What does a "resource" mean in OpenTelemetry, and why isn't it just another span attribute?
A resource describes what produced the telemetry - service name, version, environment - attached once per process rather than repeated on every single span. Keeping it separate from per-span attributes lets backends group and filter by service cheaply, without every span carrying redundant fields.
What's the point of running a collector instead of exporting directly to a vendor?
A collector receives telemetry over OTLP and can batch, filter, and re-export it to one or more backends based on its own configuration, not your application code. This decouples your service from any single vendor - switching or adding a backend becomes a collector config change, not a redeploy.
Why do span attribute names follow a fixed vocabulary like `http.status_code`?
OpenTelemetry's semantic conventions define standard attribute names for common concepts specifically so tooling and dashboards built against one service work the same way against any other OTel-instrumented service, rather than every team inventing its own field names for the same data.
Does adopting OpenTelemetry mean I no longer need a structured logger like Pino?
No - OTel's Logs signal is standardizing how structured logs correlate with traces and metrics via shared identifiers, but you still emit logs through a library like Pino; the two work together rather than one replacing the other.