npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@downtrace/agent

v0.18.1

Published

Catch N+1 queries and new calls before you deploy: downtrace check compares your test runs route by route, with no account. In production, a flight recorder for your Node.js backend, never in your request path.

Readme

@downtrace/agent

⚠️ Not production ready. Downtrace is a closed free pilot: no plans, no billing and no SLA. The ingestion protocol is v0 and can still change between minor versions. Install it where a dependency that is still moving is acceptable.

A flight recorder for your Node.js backend. Downtrace watches how your application normally behaves and, when something gets slower or breaks, tells you what changed. This is the instrumentation: it observes your application from the inside, aggregates locally per route and 10-second interval, and ships compact batches to the Downtrace cloud — never in your request path, never able to throw into your code, with bounded memory.

It observes six things, each of which can be switched off with DOWNTRACE_INSTRUMENT:

| Name | What it sees | |---|---| | http | Incoming requests by route, and outgoing calls by host, with status and duration | | pg | Postgres queries per request, their time and errors, and how long a request waited for a connection; Prisma 7's too, through pg; and postgres.js's, with no wait and no server | | mysql | MySQL and MariaDB queries per request through mysql2, mysql and mariadb, their time and errors, and how long a request waited for a connection | | mssql | SQL Server statements per request through tedious, and so through mssql, TypeORM, Knex, Sequelize and Prisma's adapter, by server, their time and errors | | sqlite | SQLite statements per request through better-sqlite3, libsql (and so @libsql/client on a file, and the ORMs that run on either) or sqlite3 (Knex's and Sequelize's), their time and errors | | mongodb | MongoDB commands per request through its official driver, and so through Mongoose, by command and collection, their time and errors | | prisma | Prisma's operations per request from 3.15 to 6, through its own engine, by action and model (findMany Post), under the database they reach, their time and errors | | redis | Redis commands per request, by server, through ioredis or redis (node-redis 5.12 and later) | | runtime | Event loop delay, GC pauses, heap, RSS and requests in flight |

It watches only the process it is loaded into: one process, one service.

Before you deploy, with no account

Your tests pass, and a change made a route run a query once per row. downtrace check runs your tests on HEAD and on your working tree, and tells you, route by route, what each request now runs that it did not:

npm install --save-dev @downtrace/agent
npx downtrace init     # finds your test command, writes downtrace.json; asks nothing
npx downtrace check    # exit 0: no route got worse · 1: one did · 2: no comparison could be made
Worse (1)
  GET /orders   3 requests in the base, 3 in the change
    appeared   SELECT name FROM users WHERE id = ?  [query 0ee1294e0ade7fb8]  0 -> 50 per request
    appeared   SELECT id, total, user_id FROM orders ORDER BY id  [query fb36696aebd98a68]  0 -> 1 per request
    gone       SELECT o.id, o.total, u.name FROM orders o JOIN users u ON u.id = o.user_id ORDER BY o.id  [query 0ad9139c76ae524a]  1 -> 0 per request
    request duration, shown and not judged: 1 ms -> 14.82 ms

Nothing leaves the machine: the runs get no token and no URL. Durations are shown and never judged, since a test run is not production; the verdict comes from what each request ran — queries, outgoing calls, Redis commands — and how many times. A route the tests did not reach is said to be not evaluated, by name, and never unchanged. A coding agent reads the same result with --json. The whole of it is in Compare two runs before you deploy; production, with the cloud, starts below.

The package also installs a command, downtrace. downtrace init configures a project for it, asking nothing and making no network request: see Configure a project. downtrace check compares two runs of your tests, before you deploy, with no account and with nothing leaving your machine: see Compare two runs before you deploy.

On the word «agent». Two things in Downtrace could be called that, and this README never uses it alone: the instrumentation is this library, and a coding agent is whoever queries and operates Downtrace — a first-class user of the product, not a part of it. The npm name @downtrace/agent keeps the older sense on purpose: renaming a published package costs its users more than the ambiguity costs them, and the ambiguity is bounded by saying which is which everywhere else.

Install

npm install @downtrace/agent
DOWNTRACE_TOKEN=dt_… DOWNTRACE_URL=https://your-downtrace-cloud \
  node --import @downtrace/agent/register server.js
# or, without touching the start command:
NODE_OPTIONS="--import @downtrace/agent/register" node server.js

| Variable | Required | What it is | |---|---|---| | DOWNTRACE_TOKEN | yes | The ingest token for this project and environment | | DOWNTRACE_URL | yes | Base URL of the cloud, http:// or https://; trailing slashes are ignored | | DOWNTRACE_ENV | no | Environment; falls back to NODE_ENV, then production. See the note below: a token that belongs to an environment is what decides where the data lands | | DOWNTRACE_VERSION | no | Deployed version or commit; detected from APP_VERSION, GIT_SHA, VERCEL_GIT_COMMIT_SHA, HEROKU_SLUG_COMMIT, SOURCE_VERSION, RENDER_GIT_COMMIT, RAILWAY_GIT_COMMIT_SHA; else unknown | | DOWNTRACE_DEBUG | no | 1 or true to log the instrumentation's own activity to stderr | | DOWNTRACE_INTERVAL_MS | no | Aggregation interval in ms (min 1000; default 10000; anything else falls back to the default) | | DOWNTRACE_PROFILE_MS | no | How long a profile window stays open, in ms (default 60000; never below DOWNTRACE_INTERVAL_MS, and never above two and a half minutes minus the interval — the floor and the ceiling are derived, see below). Shortening it multiplies the profile rows in proportion, and those count against the project's daily budget | | DOWNTRACE_INSTRUMENT | no | Which observers run: all (default), none, or a list like pg,mysql,sqlite,mongodb,prisma,http,redis,runtime | | DOWNTRACE_SHED | no | nothing (default), fine or profile: the least the instrumentation gives up, whatever its own meter measures. The benchmark's switch for weighing the fine detail and the profile on their own (ADR 0080, gh-570); leave it alone in production | | DOWNTRACE_PG_DEPTH | no | full (default), context or wrapper: how much of the Postgres observer's attribution runs — context attributes the calls and the waits without looking at the query text, wrapper only leaves the patch in place. The benchmark's switch for weighing the observer part by part (gh-592); leave it alone in production | | DOWNTRACE_QUERY_TEXT | no | off to send query fingerprints without their normalised text. The hash is the identity, so the analysis is unchanged | | DOWNTRACE_INSPECT | no | stderr or a file path: writes every batch exactly as it would be sent. With it set, DOWNTRACE_TOKEN and DOWNTRACE_URL become optional |

