QUESTPIE
InfrastructureObservability

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.

View markdown

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

SeamSpanKindAttributes
HTTP requestGET /posts/42serverhttp.request.method, url.path, http.route, http.response.status_code
Collection CRUDcollection.findOneinternaldb.collection.name, db.operation.name
Database querydb.selectclientdb.system, db.operation, db.statement, db.sql.table
Transactiondb.transactionclientdb.system
Job executionjob send-emailconsumermessaging.operation.name, messaging.destination.name, questpie.job.dispatch_id, questpie.job.idempotency_key
KVkv.getclientdb.operation.name, db.system.name, questpie.kv.key_length
Searchsearch.queryclientquestpie.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.

MetricUnitAttributes
http.server.request.durationshttp.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.

OmittedWhat you get insteadWhy
Search query textCollections, limit, result count, totalRaw user input, routinely a name or an email address.
KV keysquestpie.kv.key_lengthKeys embed record ids and occasionally session tokens.
SQL parametersThe statement textParameters are the user data the statement operates on.
CRUD inputs and rowsThe collection and the operationSame 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.

On this page