@downtrace/agent 0.1.2 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -25,6 +25,7 @@ NODE_OPTIONS="--import @downtrace/agent/register" node server.js
25
25
  | `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` |
26
26
  | `DOWNTRACE_DEBUG` | no | `1` or `true` to log the agent's own activity to stderr |
27
27
  | `DOWNTRACE_INTERVAL_MS` | no | Aggregation interval in ms (min 1000; default 10000; anything else falls back to the default) |
28
+ | `DOWNTRACE_INSTRUMENT` | no | `none` stops the agent from observing database drivers; anything else keeps it on |
28
29
 
29
30
  Without `DOWNTRACE_TOKEN` and `DOWNTRACE_URL` (or with a `DOWNTRACE_URL` that is not `http(s)://`) the agent prints one warning and does nothing else.
30
31
 
@@ -32,9 +33,22 @@ Without `DOWNTRACE_TOKEN` and `DOWNTRACE_URL` (or with a `DOWNTRACE_URL` that is
32
33
 
33
34
  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`](https://www.npmjs.com/package/@downtrace/protocol).
34
35
 
36
+ ### Database work per request
37
+
38
+ When your application uses `pg`, the agent also counts the queries each request makes, how long they took in total
39
+ and the slowest one, and reports that distribution per route. It is what turns "this endpoint got slower" into "this
40
+ endpoint went from 12 queries per request to 65". **It never reads the query text or its values**, only counts and
41
+ durations.
42
+
43
+ The agent loads before your application (`node --import`), so it wraps the driver before you import it and you write
44
+ no code. The wrapper passes arguments, results and errors through untouched, and a failure inside it runs your query
45
+ anyway. `DOWNTRACE_INSTRUMENT=none` turns it off.
46
+
35
47
  ## Guarantees
36
48
 
37
- - Observation through Node's `diagnostics_channel`; nothing in your application is monkey-patched.
49
+ - HTTP requests are observed through Node's `diagnostics_channel`, without touching your code. To count queries per
50
+ request the agent does wrap one method, `pg`'s `Client.prototype.query`: it passes arguments, results and errors
51
+ through untouched, and if the wrapper itself fails your query still runs. `DOWNTRACE_INSTRUMENT=none` disables it.
38
52
  - Sending is asynchronous with `fetch`, off the request path; a bounded queue of 6 intervals — if the cloud is unreachable, the oldest is dropped.
39
53
  - Every hook is guarded; after 10 internal errors the agent disables itself and says so once.
40
54
  - At most 500 distinct routes per interval; the rest fold into `(other)`.
@@ -1,7 +1,10 @@
1
+ import { createRequire } from "node:module";
1
2
  import { randomUUID } from "node:crypto";
2
3
  import diagnostics_channel from "node:diagnostics_channel";
3
4
  import { hostname } from "node:os";
4
- import { AGGREGATES_PATH, LATENCY_BUCKETS_V0, PROTOCOL_VERSION, latencyBucket } from "@downtrace/protocol";
5
+ import { AGGREGATES_PATH, LATENCY_BUCKETS_V0, PROTOCOL_VERSION, QUERIES_PER_REQUEST_BUCKETS_V0, latencyBucket, queriesPerRequestBucket } from "@downtrace/protocol";
6
+ import { AsyncLocalStorage } from "node:async_hooks";
7
+ import { performance as performance$1 } from "node:perf_hooks";
5
8
  //#region src/routes.ts
6
9
  const METHODS = /* @__PURE__ */ new Set([
7
10
  "GET",
@@ -64,7 +67,7 @@ var IntervalAggregator = class {
64
67
  get size() {
65
68
  return this.endpoints.size;
66
69
  }
67
- record(method, route, status, ms) {
70
+ record(method, route, status, ms, work) {
68
71
  let key = `${method} ${route}`;
69
72
  let acc = this.endpoints.get(key);
70
73
  if (!acc) {
@@ -85,7 +88,8 @@ var IntervalAggregator = class {
85
88
  serverError: 0,
86
89
  counts: new Uint32Array(LATENCY_BUCKETS_V0),
87
90
  sum: 0,
88
- max: 0
91
+ max: 0,
92
+ pg: void 0
89
93
  };
90
94
  this.endpoints.set(key, acc);
91
95
  }
@@ -102,6 +106,17 @@ var IntervalAggregator = class {
102
106
  acc.counts[bucket] = (acc.counts[bucket] ?? 0) + 1;
103
107
  acc.sum += latency;
104
108
  if (latency > acc.max) acc.max = latency;
109
+ if (work) {
110
+ acc.pg ??= {
111
+ counts: new Uint32Array(QUERIES_PER_REQUEST_BUCKETS_V0),
112
+ sum: 0,
113
+ max: 0
114
+ };
115
+ const bucket = queriesPerRequestBucket(work.queries);
116
+ acc.pg.counts[bucket] = (acc.pg.counts[bucket] ?? 0) + 1;
117
+ acc.pg.sum += work.queryMs;
118
+ if (work.queryMaxMs > acc.pg.max) acc.pg.max = work.queryMaxMs;
119
+ }
105
120
  }
106
121
  /** Closes the current interval and starts a new one. Returns null when nothing was recorded. */
107
122
  rotate() {
@@ -113,23 +128,31 @@ var IntervalAggregator = class {
113
128
  this.start = now;
114
129
  if (endpoints.size === 0) return null;
115
130
  const out = [];
116
- for (const acc of endpoints.values()) out.push({
117
- method: acc.method,
118
- route: acc.route,
119
- count: acc.count,
120
- errors: acc.errors,
121
- status: {
122
- success: acc.success,
123
- redirect: acc.redirect,
124
- clientError: acc.clientError,
125
- serverError: acc.serverError
126
- },
127
- latency: {
128
- counts: Array.from(acc.counts),
129
- sum: round3(acc.sum),
130
- max: round3(acc.max)
131
- }
132
- });
131
+ for (const acc of endpoints.values()) {
132
+ const postgres = acc.pg ? {
133
+ queriesPerRequest: Array.from(acc.pg.counts),
134
+ totalMs: round3(acc.pg.sum),
135
+ max: round3(acc.pg.max)
136
+ } : void 0;
137
+ out.push({
138
+ method: acc.method,
139
+ route: acc.route,
140
+ count: acc.count,
141
+ errors: acc.errors,
142
+ status: {
143
+ success: acc.success,
144
+ redirect: acc.redirect,
145
+ clientError: acc.clientError,
146
+ serverError: acc.serverError
147
+ },
148
+ latency: {
149
+ counts: Array.from(acc.counts),
150
+ sum: round3(acc.sum),
151
+ max: round3(acc.max)
152
+ },
153
+ ...postgres ? { postgres } : {}
154
+ });
155
+ }
133
156
  return {
134
157
  start: Math.floor(start),
135
158
  durationMs: Math.max(1, Math.round(now - start)),
@@ -141,6 +164,107 @@ function round3(n) {
141
164
  return Math.round(n * 1e3) / 1e3;
142
165
  }
143
166
  //#endregion
167
+ //#region src/context.ts
168
+ const storage = new AsyncLocalStorage();
169
+ /**
170
+ * Opens the context for a request. Called from the `http.server.request.start` subscriber, which Node publishes
171
+ * inside the request's own async context, so `enterWith` reaches the handler and everything it awaits. Verified
172
+ * against concurrent keep-alive traffic: each request counts its own work.
173
+ */
174
+ function enterRequest() {
175
+ const ctx = {
176
+ queries: 0,
177
+ queryMs: 0,
178
+ queryMaxMs: 0
179
+ };
180
+ storage.enterWith(ctx);
181
+ return ctx;
182
+ }
183
+ /** Records one finished query against the current request. Work outside a request is not attributed to any route. */
184
+ function recordQuery(ms) {
185
+ const ctx = storage.getStore();
186
+ if (!ctx) return;
187
+ ctx.queries += 1;
188
+ ctx.queryMs += ms;
189
+ if (ms > ctx.queryMaxMs) ctx.queryMaxMs = ms;
190
+ }
191
+ //#endregion
192
+ //#region src/instrument/pg.ts
193
+ const MARK = Symbol.for("downtrace.pg.instrumented");
194
+ /**
195
+ * Wraps `pg`'s `Client.prototype.query` so every query counts towards the request that issued it.
196
+ *
197
+ * The agent loads before the application (`node --import`), resolves `pg` from the application's own root and
198
+ * patches the prototype. CommonJS modules are cached by resolved path, so the instance the application later
199
+ * imports is the one patched here: no loader hooks, no dependency, and it works whether the app is ESM or CJS.
200
+ *
201
+ * Returns the instrumented module's version, or undefined when there is nothing to instrument.
202
+ */
203
+ function instrumentPg(deps) {
204
+ let pg;
205
+ let version = "unknown";
206
+ try {
207
+ if (deps.moduleImpl !== void 0) pg = deps.moduleImpl;
208
+ else {
209
+ const base = deps.from ?? process.argv[1] ?? `${process.cwd()}/`;
210
+ const require = createRequire(base);
211
+ pg = require("pg");
212
+ const pkg = require("pg/package.json");
213
+ if (typeof pkg.version === "string") version = pkg.version;
214
+ }
215
+ } catch {
216
+ return;
217
+ }
218
+ const proto = pg.Client?.prototype;
219
+ if (!proto || typeof proto.query !== "function") {
220
+ deps.log.debug("pg found but Client.prototype.query is not a function; not instrumenting");
221
+ return;
222
+ }
223
+ if (proto[MARK] === true) return version;
224
+ const original = proto.query;
225
+ const wrapped = function(...args) {
226
+ let done;
227
+ try {
228
+ const started = performance$1.now();
229
+ let counted = false;
230
+ done = () => {
231
+ if (counted) return;
232
+ counted = true;
233
+ recordQuery(performance$1.now() - started);
234
+ };
235
+ const last = args.at(-1);
236
+ if (typeof last === "function") {
237
+ const callback = last;
238
+ const finish = done;
239
+ args[args.length - 1] = function(...cbArgs) {
240
+ finish();
241
+ return callback.apply(this, cbArgs);
242
+ };
243
+ return original.apply(this, args);
244
+ }
245
+ } catch {
246
+ return original.apply(this, args);
247
+ }
248
+ const result = original.apply(this, args);
249
+ if (result && typeof result.then === "function") {
250
+ const settle = done;
251
+ return result.then((value) => {
252
+ settle?.();
253
+ return value;
254
+ }, (err) => {
255
+ settle?.();
256
+ throw err;
257
+ });
258
+ }
259
+ done?.();
260
+ return result;
261
+ };
262
+ proto.query = wrapped;
263
+ proto[MARK] = true;
264
+ deps.log.debug(`instrumented pg ${version}`);
265
+ return version;
266
+ }
267
+ //#endregion
144
268
  //#region src/log.ts
145
269
  const PREFIX = "[downtrace]";
146
270
  /** Minimal stderr logger. `debug` lines only appear with DOWNTRACE_DEBUG. */
@@ -236,7 +360,7 @@ var Sender = class {
236
360
  //#endregion
237
361
  //#region src/version.ts
238
362
  /** Version of this agent build, from package.json. */
239
- const AGENT_VERSION = "0.1.2";
363
+ const AGENT_VERSION = "0.2.1";
240
364
  //#endregion
241
365
  //#region src/agent.ts
242
366
  const REQUEST_START = "http.server.request.start";
@@ -258,6 +382,8 @@ var Agent = class {
258
382
  sender;
259
383
  handleSignals;
260
384
  starts = /* @__PURE__ */ new WeakMap();
385
+ contexts = /* @__PURE__ */ new WeakMap();
386
+ instrumented = false;
261
387
  timer;
262
388
  started = false;
263
389
  recorded = 0;
@@ -317,6 +443,11 @@ var Agent = class {
317
443
  start() {
318
444
  if (this.started || this.disabled) return;
319
445
  this.started = true;
446
+ if (this.config.instrument) {
447
+ const pg = instrumentPg({ log: this.log });
448
+ this.instrumented = pg !== void 0;
449
+ if (pg) this.log.debug(`instrumented pg ${pg}`);
450
+ }
320
451
  diagnostics_channel.subscribe(REQUEST_START, this.onStart);
321
452
  diagnostics_channel.subscribe(RESPONSE_FINISH, this.onFinish);
322
453
  this.timer = setInterval(() => void this.flushNow(), this.config.intervalMs);
@@ -349,7 +480,9 @@ var Agent = class {
349
480
  }
350
481
  requestStarted(message) {
351
482
  const request = message.request;
352
- if (request) this.starts.set(request, performance.now());
483
+ if (!request) return;
484
+ this.starts.set(request, performance.now());
485
+ if (this.instrumented) this.contexts.set(request, enterRequest());
353
486
  }
354
487
  responseFinished(message) {
355
488
  const { request, response } = message;
@@ -357,7 +490,9 @@ var Agent = class {
357
490
  const startedAt = this.starts.get(request);
358
491
  this.starts.delete(request);
359
492
  const ms = startedAt === void 0 ? 0 : performance.now() - startedAt;
360
- this.recorder.record(normalizeMethod(request.method), routeOf(request), response?.statusCode ?? 0, ms);
493
+ const work = this.contexts.get(request);
494
+ this.contexts.delete(request);
495
+ this.recorder.record(normalizeMethod(request.method), routeOf(request), response?.statusCode ?? 0, ms, work);
361
496
  this.recorded += 1;
362
497
  }
363
498
  /** Every hook runs through here: an agent bug must never reach the application. */
@@ -439,7 +574,8 @@ function configFromEnv(env = process.env) {
439
574
  environment: clamp(env.DOWNTRACE_ENV ?? env.NODE_ENV ?? "production", 64),
440
575
  version: detectVersion(env),
441
576
  debug: env.DOWNTRACE_DEBUG === "1" || env.DOWNTRACE_DEBUG === "true",
442
- intervalMs: Number.isInteger(interval) && interval >= MIN_INTERVAL_MS ? interval : DEFAULT_INTERVAL_MS
577
+ intervalMs: Number.isInteger(interval) && interval >= MIN_INTERVAL_MS ? interval : DEFAULT_INTERVAL_MS,
578
+ instrument: (env.DOWNTRACE_INSTRUMENT ?? "auto") !== "none"
443
579
  }
444
580
  };
445
581
  }
package/dist/index.d.ts CHANGED
@@ -19,8 +19,14 @@ declare function routeOf(req: RouteSource): string;
19
19
  declare function heuristicTemplate(url: string): string;
20
20
  //#endregion
21
21
  //#region src/aggregator.d.ts
22
+ /** What one finished request did in Postgres, as seen by the instrumented driver. */
23
+ interface QueryWork {
24
+ queries: number;
25
+ queryMs: number;
26
+ queryMaxMs: number;
27
+ }
22
28
  interface Recorder {
23
- record(method: Method, route: string, status: number, ms: number): void;
29
+ record(method: Method, route: string, status: number, ms: number, work?: QueryWork | undefined): void;
24
30
  rotate(): Interval | null;
25
31
  }
26
32
  declare const DEFAULT_MAX_ROUTES = 500;
@@ -36,7 +42,7 @@ declare class IntervalAggregator implements Recorder {
36
42
  private readonly now;
37
43
  constructor(maxRoutes?: number, now?: () => number);
38
44
  get size(): number;
39
- record(method: Method, route: string, status: number, ms: number): void;
45
+ record(method: Method, route: string, status: number, ms: number, work?: QueryWork | undefined): void;
40
46
  /** Closes the current interval and starts a new one. Returns null when nothing was recorded. */
41
47
  rotate(): Interval | null;
42
48
  }
@@ -51,6 +57,8 @@ interface AgentConfig {
51
57
  debug: boolean;
52
58
  /** Aggregation interval; 10 s in production. */
53
59
  intervalMs: number;
60
+ /** Observe database drivers to attribute work to each request. `DOWNTRACE_INSTRUMENT=none` turns it off. */
61
+ instrument: boolean;
54
62
  }
55
63
  type ConfigResult = {
56
64
  ok: true;
@@ -139,6 +147,8 @@ declare class Agent {
139
147
  private readonly sender;
140
148
  private readonly handleSignals;
141
149
  private readonly starts;
150
+ private readonly contexts;
151
+ private instrumented;
142
152
  private timer;
143
153
  private started;
144
154
  private recorded;
package/dist/index.js CHANGED
@@ -1,2 +1,2 @@
1
- import { a as createAgent, c as Sender, d as IntervalAggregator, f as OTHER_ROUTE, h as routeOf, i as Agent, l as createLogger, m as normalizeMethod, n as configFromEnv, o as AGENT_VERSION, p as heuristicTemplate, r as detectVersion, s as DEFAULT_MAX_QUEUED, t as DEFAULT_INTERVAL_MS, u as DEFAULT_MAX_ROUTES } from "./config-DrGYaP4a.js";
1
+ import { a as createAgent, c as Sender, d as IntervalAggregator, f as OTHER_ROUTE, h as routeOf, i as Agent, l as createLogger, m as normalizeMethod, n as configFromEnv, o as AGENT_VERSION, p as heuristicTemplate, r as detectVersion, s as DEFAULT_MAX_QUEUED, t as DEFAULT_INTERVAL_MS, u as DEFAULT_MAX_ROUTES } from "./config-B_GsH0f7.js";
2
2
  export { AGENT_VERSION, Agent, DEFAULT_INTERVAL_MS, DEFAULT_MAX_QUEUED, DEFAULT_MAX_ROUTES, IntervalAggregator, OTHER_ROUTE, Sender, configFromEnv, createAgent, createLogger, detectVersion, heuristicTemplate, normalizeMethod, routeOf };
package/dist/register.js CHANGED
@@ -1,4 +1,4 @@
1
- import { a as createAgent, l as createLogger, n as configFromEnv } from "./config-DrGYaP4a.js";
1
+ import { a as createAgent, l as createLogger, n as configFromEnv } from "./config-B_GsH0f7.js";
2
2
  //#region src/register.ts
3
3
  /**
4
4
  * Entry point users load with `node --import @downtrace/agent/register`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@downtrace/agent",
3
- "version": "0.1.2",
3
+ "version": "0.2.1",
4
4
  "description": "Downtrace agent for Node.js: a flight recorder for your backend. Observes HTTP requests, aggregates locally, ships compact batches, never in your request path.",
5
5
  "type": "module",
6
6
  "exports": {
@@ -22,7 +22,7 @@
22
22
  "node": ">=20"
23
23
  },
24
24
  "dependencies": {
25
- "@downtrace/protocol": "0.1.0"
25
+ "@downtrace/protocol": "0.2.1"
26
26
  },
27
27
  "devDependencies": {
28
28
  "@types/express": "^5.0.6",