@downtrace/agent 0.1.0

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Raúl Jiménez
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,49 @@
1
+ # @downtrace/agent
2
+
3
+ **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 agent: it observes incoming HTTP requests, aggregates them 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.
4
+
5
+ v0 observes incoming HTTP only. Outgoing calls, database queries and the black box come next.
6
+
7
+ ## Install
8
+
9
+ ```sh
10
+ npm install @downtrace/agent
11
+ ```
12
+
13
+ ```sh
14
+ DOWNTRACE_TOKEN=dt_… DOWNTRACE_URL=https://your-downtrace-cloud \
15
+ node --import @downtrace/agent/register server.js
16
+ # or, without touching the start command:
17
+ NODE_OPTIONS="--import @downtrace/agent/register" node server.js
18
+ ```
19
+
20
+ | Variable | Required | What it is |
21
+ |---|---|---|
22
+ | `DOWNTRACE_TOKEN` | yes | The project's ingest token |
23
+ | `DOWNTRACE_URL` | yes | Base URL of the cloud (`https://…`) |
24
+ | `DOWNTRACE_ENV` | no | Environment; falls back to `NODE_ENV`, then `production` |
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
+ | `DOWNTRACE_DEBUG` | no | `1` to log the agent's own activity to stderr |
27
+ | `DOWNTRACE_INTERVAL_MS` | no | Aggregation interval (min 1000; default 10000) |
28
+
29
+ Without `DOWNTRACE_TOKEN` and `DOWNTRACE_URL` the agent prints one warning and does nothing else.
30
+
31
+ ## What leaves your server
32
+
33
+ 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
+ ## Guarantees
36
+
37
+ - Observation through Node's `diagnostics_channel`; nothing in your application is monkey-patched.
38
+ - 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
+ - Every hook is guarded; after 10 internal errors the agent disables itself and says so once.
40
+ - At most 500 distinct routes per interval; the rest fold into `(other)`.
41
+ - Measured overhead budget, enforced in CI: < 1 ms added at p99, < 3 percentage points of CPU, < 64 MiB.
42
+
43
+ ## Requirements
44
+
45
+ Node.js 20 or newer (see `engines`). Express route templates are used when present; without a framework, identifier-looking path segments (numbers, UUIDs, long hex) are collapsed into `:id`.
46
+
47
+ ## Source
48
+
49
+ This package is developed in a monorepo and mirrored read-only to [RadW2020/downtrace-agent](https://github.com/RadW2020/downtrace-agent). Issues are welcome there. MIT.
@@ -0,0 +1,458 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import diagnostics_channel from "node:diagnostics_channel";
3
+ import { hostname } from "node:os";
4
+ import { AGGREGATES_PATH, LATENCY_BUCKETS_V0, PROTOCOL_VERSION, latencyBucket } from "@downtrace/protocol";
5
+ //#region src/routes.ts
6
+ const METHODS = /* @__PURE__ */ new Set([
7
+ "GET",
8
+ "POST",
9
+ "PUT",
10
+ "PATCH",
11
+ "DELETE",
12
+ "HEAD",
13
+ "OPTIONS"
14
+ ]);
15
+ const MAX_ROUTE_LENGTH = 256;
16
+ /** Route used when the per-interval cardinality cap is hit. */
17
+ const OTHER_ROUTE = "(other)";
18
+ function normalizeMethod(method) {
19
+ const m = (method ?? "").toUpperCase();
20
+ return METHODS.has(m) ? m : "OTHER";
21
+ }
22
+ /**
23
+ * Route template for a request. Prefers the framework's own template
24
+ * (`/products/:id` from Express); otherwise collapses identifier-looking path
25
+ * segments (numbers, UUIDs, long hex) into `:id`.
26
+ */
27
+ function routeOf(req) {
28
+ const route = expressTemplate(req) ?? heuristicTemplate(req.url ?? "/");
29
+ return route.length > MAX_ROUTE_LENGTH ? route.slice(0, MAX_ROUTE_LENGTH) : route;
30
+ }
31
+ function expressTemplate(req) {
32
+ const path = req.route?.path;
33
+ if (typeof path !== "string") return void 0;
34
+ const joined = `${typeof req.baseUrl === "string" ? req.baseUrl : ""}${path}`.replace(/\/{2,}/g, "/");
35
+ return joined === "" ? "/" : trimSlash(joined);
36
+ }
37
+ const NUMERIC = /^\d+$/;
38
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
39
+ const HEX_ID = /^(?:[0-9a-f]{24}|[0-9a-f]{32,})$/i;
40
+ function heuristicTemplate(url) {
41
+ return trimSlash((url.split(/[?#]/, 1)[0] ?? "/").split("/").map((s) => s !== "" && (NUMERIC.test(s) || UUID.test(s) || HEX_ID.test(s)) ? ":id" : s).join("/") || "/");
42
+ }
43
+ function trimSlash(path) {
44
+ return path.length > 1 && path.endsWith("/") ? path.slice(0, -1) : path;
45
+ }
46
+ //#endregion
47
+ //#region src/aggregator.ts
48
+ const DEFAULT_MAX_ROUTES = 500;
49
+ /**
50
+ * Aggregates finished requests for the current interval. Memory is bounded:
51
+ * at most `maxRoutes` distinct routes per interval, the rest fold into (other).
52
+ */
53
+ var IntervalAggregator = class {
54
+ endpoints = /* @__PURE__ */ new Map();
55
+ distinctRoutes = 0;
56
+ start;
57
+ maxRoutes;
58
+ now;
59
+ constructor(maxRoutes = 500, now = Date.now) {
60
+ this.maxRoutes = maxRoutes;
61
+ this.now = now;
62
+ this.start = now();
63
+ }
64
+ get size() {
65
+ return this.endpoints.size;
66
+ }
67
+ record(method, route, status, ms) {
68
+ let key = `${method} ${route}`;
69
+ let acc = this.endpoints.get(key);
70
+ if (!acc) {
71
+ if (route !== "(other)" && this.distinctRoutes >= this.maxRoutes) {
72
+ route = OTHER_ROUTE;
73
+ key = `${method} ${OTHER_ROUTE}`;
74
+ acc = this.endpoints.get(key);
75
+ } else if (route !== "(other)") this.distinctRoutes += 1;
76
+ if (!acc) {
77
+ acc = {
78
+ method,
79
+ route,
80
+ count: 0,
81
+ errors: 0,
82
+ success: 0,
83
+ redirect: 0,
84
+ clientError: 0,
85
+ serverError: 0,
86
+ counts: new Uint32Array(LATENCY_BUCKETS_V0),
87
+ sum: 0,
88
+ max: 0
89
+ };
90
+ this.endpoints.set(key, acc);
91
+ }
92
+ }
93
+ acc.count += 1;
94
+ if (status >= 500) {
95
+ acc.serverError += 1;
96
+ acc.errors += 1;
97
+ } else if (status >= 400) acc.clientError += 1;
98
+ else if (status >= 300) acc.redirect += 1;
99
+ else acc.success += 1;
100
+ const latency = ms >= 0 ? ms : 0;
101
+ const bucket = latencyBucket(latency);
102
+ acc.counts[bucket] = (acc.counts[bucket] ?? 0) + 1;
103
+ acc.sum += latency;
104
+ if (latency > acc.max) acc.max = latency;
105
+ }
106
+ /** Closes the current interval and starts a new one. Returns null when nothing was recorded. */
107
+ rotate() {
108
+ const now = this.now();
109
+ const start = this.start;
110
+ const endpoints = this.endpoints;
111
+ this.endpoints = /* @__PURE__ */ new Map();
112
+ this.distinctRoutes = 0;
113
+ this.start = now;
114
+ if (endpoints.size === 0) return null;
115
+ 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
+ });
133
+ return {
134
+ start: Math.floor(start),
135
+ durationMs: Math.max(1, Math.round(now - start)),
136
+ endpoints: out
137
+ };
138
+ }
139
+ };
140
+ function round3(n) {
141
+ return Math.round(n * 1e3) / 1e3;
142
+ }
143
+ //#endregion
144
+ //#region src/log.ts
145
+ const PREFIX = "[downtrace]";
146
+ /** Minimal stderr logger. `debug` lines only appear with DOWNTRACE_DEBUG. */
147
+ function createLogger(debug, write = defaultWrite) {
148
+ return {
149
+ warn: (message) => write(`${PREFIX} ${message}`),
150
+ debug: (message) => {
151
+ if (debug) write(`${PREFIX} ${message}`);
152
+ }
153
+ };
154
+ }
155
+ function defaultWrite(line) {
156
+ process.stderr.write(`${line}\n`);
157
+ }
158
+ //#endregion
159
+ //#region src/transport.ts
160
+ const DEFAULT_MAX_QUEUED = 6;
161
+ const DEFAULT_TIMEOUT_MS = 5e3;
162
+ /**
163
+ * Ships intervals to the cloud in batches. Never blocks, never grows without
164
+ * bound: failed batches stay queued (up to maxQueued) and ride the next flush.
165
+ */
166
+ var Sender = class {
167
+ queue = [];
168
+ inflight = false;
169
+ warnedAuth = false;
170
+ sent = 0;
171
+ failed = 0;
172
+ dropped = 0;
173
+ opts;
174
+ maxQueued;
175
+ timeoutMs;
176
+ fetchImpl;
177
+ constructor(opts) {
178
+ this.opts = opts;
179
+ this.maxQueued = opts.maxQueued ?? 6;
180
+ this.timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
181
+ this.fetchImpl = opts.fetchImpl ?? fetch;
182
+ }
183
+ get pending() {
184
+ return this.queue.length;
185
+ }
186
+ enqueue(interval) {
187
+ this.queue.push(interval);
188
+ while (this.queue.length > this.maxQueued) {
189
+ this.queue.shift();
190
+ this.dropped += 1;
191
+ }
192
+ }
193
+ /** Sends everything queued in one batch. Resolves true when the cloud accepted it. */
194
+ async flush(timeoutMs = this.timeoutMs) {
195
+ if (this.inflight || this.queue.length === 0) return false;
196
+ this.inflight = true;
197
+ const intervals = this.queue.slice(0, this.maxQueued);
198
+ const batch = {
199
+ protocol: PROTOCOL_VERSION,
200
+ agent: this.opts.agent,
201
+ instance: this.opts.instance,
202
+ deploy: this.opts.deploy,
203
+ intervals
204
+ };
205
+ try {
206
+ const res = await this.fetchImpl(`${this.opts.url}${AGGREGATES_PATH}`, {
207
+ method: "POST",
208
+ headers: {
209
+ "content-type": "application/json",
210
+ authorization: `Bearer ${this.opts.token}`
211
+ },
212
+ body: JSON.stringify(batch),
213
+ signal: AbortSignal.timeout(timeoutMs)
214
+ });
215
+ if (res.ok) {
216
+ this.queue = this.queue.filter((iv) => !intervals.includes(iv));
217
+ this.sent += 1;
218
+ this.opts.log.debug(`sent ${intervals.length} interval(s)`);
219
+ return true;
220
+ }
221
+ this.failed += 1;
222
+ if (res.status === 401 && !this.warnedAuth) {
223
+ this.warnedAuth = true;
224
+ this.opts.log.warn("the cloud rejected DOWNTRACE_TOKEN (401); aggregates will be dropped until it is fixed");
225
+ } else this.opts.log.debug(`cloud responded ${res.status}; keeping ${this.queue.length} interval(s) queued`);
226
+ return false;
227
+ } catch (err) {
228
+ this.failed += 1;
229
+ this.opts.log.debug(`send failed: ${err instanceof Error ? err.message : String(err)}`);
230
+ return false;
231
+ } finally {
232
+ this.inflight = false;
233
+ }
234
+ }
235
+ };
236
+ //#endregion
237
+ //#region src/version.ts
238
+ /** Version of this agent build, from package.json. */
239
+ const AGENT_VERSION = "0.1.0";
240
+ //#endregion
241
+ //#region src/agent.ts
242
+ const REQUEST_START = "http.server.request.start";
243
+ const RESPONSE_FINISH = "http.server.response.finish";
244
+ const MAX_INTERNAL_ERRORS = 10;
245
+ const SHUTDOWN_FLUSH_MS = 1e3;
246
+ const SIGNALS = ["SIGTERM", "SIGINT"];
247
+ /**
248
+ * The Downtrace Node agent, v0: observes finished HTTP requests through
249
+ * diagnostics_channel, aggregates them per route and interval, and ships
250
+ * batches asynchronously. Nothing here runs synchronously against the cloud,
251
+ * nothing here can throw into the application, and memory is bounded.
252
+ */
253
+ var Agent = class {
254
+ config;
255
+ instance;
256
+ log;
257
+ recorder;
258
+ sender;
259
+ handleSignals;
260
+ starts = /* @__PURE__ */ new WeakMap();
261
+ timer;
262
+ started = false;
263
+ recorded = 0;
264
+ internalErrors = 0;
265
+ disabled = false;
266
+ onStart = (message) => this.guard(() => this.requestStarted(message));
267
+ onFinish = (message) => this.guard(() => this.responseFinished(message));
268
+ onSignal;
269
+ onBeforeExit = () => {
270
+ this.flushNow(SHUTDOWN_FLUSH_MS);
271
+ };
272
+ constructor(config, deps = {}) {
273
+ this.config = config;
274
+ this.log = deps.log ?? createLogger(config.debug);
275
+ this.instance = {
276
+ id: randomUUID(),
277
+ hostname: hostname() || "unknown",
278
+ pid: process.pid
279
+ };
280
+ const agent = {
281
+ name: "@downtrace/agent",
282
+ version: AGENT_VERSION,
283
+ runtime: "node",
284
+ runtimeVersion: process.version
285
+ };
286
+ const deploy = {
287
+ version: config.version,
288
+ environment: config.environment
289
+ };
290
+ this.recorder = deps.recorder ?? new IntervalAggregator();
291
+ this.sender = deps.sender ?? new Sender({
292
+ url: config.url,
293
+ token: config.token,
294
+ agent,
295
+ instance: this.instance,
296
+ deploy,
297
+ log: this.log,
298
+ fetchImpl: deps.fetchImpl
299
+ });
300
+ this.handleSignals = deps.handleSignals ?? false;
301
+ this.onSignal = {
302
+ SIGTERM: () => this.signalled("SIGTERM"),
303
+ SIGINT: () => this.signalled("SIGINT")
304
+ };
305
+ }
306
+ get stats() {
307
+ return {
308
+ recorded: this.recorded,
309
+ internalErrors: this.internalErrors,
310
+ disabled: this.disabled,
311
+ sent: this.sender.sent,
312
+ failed: this.sender.failed,
313
+ dropped: this.sender.dropped,
314
+ pending: this.sender.pending
315
+ };
316
+ }
317
+ start() {
318
+ if (this.started || this.disabled) return;
319
+ this.started = true;
320
+ diagnostics_channel.subscribe(REQUEST_START, this.onStart);
321
+ diagnostics_channel.subscribe(RESPONSE_FINISH, this.onFinish);
322
+ this.timer = setInterval(() => void this.flushNow(), this.config.intervalMs);
323
+ this.timer.unref();
324
+ process.once("beforeExit", this.onBeforeExit);
325
+ if (this.handleSignals) for (const s of SIGNALS) process.on(s, this.onSignal[s]);
326
+ this.log.debug(`started: ${this.config.url} · ${this.config.environment} · ${this.config.version} · every ${this.config.intervalMs} ms`);
327
+ }
328
+ /** Unsubscribes and stops timers; attempts a last flush. Idempotent. */
329
+ async stop() {
330
+ if (!this.started) return;
331
+ this.started = false;
332
+ diagnostics_channel.unsubscribe(REQUEST_START, this.onStart);
333
+ diagnostics_channel.unsubscribe(RESPONSE_FINISH, this.onFinish);
334
+ if (this.timer) clearInterval(this.timer);
335
+ process.removeListener("beforeExit", this.onBeforeExit);
336
+ for (const s of SIGNALS) process.removeListener(s, this.onSignal[s]);
337
+ await this.flushNow(SHUTDOWN_FLUSH_MS);
338
+ }
339
+ /** Closes the current interval and sends everything queued. */
340
+ async flushNow(timeoutMs) {
341
+ try {
342
+ const interval = this.recorder.rotate();
343
+ if (interval) this.sender.enqueue(interval);
344
+ return await this.sender.flush(timeoutMs);
345
+ } catch (err) {
346
+ this.internalError(err);
347
+ return false;
348
+ }
349
+ }
350
+ requestStarted(message) {
351
+ const request = message.request;
352
+ if (request) this.starts.set(request, performance.now());
353
+ }
354
+ responseFinished(message) {
355
+ const { request, response } = message;
356
+ if (!request) return;
357
+ const startedAt = this.starts.get(request);
358
+ this.starts.delete(request);
359
+ const ms = startedAt === void 0 ? 0 : performance.now() - startedAt;
360
+ this.recorder.record(normalizeMethod(request.method), routeOf(request), response?.statusCode ?? 0, ms);
361
+ this.recorded += 1;
362
+ }
363
+ /** Every hook runs through here: an agent bug must never reach the application. */
364
+ guard(fn) {
365
+ try {
366
+ fn();
367
+ } catch (err) {
368
+ this.internalError(err);
369
+ }
370
+ }
371
+ internalError(err) {
372
+ this.internalErrors += 1;
373
+ this.log.debug(`internal error: ${err instanceof Error ? err.stack ?? err.message : String(err)}`);
374
+ if (this.internalErrors >= MAX_INTERNAL_ERRORS && !this.disabled) {
375
+ this.disabled = true;
376
+ this.log.warn(`agent disabled after ${this.internalErrors} internal errors; your application is unaffected`);
377
+ this.stop();
378
+ }
379
+ }
380
+ /**
381
+ * If we are the only listener, flush briefly and then let the default signal
382
+ * behaviour happen exactly as if the agent were not installed. If the app has
383
+ * its own handlers, flush in the background and stay out of the way.
384
+ */
385
+ signalled(signal) {
386
+ const onlyUs = process.listenerCount(signal) === 1;
387
+ const flush = this.flushNow(SHUTDOWN_FLUSH_MS);
388
+ if (!onlyUs) return;
389
+ const resume = () => {
390
+ process.removeListener(signal, this.onSignal[signal]);
391
+ process.kill(process.pid, signal);
392
+ };
393
+ flush.then(resume, resume);
394
+ }
395
+ };
396
+ function createAgent(config, deps = {}) {
397
+ return new Agent(config, deps);
398
+ }
399
+ //#endregion
400
+ //#region src/config.ts
401
+ const DEFAULT_INTERVAL_MS = 1e4;
402
+ const MIN_INTERVAL_MS = 1e3;
403
+ /** Env vars commonly set by deploy platforms, in order of preference, used when DOWNTRACE_VERSION is absent. */
404
+ const VERSION_ENV_VARS = [
405
+ "DOWNTRACE_VERSION",
406
+ "APP_VERSION",
407
+ "GIT_SHA",
408
+ "VERCEL_GIT_COMMIT_SHA",
409
+ "HEROKU_SLUG_COMMIT",
410
+ "SOURCE_VERSION",
411
+ "RENDER_GIT_COMMIT",
412
+ "RAILWAY_GIT_COMMIT_SHA"
413
+ ];
414
+ function configFromEnv(env = process.env) {
415
+ const token = env.DOWNTRACE_TOKEN?.trim() ?? "";
416
+ const rawUrl = env.DOWNTRACE_URL?.trim() ?? "";
417
+ if (token === "" && rawUrl === "") return {
418
+ ok: false,
419
+ reason: "DOWNTRACE_TOKEN and DOWNTRACE_URL are not set"
420
+ };
421
+ if (token === "") return {
422
+ ok: false,
423
+ reason: "DOWNTRACE_TOKEN is not set"
424
+ };
425
+ if (rawUrl === "") return {
426
+ ok: false,
427
+ reason: "DOWNTRACE_URL is not set"
428
+ };
429
+ if (!/^https?:\/\//.test(rawUrl)) return {
430
+ ok: false,
431
+ reason: "DOWNTRACE_URL must start with http:// or https://"
432
+ };
433
+ const interval = Number(env.DOWNTRACE_INTERVAL_MS);
434
+ return {
435
+ ok: true,
436
+ config: {
437
+ token,
438
+ url: rawUrl.replace(/\/+$/, ""),
439
+ environment: clamp(env.DOWNTRACE_ENV ?? env.NODE_ENV ?? "production", 64),
440
+ version: detectVersion(env),
441
+ debug: env.DOWNTRACE_DEBUG === "1" || env.DOWNTRACE_DEBUG === "true",
442
+ intervalMs: Number.isInteger(interval) && interval >= MIN_INTERVAL_MS ? interval : DEFAULT_INTERVAL_MS
443
+ }
444
+ };
445
+ }
446
+ function detectVersion(env) {
447
+ for (const name of VERSION_ENV_VARS) {
448
+ const v = env[name]?.trim();
449
+ if (v) return clamp(v, 128);
450
+ }
451
+ return "unknown";
452
+ }
453
+ function clamp(value, max) {
454
+ const v = value.trim();
455
+ return v.length > max ? v.slice(0, max) : v || "unknown";
456
+ }
457
+ //#endregion
458
+ export { createAgent as a, Sender as c, IntervalAggregator as d, OTHER_ROUTE as f, routeOf as h, Agent as i, createLogger as l, normalizeMethod as m, configFromEnv as n, AGENT_VERSION as o, heuristicTemplate as p, detectVersion as r, DEFAULT_MAX_QUEUED as s, DEFAULT_INTERVAL_MS as t, DEFAULT_MAX_ROUTES as u };
@@ -0,0 +1,176 @@
1
+ import { AgentInfo, DeployInfo, Endpoint, InstanceInfo, Interval } from "@downtrace/protocol";
2
+ //#region src/routes.d.ts
3
+ type Method = Endpoint["method"];
4
+ /** Route used when the per-interval cardinality cap is hit. */
5
+ declare const OTHER_ROUTE = "(other)";
6
+ declare function normalizeMethod(method: string | undefined): Method;
7
+ /** What we read off a request: Express sets `route`/`baseUrl`; plain Node gives us `url`. */
8
+ interface RouteSource {
9
+ url?: string | undefined;
10
+ route?: unknown;
11
+ baseUrl?: unknown;
12
+ }
13
+ /**
14
+ * Route template for a request. Prefers the framework's own template
15
+ * (`/products/:id` from Express); otherwise collapses identifier-looking path
16
+ * segments (numbers, UUIDs, long hex) into `:id`.
17
+ */
18
+ declare function routeOf(req: RouteSource): string;
19
+ declare function heuristicTemplate(url: string): string;
20
+ //#endregion
21
+ //#region src/aggregator.d.ts
22
+ interface Recorder {
23
+ record(method: Method, route: string, status: number, ms: number): void;
24
+ rotate(): Interval | null;
25
+ }
26
+ declare const DEFAULT_MAX_ROUTES = 500;
27
+ /**
28
+ * Aggregates finished requests for the current interval. Memory is bounded:
29
+ * at most `maxRoutes` distinct routes per interval, the rest fold into (other).
30
+ */
31
+ declare class IntervalAggregator implements Recorder {
32
+ private endpoints;
33
+ private distinctRoutes;
34
+ private start;
35
+ private readonly maxRoutes;
36
+ private readonly now;
37
+ constructor(maxRoutes?: number, now?: () => number);
38
+ get size(): number;
39
+ record(method: Method, route: string, status: number, ms: number): void;
40
+ /** Closes the current interval and starts a new one. Returns null when nothing was recorded. */
41
+ rotate(): Interval | null;
42
+ }
43
+ //#endregion
44
+ //#region src/config.d.ts
45
+ interface AgentConfig {
46
+ token: string;
47
+ /** Ingest base URL, without trailing slash. */
48
+ url: string;
49
+ environment: string;
50
+ version: string;
51
+ debug: boolean;
52
+ /** Aggregation interval; 10 s in production. */
53
+ intervalMs: number;
54
+ }
55
+ type ConfigResult = {
56
+ ok: true;
57
+ config: AgentConfig;
58
+ } | {
59
+ ok: false;
60
+ reason: string;
61
+ };
62
+ declare const DEFAULT_INTERVAL_MS = 10000;
63
+ declare function configFromEnv(env?: NodeJS.ProcessEnv): ConfigResult;
64
+ declare function detectVersion(env: NodeJS.ProcessEnv): string;
65
+ //#endregion
66
+ //#region src/log.d.ts
67
+ interface Logger {
68
+ warn(message: string): void;
69
+ debug(message: string): void;
70
+ }
71
+ /** Minimal stderr logger. `debug` lines only appear with DOWNTRACE_DEBUG. */
72
+ declare function createLogger(debug: boolean, write?: (line: string) => void): Logger;
73
+ //#endregion
74
+ //#region src/transport.d.ts
75
+ interface SenderOptions {
76
+ url: string;
77
+ token: string;
78
+ agent: AgentInfo;
79
+ instance: InstanceInfo;
80
+ deploy: DeployInfo;
81
+ log: Logger;
82
+ /** Intervals kept while the cloud is unreachable; the oldest is dropped beyond this. */
83
+ maxQueued?: number | undefined;
84
+ timeoutMs?: number | undefined;
85
+ fetchImpl?: typeof fetch | undefined;
86
+ }
87
+ declare const DEFAULT_MAX_QUEUED = 6;
88
+ /**
89
+ * Ships intervals to the cloud in batches. Never blocks, never grows without
90
+ * bound: failed batches stay queued (up to maxQueued) and ride the next flush.
91
+ */
92
+ declare class Sender {
93
+ private queue;
94
+ private inflight;
95
+ private warnedAuth;
96
+ sent: number;
97
+ failed: number;
98
+ dropped: number;
99
+ private readonly opts;
100
+ private readonly maxQueued;
101
+ private readonly timeoutMs;
102
+ private readonly fetchImpl;
103
+ constructor(opts: SenderOptions);
104
+ get pending(): number;
105
+ enqueue(interval: Interval): void;
106
+ /** Sends everything queued in one batch. Resolves true when the cloud accepted it. */
107
+ flush(timeoutMs?: number): Promise<boolean>;
108
+ }
109
+ //#endregion
110
+ //#region src/agent.d.ts
111
+ interface AgentDeps {
112
+ recorder?: Recorder | undefined;
113
+ sender?: Sender | undefined;
114
+ log?: Logger | undefined;
115
+ fetchImpl?: typeof fetch | undefined;
116
+ /** Flush on SIGTERM/SIGINT. Off in tests; on when loaded via register. */
117
+ handleSignals?: boolean | undefined;
118
+ }
119
+ interface AgentStats {
120
+ recorded: number;
121
+ internalErrors: number;
122
+ disabled: boolean;
123
+ sent: number;
124
+ failed: number;
125
+ dropped: number;
126
+ pending: number;
127
+ }
128
+ /**
129
+ * The Downtrace Node agent, v0: observes finished HTTP requests through
130
+ * diagnostics_channel, aggregates them per route and interval, and ships
131
+ * batches asynchronously. Nothing here runs synchronously against the cloud,
132
+ * nothing here can throw into the application, and memory is bounded.
133
+ */
134
+ declare class Agent {
135
+ readonly config: AgentConfig;
136
+ readonly instance: InstanceInfo;
137
+ private readonly log;
138
+ private readonly recorder;
139
+ private readonly sender;
140
+ private readonly handleSignals;
141
+ private readonly starts;
142
+ private timer;
143
+ private started;
144
+ private recorded;
145
+ private internalErrors;
146
+ private disabled;
147
+ private readonly onStart;
148
+ private readonly onFinish;
149
+ private readonly onSignal;
150
+ private readonly onBeforeExit;
151
+ constructor(config: AgentConfig, deps?: AgentDeps);
152
+ get stats(): AgentStats;
153
+ start(): void;
154
+ /** Unsubscribes and stops timers; attempts a last flush. Idempotent. */
155
+ stop(): Promise<void>;
156
+ /** Closes the current interval and sends everything queued. */
157
+ flushNow(timeoutMs?: number): Promise<boolean>;
158
+ private requestStarted;
159
+ private responseFinished;
160
+ /** Every hook runs through here: an agent bug must never reach the application. */
161
+ private guard;
162
+ private internalError;
163
+ /**
164
+ * If we are the only listener, flush briefly and then let the default signal
165
+ * behaviour happen exactly as if the agent were not installed. If the app has
166
+ * its own handlers, flush in the background and stay out of the way.
167
+ */
168
+ private signalled;
169
+ }
170
+ declare function createAgent(config: AgentConfig, deps?: AgentDeps): Agent;
171
+ //#endregion
172
+ //#region src/version.d.ts
173
+ /** Version of this agent build, from package.json. */
174
+ declare const AGENT_VERSION: string;
175
+ //#endregion
176
+ export { AGENT_VERSION, Agent, type AgentConfig, type AgentDeps, type AgentStats, type ConfigResult, DEFAULT_INTERVAL_MS, DEFAULT_MAX_QUEUED, DEFAULT_MAX_ROUTES, IntervalAggregator, type Logger, type Method, OTHER_ROUTE, type Recorder, Sender, type SenderOptions, configFromEnv, createAgent, createLogger, detectVersion, heuristicTemplate, normalizeMethod, routeOf };
package/dist/index.js ADDED
@@ -0,0 +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-nKU1VRvi.js";
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 };
@@ -0,0 +1 @@
1
+ export {}
@@ -0,0 +1,17 @@
1
+ import { a as createAgent, l as createLogger, n as configFromEnv } from "./config-nKU1VRvi.js";
2
+ //#region src/register.ts
3
+ /**
4
+ * Entry point users load with `node --import @downtrace/agent/register`.
5
+ *
6
+ * Reads DOWNTRACE_TOKEN / DOWNTRACE_URL (and friends) from the environment and
7
+ * starts the agent. Without them it says so once and does nothing else: an
8
+ * installed but unconfigured agent must never affect the application.
9
+ */
10
+ const result = configFromEnv();
11
+ if (result.ok) createAgent(result.config, {
12
+ log: createLogger(result.config.debug),
13
+ handleSignals: true
14
+ }).start();
15
+ else createLogger(false).warn(`agent disabled: ${result.reason}`);
16
+ //#endregion
17
+ export {};
package/package.json ADDED
@@ -0,0 +1,58 @@
1
+ {
2
+ "name": "@downtrace/agent",
3
+ "version": "0.1.0",
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
+ "type": "module",
6
+ "exports": {
7
+ ".": {
8
+ "types": "./dist/index.d.ts",
9
+ "default": "./dist/index.js"
10
+ },
11
+ "./register": {
12
+ "types": "./dist/register.d.ts",
13
+ "default": "./dist/register.js"
14
+ }
15
+ },
16
+ "files": [
17
+ "dist",
18
+ "README.md",
19
+ "LICENSE"
20
+ ],
21
+ "engines": {
22
+ "node": ">=20"
23
+ },
24
+ "dependencies": {
25
+ "@downtrace/protocol": "0.1.0"
26
+ },
27
+ "devDependencies": {
28
+ "@types/express": "^5.0.6",
29
+ "ajv": "^8.20.0",
30
+ "express": "^5.2.1"
31
+ },
32
+ "license": "MIT",
33
+ "repository": {
34
+ "type": "git",
35
+ "url": "git+https://github.com/RadW2020/downtrace-agent.git",
36
+ "directory": "packages/agent"
37
+ },
38
+ "homepage": "https://github.com/RadW2020/downtrace-agent#readme",
39
+ "keywords": [
40
+ "downtrace",
41
+ "observability",
42
+ "apm",
43
+ "agent",
44
+ "latency",
45
+ "regression",
46
+ "node"
47
+ ],
48
+ "publishConfig": {
49
+ "access": "public"
50
+ },
51
+ "scripts": {
52
+ "test": "vitest run",
53
+ "typecheck": "tsc -p tsconfig.json",
54
+ "build": "tsdown"
55
+ },
56
+ "main": "./dist/index.js",
57
+ "types": "./dist/index.d.ts"
58
+ }