Instrumented seams
Every span QUESTPIE opens, its kind and attributes, the one metric it records, how log records join a trace, and the values that are deliberately never attached to any of it.
Seven seams call span(). That is the complete set, and it is a short list on
purpose: QUESTPIE composes the OpenTelemetry SDK by hand rather than
monkey-patching Node built-ins at load time, so a fetch you make yourself or a
third-party client you construct produces nothing until you wrap it.
The seams
| Seam | Span | Kind | Attributes |
|---|---|---|---|
| HTTP request | GET /posts/42 | server | http.request.method, url.path, http.route, http.response.status_code |
| Collection CRUD | collection.findOne | internal | db.collection.name, db.operation.name |
| Database query | db.select | client | db.system, db.operation, db.statement, db.sql.table |
| Transaction | db.transaction | client | db.system |
| Job execution | job send-email | consumer | messaging.operation.name, messaging.destination.name, questpie.job.dispatch_id, questpie.job.idempotency_key |
| KV | kv.get | client | db.operation.name, db.system.name, questpie.kv.key_length |
| Search | search.query | client | questpie.search.collections, questpie.search.limit, questpie.search.result_count, questpie.search.total |
Attributes that cannot be determined are omitted rather than guessed.
http.route is absent when no route matched, db.sql.table when the statement
does not parse cleanly, and the two questpie.job.* keys when the dispatch
carries no such value.
Any span opened inside a request also picks up questpie.request_id and
questpie.trace_id, because span() reads them off the ambient context. A job
span has no request behind it, so it carries neither.
HTTP requests
The span is named <method> <path> from the raw request path, so an id in the
URL lands in the span name. The matched pattern arrives separately on
http.route, once routing has run, and it is that attribute the metric below
groups on. The span also carries questpie.request_id and questpie.trace_id,
the framework's own correlation ids and the same pair returned on the response
as x-request-id and x-trace-id.
A 5xx marks the span failed even when nothing threw. Backends alert on span status, so a handler that returns a 500 rather than raising must not look healthy.
Collection operations
Every method on app.collections.* opens collection.<operation>, where the
operation is find, findOne, count, create, update, updateMany,
updateBatch, delete, deleteMany, purge, restore, lockMany,
findVersions, revertToVersion or transitionStage. Upload collections add
upload and uploadMany.
The observability service is read from the ambient context rather than injected,
because a CRUD call can start from HTTP, a job, a hook or a script and only the
ambient context knows which. That has a consequence worth knowing. A call made
with no ambient context at all, a bare script for instance, opens no
collection.* span. Its queries are still traced, because the database seam
sits on the client rather than on the context.
Database queries
Queries are traced on every db config variant, including { drizzle } and
{ create } where you build the client yourself. The instrumentation attaches
to Drizzle's session, not to the driver. The driver looks like the obvious seam
and is unavailable in exactly the configurations that matter most, Hyperdrive
and Neon and Vercel Postgres, which would have shipped a trace that silently
omits queries on serverless deployments. A waterfall with the database missing
reads as "the database was fast".
Statements inside db.transaction() are traced too, nested under the
transaction span, which is usually where the surprise lives on writes. The
exception is a driver whose transaction object refuses the swap, where the
transaction span still opens and the statements under it go untraced rather than
the transaction breaking. Relational reads through with: {...} go through a
second query path and are covered as well.
db.statement carries the SQL text. The parameters are not attached.
If you build a Drizzle client outside the framework and want it traced,
instrumentDbClient is exported from questpie/observability, though the db
service already applies it to every variant.
Jobs are trace roots
A job runs outside any request, so its span starts a new trace rather than hanging off whatever enqueued it. That is deliberate. Background work that fails would otherwise be invisible, because nothing upstream ever knew about it.
questpie.job.dispatch_id is stable across retries and across queue adapters,
so it is what correlates a failed attempt with the one that eventually
succeeded.
Requests continue an inbound trace
A request arriving with W3C trace headers joins the caller's trace, with the remote span as parent, so a distributed waterfall stays connected across services. Only the root span reads the headers. Everything below it nests through the active context.
Propagating outward is yours
Inbound continuation is automatic. Outbound is not. If you call another
service and want it to join, send the current traceparent yourself.
Metrics
One histogram, recorded on every request the app handles, errors included.
| Metric | Unit | Attributes |
|---|---|---|
http.server.request.duration | s | http.request.method, http.route, http.response.status_code |
That single instrument is the whole RED triple. Rate is its count, errors are
that count sliced by http.response.status_code, and duration is the histogram
itself. Separate request and error counters would duplicate the same series and
eventually disagree with it.
The name, attributes and the seconds unit follow OTel HTTP semantic conventions, so a stock dashboard works without remapping. The request log line beside it stays in milliseconds, which is for humans rather than for querying.
Logs
Pino's output is untouched. An app that ships logs by scraping stdout keeps working exactly as before. Two things are added on top.
Correlation
Every record written inside a span carries trace_id and span_id in snake
case, because those are the keys a backend joins logs to traces on. The
camelCase traceId stays beside them. It is the framework's own correlation id,
read from x-trace-id, then from the inbound traceparent, then falling back
to the request id, and it is not the same value once an adapter owns
propagation. Both are emitted because they answer different questions.
Export
With otlpEndpoint set, records are also emitted on the OTel logs signal, so
the collector receives them directly rather than only through whatever tails
stdout. The tee reads the adapter off the ambient app context, so a line written
outside any request, job or hook is logged normally but not exported. A
collector being unreachable never turns a log line into a thrown error.
What is deliberately not recorded
Tracing backends are searchable, long-lived and widely readable inside a company, so QUESTPIE omits some things you might expect to find.
| Omitted | What you get instead | Why |
|---|---|---|
| Search query text | Collections, limit, result count, total | Raw user input, routinely a name or an email address. |
| KV keys | questpie.kv.key_length | Keys embed record ids and occasionally session tokens. |
| SQL parameters | The statement text | Parameters are the user data the statement operates on. |
| CRUD inputs and rows | The collection and the operation | Same reason, one layer up. |
If an investigation needs one of these, add it on a span of your own, where the decision is explicit and scoped to the thing you are looking at.
Not instrumented
Email sending, storage adapter calls, realtime delivery and sandboxed execution
open no spans of their own. Uploads are a partial exception: collection.upload
covers the CRUD operation, so the write is visible even though the bytes going
to storage are not timed separately.
Two smaller gaps sit inside seams that are otherwise covered. kv.clear() opens
no span, while get, set, delete and has do. On search only search() is
traced, so writes through index, indexBatch, remove, reindex and clear
are invisible.
Related
- Observability, configuring the adapter and writing your own.
- Trace a slow request, reading a waterfall down to the slow statement.
- Monitoring, health probes and what to alert on.
Brokered app access
How a sandboxed guest reaches your collections and files without importing your app, which route carries the calls, why the guest loses its sockets when you turn this on, and the operations the broker still refuses.
Deploying
A QUESTPIE deploy is a PostgreSQL database, one process serving HTTP, a migration step in front of the rollout, and a worker if you wrote jobs. These pages cover each piece, and the parts you own yourself.