@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 +21 -0
- package/README.md +49 -0
- package/dist/config-nKU1VRvi.js +458 -0
- package/dist/index.d.ts +176 -0
- package/dist/index.js +2 -0
- package/dist/register.d.ts +1 -0
- package/dist/register.js +17 -0
- package/package.json +58 -0
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 };
|
package/dist/index.d.ts
ADDED
|
@@ -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 {}
|
package/dist/register.js
ADDED
|
@@ -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
|
+
}
|