autotel-devtools 5.1.0 → 6.0.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/dist/cli.js CHANGED
@@ -358,12 +358,17 @@ var DevtoolsServer = class {
358
358
  limits;
359
359
  verbose;
360
360
  _port;
361
+ onData;
361
362
  constructor(options = {}) {
362
363
  this.limits = resolveTelemetryLimits(options);
363
364
  this.verbose = options.verbose ?? false;
364
365
  this._port = options.port ?? 4318;
366
+ this.onData = options.onData;
365
367
  this.httpServer = options.server ?? createServer();
366
368
  this.wss = new WebSocketServer({ server: this.httpServer, path: options.path ?? "/ws" });
369
+ this.wss.on("error", (err) => {
370
+ if (this.httpServer.listening) throw err;
371
+ });
367
372
  this.wss.on("connection", (ws) => {
368
373
  this.clients.add(ws);
369
374
  this.log(`Client connected (${this.clients.size} total)`);
@@ -455,6 +460,12 @@ var DevtoolsServer = class {
455
460
  client.send(msg);
456
461
  }
457
462
  }
463
+ if (this.onData) {
464
+ try {
465
+ this.onData(data);
466
+ } catch {
467
+ }
468
+ }
458
469
  }
459
470
  log(message) {
460
471
  if (this.verbose) console.log(`[autotel-devtools] ${message}`);
@@ -508,7 +519,10 @@ function flattenAttributes(attrs) {
508
519
  }
509
520
  function nanoToMs(nano) {
510
521
  if (!nano) return 0;
511
- return Number(BigInt(nano) / 1000000n);
522
+ const ns = BigInt(nano);
523
+ const ms = ns / 1000000n;
524
+ const remNs = ns % 1000000n;
525
+ return Number(ms) + Number(remNs) / 1e6;
512
526
  }
513
527
  var SPAN_KIND_MAP = {
514
528
  0: "INTERNAL",
@@ -546,6 +560,7 @@ function parseOtlpTraces(payload) {
546
560
  const service = String(resourceAttrs["service.name"] || "unknown");
547
561
  const scopeSpans = rs.scopeSpans || [];
548
562
  for (const ss of scopeSpans) {
563
+ const scope = ss.scope?.name ? { name: ss.scope.name, version: ss.scope.version || void 0 } : void 0;
549
564
  for (const span of ss.spans || []) {
550
565
  const traceId = normalizeHexId(span.traceId);
551
566
  if (!traceId) continue;
@@ -570,7 +585,13 @@ function parseOtlpTraces(payload) {
570
585
  name: e.name || "",
571
586
  timestamp: nanoToMs(e.timeUnixNano),
572
587
  attributes: flattenAttributes(e.attributes)
573
- }))
588
+ })),
589
+ links: (span.links || []).map((l) => ({
590
+ traceId: normalizeHexId(l.traceId),
591
+ spanId: normalizeHexId(l.spanId),
592
+ attributes: flattenAttributes(l.attributes)
593
+ })),
594
+ scope
574
595
  };
575
596
  const existing = traceMap.get(traceId);
576
597
  if (existing) {
@@ -1009,43 +1030,79 @@ function attachDevtoolsRoutes(httpServer, devtools) {
1009
1030
  });
1010
1031
  }
1011
1032
  var LOOPBACK = /* @__PURE__ */ new Set(["localhost", "127.0.0.1", "::1"]);
1033
+ var DEFAULT_MAX_PORT_TRIES = 20;
1012
1034
  function formatAddress(host, port) {
1013
1035
  return host.includes(":") ? `[${host}]:${port}` : `${host}:${port}`;
1014
1036
  }