An ingest token belongs to one environment, and that is the environment everything sent with it is stored as, whatever DOWNTRACE_ENV says. If the two disagree, the data still lands — going blind over a mislabelled deploy would be worse than the wrong label — and the project's page shows the instance as declaring something else, which is usually a variable to fix. Tokens minted before per-environment tokens existed are unbound and keep letting the batch declare.

Without DOWNTRACE_TOKEN and DOWNTRACE_URL (or with a DOWNTRACE_URL that is not http(s)://) the instrumentation prints one warning and does nothing else: it loads none of your dependencies and changes none of them — importing the package, or loading it without the token, does not even load Express. So you can add it to a deployment before you have a token: nothing changes until both exist.

If your build prunes dependencies it cannot see

--import loads the instrumentation from the command line, so nothing in your code imports it. Bundlers that decide what to ship by tracing imports — Next.js output: "standalone", and anything else that copies "only what is used" — will leave it out. Your dependency is in package.json, it is in node_modules on your machine, and the container dies at boot:

Error: Cannot find package '@downtrace/agent'

Next.js

Use instrumentation.ts, which Next runs once before it serves anything. A few lines, no change to your Dockerfile, no NODE_OPTIONS, no change to your start command — and downtrace init writes them for you when it finds output: "standalone":

// instrumentation.ts, at the root of your project (or in src/)
export async function register() {
  if (process.env.NEXT_RUNTIME === "nodejs") {
    await import("@downtrace/agent/register");
  }
}

Because your code imports it, Next traces it and ships it. Verified on Next 15 with output: "standalone": the instrumentation starts, finds your pg and reports queries per route, and names each route by the file that serves it (/api/products/[id]; see Requirements).

The condition is not optional. Next calls register in every runtime it builds for, and a project with a middleware.ts has the edge runtime too, which cannot load Node's own modules: without the condition, next build fails with Reading from "node:module" is not handled by plugins. Checked on Next 15.5 with output: "standalone", with a middleware and without one: the hook above builds in both and the built server loads the instrumentation; the same hook without the condition builds only without the middleware.

Your tsconfig.json needs "moduleResolution" set to bundler, node16 or nodenext — the classic node setting cannot resolve the /register subpath. Next puts one of those in new projects; older ones may still be on node.

Anything else that traces

Either import the instrumentation from an entry point you own, as above, or make the bundler keep it. In Next that is outputFileTracingIncludes; other tools have an equivalent.

Sending no free text at all

DOWNTRACE_MINIMAL=1 withholds everything you wrote. Route templates, dependency targets, your hostname and your deployed version travel as stable digests of themselves — same name, same digest, every time, so the analysis still groups — and query text, the labels of outgoing calls and Redis commands, error messages and exception signatures do not travel at all. A call's or a command's identity is then a digest of its withheld name.

The digest is of what the route is called. A request that Express or a Koa router answered and no route named is (unmatched), and nothing of its path enters the digest. For a request that no framework with routes saw, it is the collapsed route, so a value the heuristic folds — an email, a token, a file name with a number — never enters the digest. The limit is a plain word in a path of that kind, a name or a slug: the heuristic cannot tell it from the route's own words, so it enters the digest, and the digest is keyless, which means whoever guesses the word can confirm it. An endpoint exclusion keeps it out entirely, because excluding is not observing.

What stays is what is not yours: the protocol version, the agent's own name, the HTTP method, the kind of each dependency, the counts and the timings. Downtrace keeps detecting and comparing; what it loses is the ability to name anything, and it says so in every report rather than showing you a bare hash.

The environment is not withheld: your ingest token already tells the cloud which one it is, so hiding it here would protect nothing and would collapse the per-environment scope everything is organised by.

DOWNTRACE_QUERY_TEXT=off still exists and still does the smaller thing: send the routes, not the queries. It is about queries, so it does not touch the context of an error you report.

Errors your application handled, and the ones your framework turned into a 5xx

Installing without touching your code stays the default, and it covers most of it: a query that fails, an outgoing call that fails — a connection that is refused, a socket that resets, a timeout — and a Redis command the driver settles as failed. Each is an error with its identity, beside the dependency's failed call: the type, the sanitised message and the stack signature, as for a query. An exception that kills the process is watched too.

A 5xx the dependency answers is not an error for it: nobody threw anything, and an identity is not invented for it — it stays a failed call of that dependency, which is what it is. The kinds of error no hook can cover, because from the outside nothing went wrong, are what these two calls are for.

An error you handled. A catch that retries, falls back or writes a warning and carries on, and an error you deliberately turn into a 4xx or a 2xx. No hook can see either: the request succeeded.

import { captureException } from "@downtrace/agent";

try {
  await provider.authorize(order);
} catch (err) {
  captureException(err, { stage: "authorize", retryable: true });
  return fallback(order);
}

The shape is the one your error tracker uses, so replacing the import is the migration. The one difference is the return: a tracker gives you an event id and this gives you nothing, because the instrumentation has no identifier to hand out — an error's identifier is the cloud's digest of its signature.

It is attributed to the request being served when there is one, and to the process when there is not: a background job, a worker, start-up. It never throws, whatever you pass it, and with the instrumentation not loaded it does nothing at all, so it is safe to leave in code that also runs without Downtrace.

The context is structural, small and sanitised. A flat object: at most 8 keys of at most 32 characters, each value at most 64 characters after sanitising. A string is sanitised exactly as an error message is — including the rule that omits it when fewer than half its words survive, and then what travels is a ? — a boolean travels as it is, and a number comes out as ?: an order id, a user id and a price are all numbers and there is no telling them apart, so the key survives and the value does not. Nested objects, arrays, null and functions are dropped. A key has to be a name and survive the same sanitiser unchanged, so stage and willRetry travel and order_12345 or sk_live_… are dropped whole rather than sent half-replaced. A tracker's { extra, tags, user } hint is not read: pass the flat fields you want.

What that guarantees, and what is yours. The sanitising replaces what looks like a value, in any script:

  • anything with a digit in it (4821, 4821);
  • an email (ana@müller.de);
  • a long run of letters, digits, _ and -;
  • whatever is between quotes, closed or not: '…', "…", “…”, „…“, ‘…’, ‚…‘, «…», »…«, 「…」, 『…』 and backticks. An apostrophe is not a quote: the ' of can't or user's opens nothing, and a quote closes only where a word ends, so Can't find user 'alice smith' here becomes Can't find user ? here. Any other ' inside a word still opens one (O'Brien becomes O?), because a quote glued to a word looks the same;
  • everything in a URL after its host. The scheme and a host with its port stay, because that is what the name of a dependency already carries: https://api.example.com/users/alice?name=alice becomes https://api.example.com/?. A URL whose authority holds anything else, a user and a password for one, keeps only its scheme: postgres://?. The host ends where Node's URL parser ends it, so a \ ends it as a / does (https://api.example.com\users\alice becomes https://api.example.com\?), and a scheme such as https: with no slashes after it is read as a URL all the same (https:api.example.com?name=alice becomes https:api.example.com?);
  • any other word with a / or a \ in it, whole: a path with no scheme and its query (/users/alice?name=alice), a file path on either system (/home/alice/orders.csv, C:\Users\alice\orders.csv), a relative one, and a host followed by a path with no scheme in front (api.example.com/users/alice). Nothing of it stays, not even its first segment: your route templates travel as routes, and are not read by these rules. A slash on its own stays; a word that only joins two words with a slash goes too (i/o, application/xml);
  • any other word with a query in it, whole: a word in which a ? has a letter, a digit or an _ after it. That is a host with no scheme and its query (api.example.com?name=alice), a query on its own (?name=alice), and the query of a scheme such as sms: or mailto: with no // after it (sms:ops?body=alice). A ? at the end of a word, on its own, or with nothing but punctuation after it stays (unexpected token?);
  • any other word with a fragment in it, whole: a word in which a # has something before it and a letter, a digit or an _ after it. That is a host with no scheme and its fragment (api.example.com#alice), and the fragment of a scheme such as sms: with no // after it (sms:ops#alice). A # at the start of a word — a private field, a ticket, a channel — stays, and so does a # with nothing after it, a language's name (C#).

It cannot recognise a plain word, so { customer: "alice" } travels whole. A path or a query with a space in it ends at the space unless it is between quotes, so the words after the space travel. Nor, yet, does it recognise a span between ‹ › or fullwidth quotes. The guarantee is «no identifier, no address, no token», not «nothing about a person» — so do not put a name, an email or anything else that identifies somebody in a context. Downtrace does not measure users (IMP-01), and this call cannot enforce that on prose you wrote.

The first version that recognises all of these changes some error signatures, once. Earlier versions let through a URL's path, whether it followed the host with a / or a \, the query of a URL with no slashes after its scheme, a path with no scheme and a file path, a query with no URL around it, a fragment with no URL around it, a quoted value after an apostrophe, quotes other than ' and ", backticks, a ’ inside a ‘…’ span, and digits and addresses outside ASCII. An error whose message carried one of them is sanitised differently now, and the hash of that text is the error's identity, so after the upgrade Downtrace lists it as a new error and the old one stops being seen. Most of these were one error per value anyway — user “alice” not found and user “bob” not found were two — and now they group. A message made only of ASCII keeps its signature exactly unless it carries a URL, a backtick, a word with a / or a \ in it, an apostrophe inside a word, a closing quote with a space before it, a word in which a ? or a closing quote has a letter, a digit or an _ after it, or a word in which a # has something before it and a word character after it, and a message with no ’ inside a ‘…’ span does too.

Only the first context for a signature in each window is sent, because what travels is counted per signature and not per occurrence.

An exception your framework turned into a 5xx. Express hands a thrown or rejected handler to next(err) and, if nobody answers, to finalhandler; nothing on that path publishes anything a library outside Express can listen to, so without this line a 500 arrives as a status class with no type, message or stack. One line, after your routes and before your own error handler, because yours is likely to answer rather than call next:

import { expressErrorHandler } from "@downtrace/agent";

app.use(expressErrorHandler());
app.use((err, req, res, next) => { /* … yours … */ });

It records the error and calls next(err) with the same error, always, so your response is the one you would have given anyway. It costs nothing per request: it runs only when an error is already travelling. An error that declares itself a client's fault (status or statusCode between 400 and 499) is passed on and not recorded; if you want one of those recorded, captureException is the call.

Express 4 and 5. Fastify, Koa, Nest and Hono do not have one yet.

Both calls obey DOWNTRACE_MINIMAL=1: the message and the whole context stay on your server, and the error still groups by its hash. That switch, and not the exclusions, is the one that withholds text.

And the exclusions. An endpoint you excluded with DOWNTRACE_EXCLUDE_ENDPOINTS is not observed at all, and that covers these two calls too — as long as some observer is on, which is the default. The exception is DOWNTRACE_INSTRUMENT=none: told to observe nothing, the instrumentation opens no context per request, so a report made inside one has no route to be attributed to. It then travels as something the process saw, with no route, and an endpoint exclusion has no endpoint to match it against. Nothing about the excluded route travels — not its template, not its counts — but the error's own text does. If that matters to you, either leave an observer on, or use DOWNTRACE_MINIMAL=1, which is the switch for text.

Exceptions that kill the process

An uncaught exception and a promise rejected with no catch are watched through uncaughtExceptionMonitor, which Node calls before any real handler and which does not count as handling the exception. Your process ends exactly as it would have: same exit code, same stack trace. There is a test that runs two processes, one with the instrumentation and one without, and compares both.

What is reported is the type, the sanitised message and the stack signature, counted per signature. Each signature also travels with its running total since the process started, so a batch sent again because its answer was lost is not counted twice, and an exception reported while another batch is in flight rides the next one. The instrumentation keeps totals for up to 256 signatures over the life of the process; past that, a signature is sent with its count alone and the cloud says its number may include a resend.

A process that throws more distinct signatures than the instrumentation can hold is the case it has to survive: its window holds 32 distinct signatures, and so does the sender's accumulation while batches do not land. Past either cap a new signature is not admitted, and what does not fit is counted, not swallowed: the batch says in droppedExceptions how many exception events did not fit, and the cloud adds it up beside the other losses. A process that threw 40 distinct exceptions is never read as one that threw 32.

If the process dies, the exception is lost. Sending is asynchronous, an uncaught exception does not go through beforeExit, and there is no synchronous channel to send it on. It arrives when your application survives what it threw — because it has its own uncaughtException handler, or because the rejection did not kill it — which is the common case for the ones you can still do something about.

That is decided, not pending: the instrumentation does not write the exception to disk to deliver it at the next start. A container's disk is usually ephemeral and the next start may be another instance, so a spool would only help a machine with a persistent disk; and writing on the way down is synchronous I/O, with a bound of its own to keep, at the one moment the instrumentation must not change how your process ends. What it does instead is say the loss, and the cloud shows it per process (below). The one write on the way down that does exist is for someone who is only inspecting: it goes to a file they named, and it never carries this exception (below).

What each way of ending keeps, and what it loses

Three ways a process ends — an orderly shutdown, an explicit exit, an exception that kills it — and they are not the same for your telemetry. Every claim below names the test in test/ that asserts it; the ones about how a process ends use real processes, because a process that does not end cannot prove that from the inside, and the one claim no test can reach says so in those words.

| How it ends | What reaches Downtrace | What is lost | | --- | --- | --- | | SIGTERM or SIGINT, with the instrumentation's own handler | everything it was holding: the interval in hand and the exceptions it had recorded, and the process still dies of that signal (endings.test.ts) | nothing it was holding | | The event loop empties: nothing is left to do | the same, by the same path: the interval in hand (endings.test.ts) | nothing it was holding | | process.exit(), after await shutdown() | the same, by the same path: the last batch (shutdown.test.ts) | nothing it was holding | | process.exit(), without waiting | nothing of the last flush — the call waits for no promise and fires no beforeExit (shutdown.test.ts) | the interval in hand, the profile's window, any capture evidence | | process.exit(), without waiting, inspecting with no cloud (below) | everything it was holding, written on exit with ending: "exit" (exit.test.ts) | nothing it was holding | | An exception nobody caught | what had already been sent: the earlier intervals with their runtime signals, and the exceptions that rode an earlier batch (endings.test.ts) | the exception that killed it and the interval in hand (endings.test.ts); and with them the profile's window and any capture evidence — stated by reasoning and not by a test, because nothing runs after the process is gone and what delivers those two is the flush that never happens |

The three orderly ways out — the signal, the emptied loop and shutdown() — are one path, and on it the instrumentation also closes the profile's window whatever the clock says (profile.test.ts, agent.integration.test.ts) and hands over the evidence of any capture still under way, partial rather than silent (agent.integration.test.ts, captures.test.ts).

Two things hold in every row. The black box — the fine detail, the coarse summary and the reference samples — never leaves your process except inside a capture, so it always goes with the process; that is what it is for. And a send that fails on the very last flush is the one loss the instrumentation cannot report to you, because what it lost travels in the next batch and there is no next batch.

Inspecting with no cloud keeps what process.exit() would cut. When DOWNTRACE_INSPECT names a file or stderr and there is neither a token nor a URL, that destination is the whole of where the batches go, and the instrumentation writes what it holds on the exit event: the last thing a process runs, with no promise and no timer after it. The write is blocking, once, and the batch says ending: "exit". It is the only blocking write the instrumentation makes, and it is made there and nowhere else: not in a request, and not in a process that has nothing to keep. It is why a short run that ends with an explicit exit keeps its profile (A test run, below), and it has two limits, both asserted (exit.test.ts). With a cloud behind the file, nothing is written on the way out: what you read there is what went out, and a batch written and never sent would make it say what did not. And an exception nobody caught that is ending the process writes nothing, in this mode as in every other: the exception that kills the process is lost, and the interval in hand with it.

And the last batch of the orderly endings says how the process was leaving — ending: "signal" for the signal, ending: "exit" for an application that awaited shutdown() — or, inspecting with no cloud, one that called process.exit() without waiting —, ending: "idle" for one that ran out of work — and the cloud shows that per process, beside the instant the batch arrived. One that stopped without saying so is shown as having stopped, with the sentence that if it died, what it had not sent is lost — and that none of that is evidence that nothing went wrong.

Beside your error tracker

You probably already have one installed, and you are not going to remove it the day you install this. Both can run in the same process. Verified against @sentry/node 10.75.0, with an ESM application on Express 5, pg and ioredis, loading each one the way its own documentation asks for:

node --import @downtrace/agent/register --import ./instrument.mjs server.js

Either order is exact. Both instrumentations end up wrapping the same pg prototype, and this one never loads the driver itself: it resolves it from your application's root (with no loader hooks, which is what lets it work without you importing anything) and patches the copy your application loaded once the tracker's hooks are done with it: what your application loads at start-up, at the start of the first request; what it loads later, in a request, in the microtask after that load, which Node announces from 22. So whichever order the two are loaded in, your application's own load of pg is the only one the tracker's hooks ever see, and each one observes what it would alone: same requests, calls per request and errors on this side, and the tracker's own pg spans on its side — one pg-pool.connect and one query span per request, with no duplicates. A test runs the three configurations — each one alone, and both in each order — against one another, and another one loads pg both ways, with an import and a require, before the first request and after it.

A driver your application loads lazily is counted from the request that loads it — an import("pg") inside a handler, or a route Next.js loads the first time it is asked for. What is still not counted, said here rather than found:

  • a query made in the same turn as the require that loads the driver, such as require("better-sqlite3") and a statement right after it;
  • on Node 20, which does not announce the load, the queries of that first request, which are counted from the next one;
  • a driver imported as an ES module — postgres.js's ES build, mariadb from 3.5 —, which is reached from the first request;
  • with a tracker beside it, a request that loads pg both ways — a require and an import of it in that same request — gets two of the tracker's spans for each query from then on.

A CommonJS application is verified too, on Express 5 and pg, with the tracker required on the first line of the entry or preloaded, as its documentation asks a CommonJS application to load it:

node --import @downtrace/agent/register --require ./instrument.js server.js

There the tracker sees pg only through its hook on require, which patches the driver as the require that loads it returns, and this instrumentation patches it after that, so its pg spans come out as they do alone. Node runs a --require before any --import, so that command loads the tracker first; required from the entry, it loads second, and the test runs both.

Everything else composes cleanly, in both orders, and there is a test that compares exact counts — requests, calls per request, errors — against each one running alone:

  • What this instrumentation reports does not change: same requests per route, same queries, Redis operations and outgoing calls per request, same errors, whether the tracker is there or not.
  • Its own cost does not change either: no internal errors, and the meter never has to give anything up. The hook time it reports is sampled inside its own hooks, so your tracker's work is never counted as ours.
  • Your tracker's ingestion is not reported as one of your dependencies. @sentry/node sends an error from inside the request that reported it, so its send would otherwise be a call and a dependency of that route, coming and going with whether it answers before the request ends. This instrumentation recognises that send by the request its transport makes — a POST to …/api/<project>/envelope/?sentry_version=…&sentry_key=… with the node:http client — and does not observe it. Nothing wider: a tunnel you configure is a URL of yours, and a call your application makes to it is counted like any other.

Two captureExceptions while the migration lasts

captureException here takes the tracker's shape on purpose, so finishing the migration is deleting an import. While both are installed, call both — same error, same context:

import { captureException } from "@downtrace/agent";
import * as Sentry from "@sentry/node";

try {
  await provider.authorize(order);
} catch (err) {
  const context = { stage: "authorize", retryable: true };
  captureException(err, context);
  Sentry.captureException(err, { extra: context });
  return fallback(order);
}

Same for the framework's 5xx: register both error handlers, in either order. Both record and then call next(err), so both see it and neither answers — your own handler still decides the response.

And when the process dies

A tracker's uncaught-exception integration usually handles the exception and then ends the process itself. This one never does that (see above), so with both installed how your process ends is your tracker's business: its exit code, its rendering of the crash. What is checked is that adding this instrumentation changes neither, on either side of the tracker.

If your application calls process.exit()

process.exit() is immediate. It does not wait for a promise in flight and it does not fire beforeExit, so an application that calls it the moment its servers close cuts off whatever the instrumentation was about to send: the interval in hand, the profile of the window, the evidence of a capture. Two lines fix it:

import { shutdown } from "@downtrace/agent";

process.once("SIGTERM", async () => {
  await server.close();
  await shutdown();
  process.exit(0);
});

shutdown() is safe to call when the instrumentation is off, safe to call twice, and never throws — an application on its way out has nothing to do with an error from its telemetry.

It waits for the cloud at most one second in total: a flush already under way when it is called —the regular one, or the one a signal started— and then the last batch and the evidence of any capture under way share that second, one after another. What has not landed when it passes is dropped — the last batch, if the cloud never answered it, and the evidence of the captures still waiting, which then expire in the cloud unless another instance of your service delivers them. With DOWNTRACE_DEBUG=1 the log says which. A signal the instrumentation handles itself, and the flush when the event loop empties, keep the same second.

You do not need it if you let the process end on its own: closing your servers and letting the event loop empty is what runs beforeExit, and the instrumentation flushes there. Nor do you need it when you only inspect: with no token and no URL the instrumentation writes what it holds when the process exits (see What each way of ending keeps, above, and A test run, below).

What leaves your server

Only structural metadata: method, route template (/products/:id, never the actual URL), status, counts and a fixed-bucket latency histogram per route and interval, plus the process identity (random id, hostname, pid) and the deploy (version, environment). No bodies, no headers, no query strings. The exact contract is the JSON Schema in @downtrace/protocol.

That is what goes out every ten seconds. Three other things can leave, and they are listed here rather than left for somebody to find in a schema: the detail of a capture, reference samples and the instrumentation's own resources. Each has its own section below. None of them carries a value from your database, a body, a header or a URL — the same rule as the aggregates, and DOWNTRACE_INSPECT shows you all of it.

The health of your process

Every interval the instrumentation also reports how late Node's event loop ran (median, p99 and worst), how much time went to garbage collection, heap and resident memory, and the peak number of requests in flight. In Node many slowdowns are neither the database nor the network but the process itself, and a diagnosis that does not measure it will blame whatever it does measure. It all comes from Node's own instruments, which run whether anyone looks at them or not.

Database work per request

When your application uses pg, the instrumentation also counts the calls each request makes to it, how long they took in total, the slowest one and how many failed, and reports that distribution per route. It also times how long each request waited for a connection from the pool. That wait is not the database being slow, it is your application having nowhere to run, and it is invisible in the query's own duration. A wait that ends without a connection — the pool timed out, or the database refused — is counted too, on the same database as the connections that pool handed over, or on the host and port of its connection string when it has handed none: a pool run dry is one dependency saturated, not a nameless one. The connection string gives only its host and port, never its user, password or database. It is what turns "this endpoint got slower" into "this endpoint went from 12 queries per request to 65". It never reads the query text or its values, only counts and durations.

A query counts for the request whose code ran it, and for no other. One that runs outside any request — a migration at start-up, a scheduled job, a health check's poll, work a handler left for later — is in no route's numbers, because no route ran it. A request answered before it reached the database — a gate that says «starting up», a rate limiter's 429, a 401 for a missing session — carries no Postgres at all, because it made no call. So a route with Postgres in one interval and none in the next has not lost its queries: its requests ran none inside the request, which does not mean the database was idle. Checked against n8n with every statement logged by Postgres: each request carried the queries the log shows for it, and the ones n8n answered without its database carried none.

postgres.js

When your application uses postgres.js (postgres, the driver of Drizzle's drizzle-orm/postgres-js and of most Supabase backends), its queries are observed under the same pg switch, as dependency postgres: per request, how many, how long and how many failed, and each one an operation of the route by its text with ? for every value — postgres.js sends the values as parameters, and the text it reads is the one the driver built. A query outside a request is in no route's numbers, as with pg. Run against a real Postgres, with the driver imported as ESM and required as CommonJS, on postgres.js 3.4.9. What it does not say, and why:

  • Which server. A postgres.js query does not know its connection, so every query is the dependency postgres with no host: a read replica and a primary reached through postgres.js are one dependency, not two, and a finding names no server.
  • The wait for a connection. postgres.js queues a query that finds its connections busy inside the driver, and the wait is part of the query's time. A pool run dry shows as slower queries, not as a wait.
  • The first request, imported as ESM. The ESM copy of the driver never enters the module cache the instrumentation reads, so it imports the driver's query module itself, the same file your import "postgres" loads, from the first request your server answers, on the next turn of the event loop. Queries that start before that import finishes, which in practice means the first request's, are not counted. Required as CommonJS it is patched before the first request's handler runs, as pg is. Under downtrace check the import starts at start-up, so a test file's first request counts.
  • A cursor read with for await is not counted; one with a callback, and forEach, are.
  • Beside an error tracker it was not verified. The instrumentation wraps Query.prototype.handle, where Sentry's own postgres.js integration also hooks, and it loads the query module only from the first request, after a tracker loaded with --import has registered its hooks.

MySQL

When your application uses mysql2, the instrumentation does the same for MySQL, as a dependency of its own (mysql): the queries each request makes, how long they took, how many failed, and how long the request waited for a connection from a mysql2 pool, apart from the query that followed it. TypeORM, Sequelize and knex — which is what Strapi uses — reach MySQL through mysql2, so they are observed through it; nothing is asked of them. So does MikroORM, whose @mikro-orm/mysql brings a mysql2 of its own: that one is observed too, from where the package is. So is an application that imports mysql2/promise alone, as Drizzle's guide writes it: from 3.24 that entry requires the driver's classes and not mysql2's own, and the instrumentation patches them from the connection's module, which both entries load. The target is the host and the port of the server (the socket path, for a socket), never the user, the password or the database. Prepared statements (execute) count like queries.

mysql (mysqljs), the driver mysql2 was written to replace, is observed the same way, because its API is the one mysql2 copied: its connection's query, its pool's getConnection and the wait on it, the text as the application wrote it before the driver put the values into it. TypeORM loads mysql first when it is installed — its documentation has long said to install it — and Knex's mysql client uses it. It loads each of its classes the first time it is used, so the instrumentation requires its connection and its pool from its lib at the first request, the modules your application's mysql loads. Run against 2.18.1, under Jest too.

The query text is read as MySQL's, not as Postgres'. In MySQL, without ANSI_QUOTES, a double quote opens a string, where in Postgres it opens a name: WHERE name = "ana" has a value in it, and it is replaced like any other (WHERE name = ?). A backtick opens a name, which stays: SELECT `name` FROM `users` travels as it is. The rest of MySQL's lexical rules are the server's: a backslash escapes in a string, # and -- open a comment, /* */ does not nest, and a $ is a letter of a name. What the scanner cannot read with confidence is omitted and not guessed: a -- not followed by a blank, a comment opened with /*! (the server runs what is inside), a block comment that opens another, a delimiter that never closes. Such a query travels as its hash and its class (select, insert, …) and no text. A server in ANSI_QUOTES mode gets a poorer label — its double-quoted names read as values — and nothing leaves.

What the instrumentation does with the driver, and what it gives up:

  • A query belongs to the request whose code ran it. mysql2 finishes a query on the connection's socket, in the async context of whichever request opened that connection, and its callback API is built on callbacks: the second query of a chain is asked from inside the first one's. So the context is taken when the application asks, the result is written into it, and the application's callback runs with it. That changes the one thing this instrumentation keeps in async storage and nothing of the application's own.
  • It wraps the methods that define query, execute and the pool's getConnection — on the class mysql2 defines them on, so a pool's connections are covered whichever way the version lays its classes out (up to 3.18 a pooled connection is not a Connection). Run against a MySQL 8.4 server, with every way of asking for a query, on 2.3.3, 3.0.0, 3.2.0, 3.6.5, 3.9.8, 3.10.3, 3.11.5, 3.14.5, 3.19.1, 3.20.0 and 3.24.5. Arguments, results and errors pass through untouched, and if the wrapper itself fails the query still runs. It never asks a query for a result: mysql2's commands throw when they are awaited.
  • The wait is the wait of mysql2's own pool. knex and Sequelize keep a pool of their own over plain connections, and a request that queues in it is not seen waiting. The wait of a MySQL pool travels with the dependency's aggregate, which is what a pool-saturation finding reads; it is not part of the wait that goes with a capture's requests, which is Postgres' (a finding over a MySQL pool gets no estimate of its impact, and says so).
  • A query run with listeners or as a stream (connection.query(sql).on("result", …)) has no callback to settle: it is counted when it is issued, with no time and no failure, as a pg cursor is. Prepared statements made with connection.prepare() and run through the statement are not observed.
  • Beside an error tracker this was not verified for mysql2, as it was for pg and ioredis (below): the mechanism is the same — it resolves the driver and loads nothing — but nobody has run the pair.

Through mariadb — the connector MariaDB publishes, which Prisma's adapter for MySQL and MariaDB (@prisma/adapter-mariadb, the one Prisma 7 needs for them) and Sequelize's mariadb dialect query through — it is the same: the same dependency, mysql, with the same target, the text read as MySQL's, the wait on its pool, and a callback that runs in the request that asked. The copy @prisma/adapter-mariadb brings is observed from where the adapter is. Every way the driver runs a statement goes through its connection's query, prepareExecute and batch, and the callback API's own query; those are what it wraps, from 3.0.1. Run against MySQL 8.4 and MariaDB 11.4, with prepared statements, batches, errors and transactions, on 3.4.7 and 3.5.4, and its queries on 3.0.2, 3.1.2 and 3.3.2.

  • Up to 3.4 the driver is CommonJS, and from 3.5 its modules are ES modules. 3.5's are imported by their URL, the modules your application's import loads, from the first request — from the start under downtrace check —, as postgres.js's are: a query that starts before that import resolves is not counted.
  • What require("mariadb") loads from 3.5.3 on is one bundle, dist/promise.cjs, which exports none of the driver's classes, and is not observed; DOWNTRACE_DEBUG=1 says so at the first request. An application compiled to CommonJS — NestJS's, for one — with Prisma 7.10's adapter, which pins 3.5.4, has its queries go there, and they are observed one floor up, at the adapter: as it loads, which Node announces on its module.require channel (on Node 22 and later; on Node 20, only an adapter loaded before the instrumentation started), and under Jest in its registry. Every query Prisma sends is a query of the route, with its time and its failure; the dependency is mysql with no target, because the bundle keeps the server to itself, and the wait for a connection and the BEGIN and COMMIT the adapter sends on its own are not seen. Prisma 7.0 to 7.9 pin 3.4.5, which is observed at the driver however it is loaded. Sequelize or code of your own on the bundle stays unobserved.
  • What the connection runs while it opens — SET NAMES, the session's variables — is not counted: a pool opens a connection inside the request that found none free, and those statements are the driver's.
  • queryStream, and execute and batch before 3.4, which return a promise of their own, are not observed. A callback query with no callback is counted when it is issued, with no time and no failure.

SQL Server

When your application reaches SQL Server, the instrumentation does the same, as a dependency of its own (mssql): the statements each request sends, how long they took and how many failed. Every way there goes through one driver, tedious: mssql wraps it, and TypeORM, Knex and Prisma 7's @prisma/adapter-mssql go through mssql; Sequelize's mssql dialect calls tedious itself. Nothing is asked of any of them. The target is the server and its port (or its instance), never the user, the password or the database. Run against tedious 20 and mssql 12, against a server of the tests' own and against Azure SQL Edge, with parameterised queries, prepared statements, a stored procedure, a server's error and a transaction; tedious 16 to 19 have the same method.

The text is read as T-SQL. A single quote opens a value, and a backslash escapes nothing in it; a bracket opens a name — [dbo].[users], [Order Items] —, and ]] inside one is a bracket of the name. A double quote opens a name too, as TypeORM writes them ("User"."email"): tedious turns QUOTED_IDENTIFIER on as it logs in. A name that looks like a value — an email, a number — goes as ?, as in Postgres. @p0, @0, @P1 and @id are placeholders, so a list of them is one label however long it is and whichever ORM numbered it, and @@ROWCOUNT stays. N'…', 0x… and $5.00 are values. Comments nest. #orders is a temporary table.

What the instrumentation does with the driver, and what it gives up:

  • It wraps Connection.prototype.makeRequest, which every request tedious sends goes through, and the callback of the request, which runs in the request that asked: the driver ends a statement on the connection's socket, in the context of whichever request opened it. Arguments, results and errors pass through untouched.
  • A prepared statement is named by its text. mssql prepares once with the text and executes with a handle; the handle is remembered for its connection, also when it was prepared at start-up.
  • A stored procedure is named by its name (dbo.refresh); a transaction's begin, commit and rollback, and a bulk load, are calls with no operation.
  • What is not your route's is not counted: the options the connection sets as it logs in, and the SELECT 1; with which mssql's pool validates a connection before it hands it over. That validation is skipped by patching mssql as your application loads it, from Node 22; on Node 20, an mssql loaded after the instrumentation started keeps counting it.
  • The wait for a connection is not measured: mssql's pool has no request to wrap.
  • Prisma 4 to 6 on SQL Server goes through its own engine, and is observed through its client, by action and model (Prisma, switch prisma); Prisma 7, which needs @prisma/adapter-mssql, through mssql, statement by statement.

SQLite

When your application opens a SQLite database through better-sqlite3, or through libsql — which is what @libsql/client opens a file: URL with, and so Drizzle and Prisma 7's adapters on either, and MikroORM's packages for SQLite, whose driver they bring — the instrumentation observes it as a dependency of its own (sqlite): the statements each request runs, how long they took and how many failed. Each statement is an operation of kind query, normalised as SQLite reads it: a single quote delimits a value; a double quote, a backtick and a bracket delimit a name, kept while nothing in it looks like a value; and a backslash escapes nothing. select * from "users" where "email" = '[email protected]' is select * from "users" where "email" = ?.

What the instrumentation does with the driver, and what it gives up:

  • It wraps the methods that run a statement — run, get, all, iterate — on the class every statement of the driver shares, and exec, and prepare to learn each statement's text where libsql keeps none. A statement your application prepares once at start-up and runs in every request is counted at each run. It is put in place from the first request, resolved and not loaded at start-up, as for pg, on the driver your application loads and on the libsql @libsql/client brings. Results and errors pass through untouched. Run with better-sqlite3 12, libsql 0.5 and @libsql/client 0.18, on :memory:.
  • One dependency, with no target. A database file's path is your machine's; every file is one sqlite.
  • Preparing is not running, and is not counted. iterate counts the statement once, when the loop starts; the time the loop spends reading rows is the loop's.
  • A libsql statement prepared before the instrumentation was in place — at start-up, before the first request — is counted against the dependency and has no label: libsql keeps no text of a statement. better-sqlite3 keeps it, and those are named like any other. @libsql/client prepares at every query, so all of its are named.
  • @libsql/client against a server — Turso, libsql://, http:// — is outgoing HTTP or a WebSocket, not this.
  • sqlite3, the callback driver Knex and Sequelize open SQLite with, is observed too, 5 and 6: each statement its Statement runs — which every method of its Database makes and runs — is counted from the call, in the request that made it, to the callback the driver answers through, whose arguments and this pass untouched; a statement that does not compile is counted as a failed one. A statement run with no callback is not counted: nothing says when it ends, and giving it one would change where its errors go. each is counted when it has its completion callback.
  • observers in the batch says on when a driver was found; when none was, it says nothing, as for an application that does not use it.

MongoDB

When your application uses MongoDB through its official driver, mongodb 6 or 7 — and so through Mongoose 8 and 9, which send every command through it — the instrumentation observes it as a dependency of its own (mongodb): the commands each request sends, how long they took and how many failed. Each command is an operation of kind query, named by two words, the command and the collection: find users, insert orders, aggregate events, getMore orders. Never the filter, the documents, the pipeline or the update, which hold your data; a collection whose name looks like a value (tenant_8f3a9c2e…, a number) is ?, so a collection per customer is one operation and its name stays on your server. Two shapes of one command on one collection — a find by email and one by id — are one operation, and a request that sends one more of them counts one more. A command the server refused, and an insert a unique index refused, are failures.

What the instrumentation does with the driver, and what it gives up:

  • It wraps one method, Connection.prototype.command, which every command the driver sends goes through, on the driver your application loads and on the one Mongoose or @mikro-orm/mongodb brings when that is another. It is put in place from the first request, resolved and not loaded at start-up, as for pg. Arguments, results and errors pass through untouched, and if the wrapper itself fails the command still runs. Run with the driver against the tests' own server on 6.21 and 7.7, with Mongoose 9, and against MongoDB 7.
  • One dependency, with no target. A replica set's members — the primary, the secondaries a read goes to — are one database to your application.
  • The driver's own conversation is not a request's: the handshake and the authentication of a new connection, and ending the sessions, are not counted, and no command outside a request is.
  • The wait for a connection from the driver's pool is before the command, and is part of no command's time.
  • Mongoose 5 to 7, on the drivers 3 to 5, are not observed. Those drivers answer through callbacks, and their pool hands a waiting request its connection from the request that gave it back: what a wrapper recorded would not be known to be the request's. init says it, from the range your package.json declares.
  • observers in the batch says on when a driver was found; when none was, it says nothing, as for an application that does not use it.

Prisma

Prisma is observed two ways, by the engine that runs its queries: Prisma 7, and 6.16 or later with engineType = "client", query through a driver adapter, and are seen through that driver, as SQL; before 7, on Prisma's own engine, a client prisma-client-js generated is seen through the client itself, as Prisma's operations (ADR 0241). Measured version by version, with this instrumentation loaded through --import:

| Your Prisma | Its queries go | Observed | |---|---|---| | 7.x, with @prisma/adapter-pg, @prisma/adapter-better-sqlite3 or @prisma/adapter-mariadb | through pg, better-sqlite3 or mariadb: 7 has no engine of its own and requires an adapter | Yes, as the driver's SQL (pg, sqlite, mysql); with @prisma/adapter-mariadb 7.10 or later in an application compiled to CommonJS, at the adapter, with no server named (MySQL) | | 6.16 and later with engineType = "client" in the schema's generator (6.7 to 6.15: the queryCompiler preview), with either adapter | through the driver, as 7 does | Yes, the same | | 3.15 to 6.x on its default engine, with no adapter, from the prisma-client-js generator: what Prisma does before 7 unless told otherwise | from Prisma's query engine, which speaks to the database itself | Yes, as Prisma's operations (prisma), measured on 3.15.2, 4.0.0, 4.16.2, 5.22.0 and 6.19.3 | | 6.x on its default engine from the prisma-client generator, what prisma init writes in 6.19 | the same, from a client that imports Prisma's runtime as an ES module | No: see below | | 5.x, and 6.x on its default engine, with an adapter | from the query engine, which calls the adapter, and so its driver, from a thread of its own | No | | Any version with another adapter (Neon, PlanetScale, …) | through that adapter's own driver | No: it was not looked at |

Through the driver, nothing is asked of you: a Prisma query is a query of the route that ran it, with its time and its errors, and its fingerprint in the route's profile, like any pg query. The queries of prisma.$transaction carry the BEGIN and the COMMIT that Prisma sends, because they are queries. Prisma sends its values as parameters, so what a findMany({ where: { name } }) is called is … WHERE "public"."User"."name" = ? OFFSET ?; SQL that you write yourself with a value in it ($queryRawUnsafe) is normalised like any other, and the value does not leave. Prisma merges calls to findUnique of the same shape made in the same tick into one query (WHERE "id" IN (…)), and that query is counted once, for the request whose call came first: measured on 7.10.0, four requests that asked for the same row in one tick made one query. To be observed this way: Prisma 7, or on 6.16 or later engineType = "client" in the generator block of the schema, and the adapter. No tracing and no OpenTelemetry are involved:

import { PrismaPg } from "@prisma/adapter-pg";

const prisma = new PrismaClient({ adapter: new PrismaPg({ connectionString: process.env.DATABASE_URL }) });

Through the client, on Prisma's own engine, nothing is asked of you either. Every operation of a generated client goes through one method of its class, and the instrumentation wraps it (switch prisma):

  • Each operation is one query, of the database the datasource is — postgresql and cockroachdb are Postgres, sqlserver is SQL Server (mssql), and mysql, sqlite and mongodb their own — named by its action and its model: findMany Post, create User, count Order. Never its arguments. A raw query is named by its SQL, normalised as that database reads it: prisma.$queryRaw`select "id" from "User" where "email" = ${email}` is select "id" from "User" where "email" = ?.
  • It counts what your code asked for. An include is one operation, however many statements the engine runs for it; the findUniques Prisma batches in one tick are each one, as you wrote them; every operation of a $transaction, of an interactive one, and of a client $extends made is counted; a middleware that answers without the engine is not. A loop that runs a findMany per item is the N+1 check is for, and it is seen.
  • Where the client is found. In the module cache, nothing loaded at start-up, as for pg: the client generated into node_modules/.prisma/client, which every Prisma 3 to 5 project has, and one generated into a directory of your own (output = "../generated/prisma", what Prisma 6 asks for), with the copy of Prisma's runtime Prisma puts beside it, as long as it is loaded as CommonJS. Since that directory is yours to name, the whole cache is looked at, at the 1st, 2nd, 4th… request up to the 1024th, and not again once a client is found: a client your application loads after that is not seen. Under Jest, the copy of a module whose name has prisma in it — @prisma/client, ../generated/prisma. Under downtrace check, also as Prisma's runtime is loaded: what reaches a client a test runner loads outside Node's module cache, as Vitest does one generated into your project's directory, whose runtime Node still loads. Not seen: a client the prisma-client generator wrote — what prisma init writes in 6.19 —, which imports Prisma's runtime as an ES module nothing can reach into (on Prisma 7 it goes through a driver adapter, and is seen through the driver); one a bundler inlined; and, outside check, one a runner loads itself.
  • Not counted twice. A client given a driver adapter is left alone: its SQL is the driver's to count.
  • observers in the batch says prisma: on when @prisma/client resolves from your application, and nothing when it does not.

How to tell which row you are in: with DOWNTRACE_INSPECT, a route whose Prisma queries are seen has a postgres (or mysql, sqlite, mongodb) dependency. A route that queries through Prisma and has none is in one of the No rows, and is not a route that makes no queries.

Why the engine's queries are not seen through an adapter. With an adapter, the engine calls it from a thread of its own, so the query reaches pg carrying none of the request's async context: pg sees every query, outside any request, and a query outside a request counts for no route. The instrumentation counts a query for the request whose code ran it and never guesses which one that was. The versions that run their queries in JavaScript run them in the context of the call that asked: with four concurrent requests, every query came to the request that made it. Without an adapter, there is nothing to see through pg at all.

What could be seen, and is not asked for. Prisma's engine can report each query as a trace span, and what it looks for in order to do it is an object on globalThis, so it can be read without loading OpenTelemetry into your application. The instrumentation does not switch it on, because the engine only produces those spans when asked to, and asking has a price that was measured against the same Postgres on the same machine: a query went from 0.38 ms to 0.46 ms, about a fifth more, and the process used about 15 points more CPU while queries ran back to back; and Prisma appends a comment with the trace's identity to every statement it sends. A request that runs twelve queries would spend on that the millisecond that is the whole budget (see Guarantees). On 5.x it also needs previewFeatures = ["tracing"] in your schema. It is a decision this README does not take for you.

The pg the adapter uses. From 6.11, @prisma/adapter-pg has pg as a dependency of its own and not as a peer (in 5.x and up to 6.10 it was one), and the copy it queries through is not always yours: with pnpm, an application that lists the adapter and not pg has none at its root, and with npm a pg of yours that the adapter's version range does not take is a second copy beside it. So the instrumentation also resolves the pg that @prisma/adapter-pg resolves, without loading it, and patches it when the application has: a query is counted by the copy it went through, once.

Calls to other services

Outgoing HTTP is reported the same way, grouped by the host your application asked for: how many calls per request, how long they took, the slowest one, and how many failed. A 5xx from a dependency counts as a failure of that dependency, because for "is this service degraded?" a 500 and a timeout are the same answer.

fetch and the node:http client both publish on Node's diagnostics_channel, and the instrumentation listens. There is one exception: when a fetch cannot connect at all, undici publishes nothing, and t