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/README.md CHANGED
@@ -113,10 +113,10 @@ const myFunction = trace((ctx) => async () => {
113
113
  - **HTTP Routes** - OTLP receivers for traces/logs/metrics (JSON + protobuf)
114
114
  - **Exporters** - OpenTelemetry span/log exporters
115
115
 
116
- ### Widget (Preact)
116
+ ### Widget (Svelte 5)
117
117
 
118
118
  - **Shadow DOM** - Isolated styles, no conflicts with app CSS
119
- - **Preact Signals** - Reactive state management
119
+ - **Svelte 5 runes** - Reactive state via a signal shim that preserves a `.value` API
120
120
  - **Views** - Traces, Logs, Metrics, Errors, Resources, Service Map
121
121
 
122
122
  ## Features
@@ -152,12 +152,17 @@ AUTOTEL_DEVTOOLS_TITLE="My App" # Dashboard title (optional)
152
152
  ### CLI Options
153
153
 
154
154
  ```bash
155
+ npx autotel-devtools 4319 # port as a bare positional
155
156
  npx autotel-devtools --port 4319 --host 0.0.0.0
156
157
  ```
157
158
 
159
+ Arguments:
160
+
161
+ - `[port]` - Port to listen on, shorthand for `--port` (an explicit `--port` always wins)
162
+
158
163
  Options:
159
164
 
160
- - `--port, -p` - Port to listen on (default: 4318)
165
+ - `--port, -p` - Port to listen on (default: 4318). If the port is taken, the receiver walks forward to the next free port and prints a warning.
161
166
  - `--host, -H` - Host to bind to (default: 127.0.0.1)
162
167
  - `--title, -t` - Dashboard title
163
168
 
package/dist/cli.cjs CHANGED
@@ -365,12 +365,17 @@ var DevtoolsServer = class {
365
365
  limits;
366
366
  verbose;
367
367
  _port;
368
+ onData;
368
369
  constructor(options = {}) {
369
370
  this.limits = resolveTelemetryLimits(options);
370
371
  this.verbose = options.verbose ?? false;
371
372
  this._port = options.port ?? 4318;
373
+ this.onData = options.onData;
372
374
  this.httpServer = options.server ?? http.createServer();
373
375
  this.wss = new ws.WebSocketServer({ server: this.httpServer, path: options.path ?? "/ws" });
376
+ this.wss.on("error", (err) => {
377
+ if (this.httpServer.listening) throw err;
378
+ });
374
379
  this.wss.on("connection", (ws) => {
375
380
  this.clients.add(ws);
376
381
  this.log(`Client connected (${this.clients.size} total)`);
@@ -462,6 +467,12 @@ var DevtoolsServer = class {
462
467
  client.send(msg);
463
468
  }
464
469
  }
470
+ if (this.onData) {
471
+ try {
472
+ this.onData(data);
473
+ } catch {
474
+ }
475
+ }
465
476
  }
466
477
  log(message) {
467
478
  if (this.verbose) console.log(`[autotel-devtools] ${message}`);
@@ -515,7 +526,10 @@ function flattenAttributes(attrs) {
515
526
  }
516
527
  function nanoToMs(nano) {
517
528
  if (!nano) return 0;
518
- return Number(BigInt(nano) / 1000000n);
529
+ const ns = BigInt(nano);
530
+ const ms = ns / 1000000n;
531
+ const remNs = ns % 1000000n;
532
+ return Number(ms) + Number(remNs) / 1e6;
519
533
  }
520
534
  var SPAN_KIND_MAP = {
521
535
  0: "INTERNAL",
@@ -553,6 +567,7 @@ function parseOtlpTraces(payload) {
553
567
  const service = String(resourceAttrs["service.name"] || "unknown");
554
568
  const scopeSpans = rs.scopeSpans || [];
555
569
  for (const ss of scopeSpans) {
570
+ const scope = ss.scope?.name ? { name: ss.scope.name, version: ss.scope.version || void 0 } : void 0;
556
571
  for (const span of ss.spans || []) {
557
572
  const traceId = normalizeHexId(span.traceId);
558
573
  if (!traceId) continue;
@@ -577,7 +592,13 @@ function parseOtlpTraces(payload) {
577
592
  name: e.name || "",
578
593
  timestamp: nanoToMs(e.timeUnixNano),
579
594
  attributes: flattenAttributes(e.attributes)
580
- }))
595
+ })),
596
+ links: (span.links || []).map((l) => ({
597
+ traceId: normalizeHexId(l.traceId),
598
+ spanId: normalizeHexId(l.spanId),
599
+ attributes: flattenAttributes(l.attributes)
600
+ })),
601
+ scope
581
602
  };
582
603
  const existing = traceMap.get(traceId);
583
604
  if (existing) {
@@ -1016,43 +1037,79 @@ function attachDevtoolsRoutes(httpServer, devtools) {
1016
1037
  });
1017
1038
  }
1018
1039
  var LOOPBACK = /* @__PURE__ */ new Set(["localhost", "127.0.0.1", "::1"]);
1040
+ var DEFAULT_MAX_PORT_TRIES = 20;
1019
1041
  function formatAddress(host, port) {
1020
1042
  return host.includes(":") ? `[${host}]:${port}` : `${host}:${port}`;
1021
1043
  }
1022
1044
  function listenLoopbackDualStack(args) {
1023
- const { primary, port, host, attachSecondary } = args;
1045
+ const { primary, port, host, attachSecondary, maxTries } = args;
1046
+ const maxAttempts = Math.max(1, maxTries ?? DEFAULT_MAX_PORT_TRIES);
1024
1047
  let sibling;
1025
1048
  const ready = new Promise(
1026
- (resolve3) => {
1049
+ (resolve3, reject) => {
1027
1050
  const addresses = [];
1028
1051
  const warnings = [];
1029
1052
  const primaryHost = host === "localhost" ? "127.0.0.1" : host;
1030
- primary.listen(port, primaryHost, () => {
1053
+ let candidate = port;
1054
+ let attempt = 0;
1055
+ const bindFailed = (atPort, msg) => reject(
1056
+ new Error(`could not bind ${formatAddress(primaryHost, atPort)}: ${msg}`)
1057
+ );
1058
+ const onError = (e) => {
1059
+ if (e.code !== "EADDRINUSE") return bindFailed(candidate, e.message);
1060
+ if (++attempt >= maxAttempts) {
1061
+ reject(
1062
+ new Error(
1063
+ `could not bind ${formatAddress(primaryHost, port)}: ${maxAttempts} consecutive ports in use`
1064
+ )
1065
+ );
1066
+ return;
1067
+ }
1068
+ candidate++;
1069
+ listen();
1070
+ };
1071
+ const onListening = () => {
1072
+ primary.removeListener("error", onError);
1073
+ if (candidate !== port) {
1074
+ warnings.push(`port ${port} was busy; using ${candidate} instead`);
1075
+ }
1031
1076
  const addr = primary.address();
1032
- const resolvedPort = addr && typeof addr === "object" ? addr.port : port;
1077
+ const resolvedPort = addr && typeof addr === "object" ? addr.port : candidate;
1033
1078
  addresses.push(formatAddress(primaryHost, resolvedPort));
1034
1079
  if (!LOOPBACK.has(host)) {
1035
- resolve3({ addresses, warnings });
1080
+ resolve3({ addresses, port: resolvedPort, warnings });
1036
1081
  return;
1037
1082
  }
1038
1083
  const siblingHost = primaryHost === "::1" ? "127.0.0.1" : "::1";
1039
1084
  const s = http.createServer();
1040
1085
  attachSecondary(s);
1041
- const onError = (e) => {
1086
+ const onSiblingError = (se) => {
1042
1087
  s.close();
1043
1088
  warnings.push(
1044
- `could not also bind ${formatAddress(siblingHost, resolvedPort)} (${e.message}); clients using the ${siblingHost === "::1" ? "IPv6" : "IPv4"} form of "localhost" may not connect.`
1089
+ `could not also bind ${formatAddress(siblingHost, resolvedPort)} (${se.message}); clients using the ${siblingHost === "::1" ? "IPv6" : "IPv4"} form of "localhost" may not connect.`
1045
1090
  );
1046
- resolve3({ addresses, warnings });
1091
+ resolve3({ addresses, port: resolvedPort, warnings });
1047
1092
  };
1048
- s.once("error", onError);
1093
+ s.once("error", onSiblingError);
1049
1094
  s.listen(resolvedPort, siblingHost, () => {
1050
- s.off("error", onError);
1095
+ s.off("error", onSiblingError);
1051
1096
  sibling = s;
1052
1097
  addresses.push(formatAddress(siblingHost, resolvedPort));
1053
- resolve3({ addresses, warnings });
1098
+ resolve3({ addresses, port: resolvedPort, warnings });
1054
1099
  });
1055
- });
1100
+ };
1101
+ const listen = () => {
1102
+ try {
1103
+ primary.listen(candidate, primaryHost);
1104
+ } catch (e) {
1105
+ primary.removeListener("error", onError);
1106
+ primary.removeListener("listening", onListening);
1107
+ bindFailed(candidate, e.message);
1108
+ }
1109
+ };
1110
+ primary.on("error", onError);
1111
+ primary.once("listening", onListening);
1112
+ listen();
1056
1113
  }
1057
1114
  );
1058
1115
  return {
@@ -1069,10 +1126,14 @@ function printHelp() {
1069
1126
  process.stdout.write(
1070
1127
  `autotel-devtools - Standalone OTLP receiver with web devtools UI
1071
1128
 
1072
- Usage: autotel-devtools [options]
1129
+ Usage: autotel-devtools [port] [options]
1130
+
1131
+ Arguments:
1132
+ port Port to listen on (shorthand for --port; must be a positive integer)
1073
1133
 
1074
1134
  Options:
1075
- -p, --port <port> Port to listen on (default: 4318, env: AUTOTEL_DEVTOOLS_PORT)
1135
+ -p, --port <port> Port to listen on (default: 4318, env: AUTOTEL_DEVTOOLS_PORT).
1136
+ If the port is taken, the next free port is used and a warning is shown.
1076
1137
  -H, --host <host> Host to bind to (default: 127.0.0.1, env: AUTOTEL_DEVTOOLS_HOST)
1077
1138
  -t, --title <title> Dashboard title (env: AUTOTEL_DEVTOOLS_TITLE)
1078
1139
  Env limits: AUTOTEL_MAX_TRACE_COUNT, AUTOTEL_MAX_LOG_COUNT, AUTOTEL_MAX_METRIC_COUNT
@@ -1092,7 +1153,8 @@ Endpoints:
1092
1153
 
1093
1154
  Examples:
1094
1155
  npx autotel-devtools
1095
- npx autotel-devtools -p 4319
1156
+ npx autotel-devtools 4319
1157
+ npx autotel-devtools -p 4319 -H 0.0.0.0
1096
1158
 
1097
1159
  Then point your app:
1098
1160
  OTEL_EXPORTER_OTLP_PROTOCOL=http/json OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318 node app.js
@@ -1119,10 +1181,12 @@ function printVersion() {
1119
1181
  }
1120
1182
  function parseArgs(argv) {
1121
1183
  const options = {
1122
- port: Number(process.env.AUTOTEL_DEVTOOLS_PORT || 4318),
1184
+ port: parsePort(process.env.AUTOTEL_DEVTOOLS_PORT || "4318"),
1123
1185
  host: process.env.AUTOTEL_DEVTOOLS_HOST || "127.0.0.1",
1124
1186
  title: process.env.AUTOTEL_DEVTOOLS_TITLE
1125
1187
  };
1188
+ let portWasExplicit = false;
1189
+ let positionalPortConsumed = false;
1126
1190
  for (let i = 0; i < argv.length; i++) {
1127
1191
  const arg = argv[i];
1128
1192
  const next = argv[i + 1];
@@ -1135,7 +1199,8 @@ function parseArgs(argv) {
1135
1199
  return null;
1136
1200
  }
1137
1201
  if ((arg === "--port" || arg === "-p") && next) {
1138
- options.port = Number(next);
1202
+ options.port = parsePort(next);
1203
+ portWasExplicit = true;
1139
1204
  i++;
1140
1205
  continue;
1141
1206
  }
@@ -1149,9 +1214,23 @@ function parseArgs(argv) {
1149
1214
  i++;
1150
1215
  continue;
1151
1216
  }
1217
+ if (/^\d+$/.test(arg) && !positionalPortConsumed) {
1218
+ if (!portWasExplicit) options.port = parsePort(arg);
1219
+ positionalPortConsumed = true;
1220
+ continue;
1221
+ }
1152
1222
  }
1153
1223
  return options;
1154
1224
  }
1225
+ function parsePort(value) {
1226
+ const n = Number(value);
1227
+ if (!Number.isInteger(n) || n < 0 || n > 65535) {
1228
+ process.stderr.write(`[autotel-devtools] invalid port: ${value}
1229
+ `);
1230
+ process.exit(2);
1231
+ }
1232
+ return n;
1233
+ }
1155
1234
  async function main() {
1156
1235
  const options = parseArgs(process.argv.slice(2));
1157
1236
  if (!options) {
@@ -1166,8 +1245,8 @@ async function main() {
1166
1245
  host: options.host,
1167
1246
  attachSecondary: (s) => attachDevtoolsRoutes(s, wsServer)
1168
1247
  });
1169
- const { addresses, warnings } = await listeners.ready;
1170
- const uiBase = `http://${options.host === "localhost" ? "127.0.0.1" : options.host}:${options.port}`;
1248
+ const { addresses, warnings, port: boundPort } = await listeners.ready;
1249
+ const uiBase = `http://${options.host === "localhost" ? "127.0.0.1" : options.host}:${boundPort}`;
1171
1250
  const title = options.title || "autotel-devtools";
1172
1251
  process.stdout.write(`
1173
1252
  ${title}
@@ -1175,18 +1254,34 @@ async function main() {
1175
1254
  `);
1176
1255
  process.stdout.write(` Listening: ${addresses.join(" + ")}
1177
1256
  `);
1178
- process.stdout.write(` UI: ${uiBase}
1257
+ process.stdout.write(` UI: ${uiBase} (open in a browser)
1179
1258
  `);
1180
- process.stdout.write(` Widget: <script src="${uiBase}/widget.js"></script>
1259
+ process.stdout.write(` OTLP: ${uiBase}/v1/traces
1181
1260
  `);
1182
1261
  process.stdout.write(` WebSocket: ${uiBase.replace("http", "ws")}/ws
1262
+
1183
1263
  `);
1184
- process.stdout.write(` OTLP: ${uiBase}/v1/traces
1264
+ process.stdout.write(
1265
+ ` Embed in your app \u2014 paste into your HTML; a floating panel appears automatically:
1266
+ `
1267
+ );
1268
+ process.stdout.write(` <script src="${uiBase}/widget.js"></script>
1185
1269
 
1186
1270
  `);
1187
- process.stdout.write(` Set OTEL_EXPORTER_OTLP_PROTOCOL=http/json
1271
+ process.stdout.write(
1272
+ ` full screen instead: <script src="${uiBase}/widget.js?mode=fullpage"></script>
1273
+ `
1274
+ );
1275
+ process.stdout.write(
1276
+ ` choose where it goes: add <autotel-devtools></autotel-devtools> to your markup
1277
+
1278
+ `
1279
+ );
1280
+ process.stdout.write(` Or point any OTLP exporter at this receiver:
1281
+ `);
1282
+ process.stdout.write(` OTEL_EXPORTER_OTLP_PROTOCOL=http/json
1188
1283
  `);
1189
- process.stdout.write(` Set OTEL_EXPORTER_OTLP_ENDPOINT=${uiBase}
1284
+ process.stdout.write(` OTEL_EXPORTER_OTLP_ENDPOINT=${uiBase}
1190
1285
 
1191
1286
  `);
1192
1287
  process.stdout.write(` Verify ingestion: curl -s ${uiBase}/v1/traces