Skip to content
Obscenter

Blog 26 October 2026 · 6 min read

From a slow page to the SQL behind it: end-to-end tracing

One trace from the browser click through your Node.js services to the database query, with OpenTelemetry spans from other languages merged in.

One trace of POST /api/checkout on the Northwind Coffee API, opened span by span down to its database query

"The checkout page is slow" is where most performance work starts, and where most of it stalls. The browser says the document took two seconds. The API logs say every request was fine. The database dashboard shows nothing unusual. Each tool is right about its own layer and none of them can say which query that particular visitor was waiting for.

Obscenter records the page load, the request that served it and the query underneath as one story. This article explains how the pieces are joined — including the part most tracing setups get wrong, the page load itself.

What the Node.js agent records inside the process

For Node.js services, tracing comes from an in-process agent loaded with --require through NODE_OPTIONS. It is one file with no dependencies, it opens no port, and everything it does is wrapped: a failure inside the agent costs measurements, never your application.

For every request it records:

  • The route and its timing, as a route shape rather than a raw URL, so /orders/1842 and /orders/1843 are one endpoint.
  • Database spans for pg, mysql and mysql2, mongodb, redis and ioredis, and Node's built-in node:sqlite.
  • Outgoing calls made with http.request and with fetch, which are different code paths in Node and are both covered.
  • Exceptions thrown while the request was running.
  • CPU and garbage-collection time spent on that request.

Headers pass through an allowlist — host, user agent, content type, referer and a few others. Cookies and authorization headers are not on it. Request and response bodies are not collected unless you switch sampling on for that service.

Backend services for Northwind Coffee with request rate, p95 and error rate
Every instrumented service with its routes, timings and errors

Linking a page load to the request that served it

Here is the part that usually breaks. The standard way to connect a browser call to a server trace is the W3C traceparent header, added by the browser-side code to each outgoing request. But the browser fetches the HTML document before any script exists, so no header can ever be put on a page load. On a server-rendered site, that document is often the single biggest thing on the page — and in a normal setup it is linked to nothing.

Obscenter turns the direction around. The server knows the trace id, so it hands it back:

  • The Node agent adds Server-Timing: rumtrace;desc="<trace id>" to every response it handles, appended to whatever Server-Timing the application already sends.
  • The browser tag reads that value off the navigation entry and off every same-origin resource entry. That covers the document and every request a framework made through its own transport — neither of which a browser-side wrapper could ever have tagged.
  • Where nginx injects the tag, it also writes nginx's own $request_id into the tag as data-trace. A response header can be dropped by a server block that sets headers of its own; the page body cannot.

For API calls to other origins, the tag can still add traceparent and a baggage header carrying only the session, visitor and application ids. That is opt-in per origin, matched exactly rather than by prefix, because those headers make cross-origin calls preflighted and the API has to allow them.

Finding the slow database query

Database spans are named by the shape of the statement. Quoted strings and numbers are replaced with ? before anything leaves the process, so queries group by what they look like and your customers' data stays where it was. Mongo commands are named by operation, collection and the keys the filter used — never the values.

For Postgres, the agent can also read the database's own statistics: pg_stat_statements for per-statement cost, and pg_stat_database for the things a statement list cannot show — rollbacks, deadlocks and a falling cache hit ratio. Each database becomes an entity with its own page, and those three numbers are watched by the problem engine.

Databases page with a card per database: PostgreSQL on db-1 and the PostgreSQL each service talks to
Every database the services use, found from their calls and from the machine it runs on

OpenTelemetry traces from Python, Go and Java

The in-process agent is Node-only. For everything else, Obscenter speaks OTLP, the wire format every OpenTelemetry SDK already uses. Point an exporter at your install with two environment variables:

OTEL_EXPORTER_OTLP_ENDPOINT=https://your-collector.example/
OTEL_EXPORTER_OTLP_HEADERS=x-api-key=rum_…
OTEL_EXPORTER_OTLP_PROTOCOL=http/json

OTLP over HTTP with JSON is accepted at /v1/traces. Server spans become requests, spans with a db.system become database spans, client spans become outgoing calls. A FastAPI service and a Node service then land in the same trace list, the same service pages and the same detectors.

One request, one row

The harder case is a Node service that is both instrumented by the agent and exporting OpenTelemetry — a Next.js app with its own spans, for example. That is one request seen by two witnesses, and storing it twice produces two half-truths under two service names. Obscenter merges instead:

  1. When the service name matches a natively instrumented service, no second service is created and no second request row is written. The OTLP spans are grafted into the native request by trace id.
  2. If the SDK started its own root instead of continuing the incoming context, they are matched by overlap: same service, same method, overlapping time.
  3. If the native report has not arrived yet, the fragment waits and is retried for a few minutes.

Better still, the agent captures the application's own OpenTelemetry spans in process — no exporter needed — by observing the tracer provider the app registers. Those spans land in the right request because they were created inside it, and a span id that is already stored is dropped, so you can export to Obscenter and to another backend without duplicates.

When no query is to blame: CPU profiles and the event loop

Some slow requests wait on nothing. No database, no downstream call — the time went into your own code. For those, the agent has a sampled CPU profiler that points at the function that spent the time. It is off until you switch it on. When it runs, it opens profiling windows only as often as their measured cost allows, within about one percent of the main thread, opens none while the process is idle, and pauses itself if starting one holds the thread too long.

Processor page: CPU use per machine over time, and the processes using it
Where the processor time goes, machine by machine and process by process — the way into the sampled frames

Two more views answer the questions a profile alone cannot. The event loop view separates a loop that is busy from one that is held by a single long task. The memory view reads the heap from inside the process, ranks heaps by growth, and when old space is climbing on a straight line says how fast and roughly how long is left — a leak looks different from a busy service there.

The map, drawn from real traffic

Topology map of Northwind Coffee from the browser through the services to the database
The map is drawn from recorded calls, not from a diagram somebody maintains

Every span is also an edge. The topology view draws browser to site, site to service, service to database and external dependency from the rows that were actually recorded, not from a diagram somebody maintains. A line on the map is a measurement you can click into.

Start tracing

Install the host agent and name the Node services you want traced, or point an OpenTelemetry exporter at your install. Either way, the first traces arrive with the first requests.

Start free or read Getting started.

More from the blog