# Instrumented seams (/docs/infrastructure/observability/seams)

---
title: Instrumented seams
description: 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.
kind: guide
package: questpie
---

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.

<Callout type="info" title="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.
</Callout>

## 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](/docs/infrastructure/observability), configuring the adapter and writing your own.
- [Trace a slow request](/docs/guides/debug-a-slow-request), reading a waterfall down to the slow statement.
- [Monitoring](/docs/ship/monitoring), health probes and what to alert on.