1015
1037
  function listenLoopbackDualStack(args) {
1016
- const { primary, port, host, attachSecondary } = args;
1038
+ const { primary, port, host, attachSecondary, maxTries } = args;
1039
+ const maxAttempts = Math.max(1, maxTries ?? DEFAULT_MAX_PORT_TRIES);
1017
1040
  let sibling;
1018
1041
  const ready = new Promise(
1019
- (resolve3) => {
1042
+ (resolve3, reject) => {
1020
1043
  const addresses = [];
1021
1044
  const warnings = [];
1022
1045
  const primaryHost = host === "localhost" ? "127.0.0.1" : host;
1023
- primary.listen(port, primaryHost, () => {
1046
+ let candidate = port;
1047
+ let attempt = 0;
1048
+ const bindFailed = (atPort, msg) => reject(
1049
+ new Error(`could not bind ${formatAddress(primaryHost, atPort)}: ${msg}`)
1050
+ );
1051
+ const onError = (e) => {
1052
+ if (e.code !== "EADDRINUSE") return bindFailed(candidate, e.message);
1053
+ if (++attempt >= maxAttempts) {
1054
+ reject(
1055
+ new Error(
1056
+ `could not bind ${formatAddress(primaryHost, port)}: ${maxAttempts} consecutive ports in use`
1057
+ )
1058
+ );
1059
+ return;
1060
+ }
1061
+ candidate++;
1062
+ listen();
1063
+ };
1064
+ const onListening = () => {
1065
+ primary.removeListener("error", onError);
1066
+ if (candidate !== port) {
1067
+ warnings.push(`port ${port} was busy; using ${candidate} instead`);
1068
+ }
1024
1069
  const addr = primary.address();
1025
- const resolvedPort = addr && typeof addr === "object" ? addr.port : port;
1070
+ const resolvedPort = addr && typeof addr === "object" ? addr.port : candidate;
1026
1071
  addresses.push(formatAddress(primaryHost, resolvedPort));
1027
1072
  if (!LOOPBACK.has(host)) {
1028
- resolve3({ addresses, warnings });
1073
+ resolve3({ addresses, port: resolvedPort, warnings });
1029
1074
  return;
1030
1075
  }
1031
1076
  const siblingHost = primaryHost === "::1" ? "127.0.0.1" : "::1";
1032
1077
  const s = createServer();
1033
1078
  attachSecondary(s);
1034
- const onError = (e) => {
1079
+ const onSiblingError = (se) => {
1035
1080
  s.close();
1036
1081
  warnings.push(
1037
- `could not also bind ${formatAddress(siblingHost, resolvedPort)} (${e.message}); clients using the ${siblingHost === "::1" ? "IPv6" : "IPv4"} form of "localhost" may not connect.`
1082
+ `could not also bind ${formatAddress(siblingHost, resolvedPort)} (${se.message}); clients using the ${siblingHost === "::1" ? "IPv6" : "IPv4"} form of "localhost" may not connect.`
1038
1083
  );
1039
- resolve3({ addresses, warnings });
1084
+ resolve3({ addresses, port: resolvedPort, warnings });
1040
1085
  };
1041
- s.once("error", onError);
1086
+ s.once("error", onSiblingError);
1042
1087
  s.listen(resolvedPort, siblingHost, () => {
1043
- s.off("error", onError);
1088
+ s.off("error", onSiblingError);
1044
1089
  sibling = s;
1045
1090
  addresses.push(formatAddress(siblingHost, resolvedPort));
1046
- resolve3({ addresses, warnings });
1091
+ resolve3({ addresses, port: resolvedPort, warnings });
1047
1092
  });
1048
- });
1093
+ };
1094
+ const listen = () => {
1095
+ try {
1096
+ primary.listen(candidate, primaryHost);
1097
+ } catch (e) {
1098
+ primary.removeListener("error", onError);
1099
+ primary.removeListener("listening", onListening);
1100
+ bindFailed(candidate, e.message);
1101
+ }
1102
+ };
1103
+ primary.on("error", onError);
1104
+ primary.once("listening", onListening);
1105
+ listen();
1049
1106
  }
1050
1107
  );
1051
1108
  return {
@@ -1062,10 +1119,14 @@ function printHelp() {
1062
1119
  process.stdout.write(
1063
1120
  `autotel-devtools - Standalone OTLP receiver with web devtools UI
1064
1121
 
1065
- Usage: autotel-devtools [options]
1122
+ Usage: autotel-devtools [port] [options]
1123
+
1124
+ Arguments:
1125
+ port Port to listen on (shorthand for --port; must be a positive integer)
1066
1126
 
1067
1127
  Options:
1068
- -p, --port <port> Port to listen on (default: 4318, env: AUTOTEL_DEVTOOLS_PORT)
1128
+ -p, --port <port> Port to listen on (default: 4318, env: AUTOTEL_DEVTOOLS_PORT).
1129
+ If the port is taken, the next free port is used and a warning is shown.
1069
1130
  -H, --host <host> Host to bind to (default: 127.0.0.1, env: AUTOTEL_DEVTOOLS_HOST)
1070
1131
  -t, --title <title> Dashboard title (env: AUTOTEL_DEVTOOLS_TITLE)
1071
1132
  Env limits: AUTOTEL_MAX_TRACE_COUNT, AUTOTEL_MAX_LOG_COUNT, AUTOTEL_MAX_METRIC_COUNT
@@ -1085,7 +1146,8 @@ Endpoints:
1085
1146
 
1086
1147
  Examples:
1087
1148
  npx autotel-devtools
1088
- npx autotel-devtools -p 4319
1149
+ npx autotel-devtools 4319
1150
+ npx autotel-devtools -p 4319 -H 0.0.0.0
1089
1151
 
1090
1152
  Then point your app:
1091
1153
  OTEL_EXPORTER_OTLP_PROTOCOL=http/json OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318 node app.js
@@ -1112,10 +1174,12 @@ function printVersion() {
1112
1174
  }
1113
1175
  function parseArgs(argv) {
1114
1176
  const options = {
1115
- port: Number(process.env.AUTOTEL_DEVTOOLS_PORT || 4318),
1177
+ port: parsePort(process.env.AUTOTEL_DEVTOOLS_PORT || "4318"),
1116
1178
  host: process.env.AUTOTEL_DEVTOOLS_HOST || "127.0.0.1",
1117
1179
  title: process.env.AUTOTEL_DEVTOOLS_TITLE
1118
1180
  };
1181
+ let portWasExplicit = false;
1182
+ let positionalPortConsumed = false;
1119
1183
  for (let i = 0; i < argv.length; i++) {
1120
1184
  const arg = argv[i];
1121
1185
  const next = argv[i + 1];
@@ -1128,7 +1192,8 @@ function parseArgs(argv) {
1128
1192
  return null;
1129
1193
  }
1130
1194
  if ((arg === "--port" || arg === "-p") && next) {
1131
- options.port = Number(next);
1195
+ options.port = parsePort(next);
1196
+ portWasExplicit = true;
1132
1197
  i++;
1133
1198
  continue;
1134
1199
  }
@@ -1142,9 +1207,23 @@ function parseArgs(argv) {
1142
1207
  i++;
1143
1208
  continue;
1144
1209
  }
1210
+ if (/^\d+$/.test(arg) && !positionalPortConsumed) {
1211
+ if (!portWasExplicit) options.port = parsePort(arg);
1212
+ positionalPortConsumed = true;
1213
+ continue;
1214
+ }
1145
1215
  }
1146
1216
  return options;
1147
1217
  }
1218
+ function parsePort(value) {
1219
+ const n = Number(value);
1220
+ if (!Number.isInteger(n) || n < 0 || n > 65535) {
1221
+ process.stderr.write(`[autotel-devtools] invalid port: ${value}
1222
+ `);
1223
+ process.exit(2);
1224
+ }
1225
+ return n;
1226
+ }
1148
1227
  async function main() {
1149
1228
  const options = parseArgs(process.argv.slice(2));
1150
1229
  if (!options) {
@@ -1159,8 +1238,8 @@ async function main() {
1159
1238
  host: options.host,
1160
1239
  attachSecondary: (s) => attachDevtoolsRoutes(s, wsServer)
1161
1240
  });
1162
- const { addresses, warnings } = await listeners.ready;
1163
- const uiBase = `http://${options.host === "localhost" ? "127.0.0.1" : options.host}:${options.port}`;
1241
+ const { addresses, warnings, port: boundPort } = await listeners.ready;
1242
+ const uiBase = `http://${options.host === "localhost" ? "127.0.0.1" : options.host}:${boundPort}`;
1164
1243
  const title = options.title || "autotel-devtools";
1165
1244
  process.stdout.write(`
1166
1245
  ${title}
@@ -1168,18 +1247,34 @@ async function main() {
1168
1247
  `);
1169
1248
  process.stdout.write(` Listening: ${addresses.join(" + ")}
1170
1249
  `);
1171
- process.stdout.write(` UI: ${uiBase}
1250
+ process.stdout.write(` UI: ${uiBase} (open in a browser)
1172
1251
  `);
1173
- process.stdout.write(` Widget: <script src="${uiBase}/widget.js"></script>
1252
+ process.stdout.write(` OTLP: ${uiBase}/v1/traces
1174
1253
  `);
1175
1254
  process.stdout.write(` WebSocket: ${uiBase.replace("http", "ws")}/ws
1255
+
1176
1256
  `);
1177
- process.stdout.write(` OTLP: ${uiBase}/v1/traces
1257
+ process.stdout.write(
1258
+ ` Embed in your app \u2014 paste into your HTML; a floating panel appears automatically:
1259
+ `
1260
+ );
1261
+ process.stdout.write(` <script src="${uiBase}/widget.js"></script>
1178
1262
 
1179
1263
  `);
1180
- process.stdout.write(` Set OTEL_EXPORTER_OTLP_PROTOCOL=http/json
1264
+ process.stdout.write(
1265
+ ` full screen instead: <script src="${uiBase}/widget.js?mode=fullpage"></script>
1266
+ `
1267
+ );
1268
+ process.stdout.write(
1269
+ ` choose where it goes: add <autotel-devtools></autotel-devtools> to your markup
1270
+
1271
+ `
1272
+ );
1273
+ process.stdout.write(` Or point any OTLP exporter at this receiver:
1274
+ `);
1275
+ process.stdout.write(` OTEL_EXPORTER_OTLP_PROTOCOL=http/json
1181
1276
  `);
1182
- process.stdout.write(` Set OTEL_EXPORTER_OTLP_ENDPOINT=${uiBase}
1277
+ process.stdout.write(` OTEL_EXPORTER_OTLP_ENDPOINT=${uiBase}
1183
1278
 
1184
1279
  `);
1185
1280
  process.stdout.write(` Verify ingestion: curl -s ${uiBase}/v1/traces