autotel-devtools 6.0.0 → 6.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/dist/cli.js CHANGED
@@ -366,6 +366,9 @@ var DevtoolsServer = class {
366
366
  this.onData = options.onData;
367
367
  this.httpServer = options.server ?? createServer();
368
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
+ });
369
372
  this.wss.on("connection", (ws) => {
370
373
  this.clients.add(ws);
371
374
  this.log(`Client connected (${this.clients.size} total)`);
@@ -913,7 +916,38 @@ function decodeOtlpMetricsRequest(body) {
913
916
  return decodeRequest("opentelemetry.proto.metrics.v1.ExportMetricsServiceRequest", body);
914
917
  }
915
918
 
919
+ // src/server/identity.ts
920
+ var DEVTOOLS_IDENTITY = "autotel-devtools";
921
+ async function probePortHolder(host, port, timeoutMs = 500) {
922
+ const authority = host.includes(":") ? `[${host}]` : host;
923
+ const controller = new AbortController();
924
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
925
+ try {
926
+ const res = await fetch(`http://${authority}:${port}/healthz`, {
927
+ signal: controller.signal
928
+ });
929
+ if (res.headers.get("x-autotel-devtools")) return "autotel-devtools";
930
+ try {
931
+ const body = await res.json();
932
+ if (body && body.service === DEVTOOLS_IDENTITY) return "autotel-devtools";
933
+ } catch {
934
+ }
935
+ return "foreign";
936
+ } catch {
937
+ return "none";
938
+ } finally {
939
+ clearTimeout(timer);
940
+ }
941
+ }
942
+
916
943
  // src/server/http.ts
944
+ function sendOtlpError(res, req, e) {
945
+ sendJson(res, 400, {
946
+ error: "Invalid OTLP payload",
947
+ message: e instanceof Error ? e.message : String(e),
948
+ contentType: req.headers["content-type"] ?? null
949
+ });
950
+ }
917
951
  var PROTOBUF_DECODERS = {
918
952
  traces: decodeOtlpTraceRequest,
919
953
  logs: decodeOtlpLogsRequest,
@@ -934,6 +968,18 @@ function findPackageRoot() {
934
968
  return dir;
935
969
  }
936
970
  var FULLPAGE_HTML = `<!DOCTYPE html><html><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"><title>autotel-devtools</title><style>*{margin:0;padding:0;box-sizing:border-box}html,body{height:100%;width:100%;overflow:hidden}</style></head><body><script src="/widget.js?mode=fullpage"></script></body></html>`;
971
+ var cachedVersion = null;
972
+ function getVersion() {
973
+ if (cachedVersion !== null) return cachedVersion;
974
+ let version = "unknown";
975
+ try {
976
+ const pkg = JSON.parse(readFileSync(resolve(findPackageRoot(), "package.json"), "utf8"));
977
+ if (typeof pkg.version === "string") version = pkg.version;
978
+ } catch {
979
+ }
980
+ cachedVersion = version;
981
+ return version;
982
+ }
937
983
  var cachedWidgetJs = null;
938
984
  function getWidgetJs() {
939
985
  if (!cachedWidgetJs) {
@@ -960,6 +1006,8 @@ function attachDevtoolsRoutes(httpServer, devtools) {
960
1006
  res.setHeader("Access-Control-Allow-Origin", "*");
961
1007
  res.setHeader("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS");
962
1008
  res.setHeader("Access-Control-Allow-Headers", "Content-Type");
1009
+ res.setHeader("x-autotel-devtools", getVersion());
1010
+ res.setHeader("Access-Control-Expose-Headers", "x-autotel-devtools");
963
1011
  if (req.method === "OPTIONS") {
964
1012
  res.writeHead(204);
965
1013
  res.end();
@@ -978,7 +1026,12 @@ function attachDevtoolsRoutes(httpServer, devtools) {
978
1026
  return;
979
1027
  }
980
1028
  if (req.method === "GET" && url === "/healthz") {
981
- sendJson(res, 200, { ok: true, clients: devtools.clientCount });
1029
+ sendJson(res, 200, {
1030
+ ok: true,
1031
+ service: DEVTOOLS_IDENTITY,
1032
+ version: getVersion(),
1033
+ clients: devtools.clientCount
1034
+ });
982
1035
  return;
983
1036
  }
984
1037
  if (req.method === "GET" && url === "/v1/traces") {
@@ -998,7 +1051,7 @@ function attachDevtoolsRoutes(httpServer, devtools) {
998
1051
  devtools.addTraces(traces);
999
1052
  sendJson(res, 200, { acceptedTraces: traces.length });
1000
1053
  } catch (e) {
1001
- sendJson(res, 400, { error: "Invalid OTLP payload", message: e instanceof Error ? e.message : String(e) });
1054
+ sendOtlpError(res, req, e);
1002
1055
  }
1003
1056
  return;
1004
1057
  }
@@ -1009,7 +1062,7 @@ function attachDevtoolsRoutes(httpServer, devtools) {
1009
1062
  devtools.addLogs(logs);
1010
1063
  sendJson(res, 200, { acceptedLogs: logs.length });
1011
1064
  } catch (e) {
1012
- sendJson(res, 400, { error: "Invalid OTLP payload", message: e instanceof Error ? e.message : String(e) });
1065
+ sendOtlpError(res, req, e);
1013
1066
  }
1014
1067
  return;
1015
1068
  }
@@ -1019,7 +1072,7 @@ function attachDevtoolsRoutes(httpServer, devtools) {
1019
1072
  const count = countOtlpMetrics(payload);
1020
1073
  sendJson(res, 200, { acceptedMetrics: count });
1021
1074
  } catch (e) {
1022
- sendJson(res, 400, { error: "Invalid OTLP payload", message: e instanceof Error ? e.message : String(e) });
1075
+ sendOtlpError(res, req, e);
1023
1076
  }
1024
1077
  return;
1025
1078
  }
@@ -1027,43 +1080,79 @@ function attachDevtoolsRoutes(httpServer, devtools) {
1027
1080
  });
1028
1081
  }
1029
1082
  var LOOPBACK = /* @__PURE__ */ new Set(["localhost", "127.0.0.1", "::1"]);
1083
+ var DEFAULT_MAX_PORT_TRIES = 20;
1030
1084
  function formatAddress(host, port) {
1031
1085
  return host.includes(":") ? `[${host}]:${port}` : `${host}:${port}`;
1032
1086
  }
1033
1087
  function listenLoopbackDualStack(args) {
1034
- const { primary, port, host, attachSecondary } = args;
1088
+ const { primary, port, host, attachSecondary, maxTries } = args;
1089
+ const maxAttempts = Math.max(1, maxTries ?? DEFAULT_MAX_PORT_TRIES);
1035
1090
  let sibling;
1036
1091
  const ready = new Promise(
1037
- (resolve3) => {
1092
+ (resolve3, reject) => {
1038
1093
  const addresses = [];
1039
1094
  const warnings = [];
1040
1095
  const primaryHost = host === "localhost" ? "127.0.0.1" : host;
1041
- primary.listen(port, primaryHost, () => {
1096
+ let candidate = port;
1097
+ let attempt = 0;
1098
+ const bindFailed = (atPort, msg) => reject(
1099
+ new Error(`could not bind ${formatAddress(primaryHost, atPort)}: ${msg}`)
1100
+ );
1101
+ const onError = (e) => {
1102
+ if (e.code !== "EADDRINUSE") return bindFailed(candidate, e.message);
1103
+ if (++attempt >= maxAttempts) {
1104
+ reject(
1105
+ new Error(
1106
+ `could not bind ${formatAddress(primaryHost, port)}: ${maxAttempts} consecutive ports in use`
1107
+ )
1108
+ );
1109
+ return;
1110
+ }
1111
+ candidate++;
1112
+ listen();
1113
+ };
1114
+ const onListening = () => {
1115
+ primary.removeListener("error", onError);
1116
+ if (candidate !== port) {
1117
+ warnings.push(`port ${port} was busy; using ${candidate} instead`);
1118
+ }
1042
1119
  const addr = primary.address();
1043
- const resolvedPort = addr && typeof addr === "object" ? addr.port : port;
1120
+ const resolvedPort = addr && typeof addr === "object" ? addr.port : candidate;
1044
1121
  addresses.push(formatAddress(primaryHost, resolvedPort));
1045
1122
  if (!LOOPBACK.has(host)) {
1046
- resolve3({ addresses, warnings });
1123
+ resolve3({ addresses, port: resolvedPort, warnings });
1047
1124
  return;
1048
1125
  }
1049
1126
  const siblingHost = primaryHost === "::1" ? "127.0.0.1" : "::1";
1050
1127
  const s = createServer();
1051
1128
  attachSecondary(s);
1052
- const onError = (e) => {
1129
+ const onSiblingError = (se) => {
1053
1130
  s.close();
1054
1131
  warnings.push(
1055
- `could not also bind ${formatAddress(siblingHost, resolvedPort)} (${e.message}); clients using the ${siblingHost === "::1" ? "IPv6" : "IPv4"} form of "localhost" may not connect.`
1132
+ `could not also bind ${formatAddress(siblingHost, resolvedPort)} (${se.message}); clients using the ${siblingHost === "::1" ? "IPv6" : "IPv4"} form of "localhost" may not connect.`
1056
1133
  );
1057
- resolve3({ addresses, warnings });
1134
+ resolve3({ addresses, port: resolvedPort, warnings });
1058
1135
  };
1059
- s.once("error", onError);
1136
+ s.once("error", onSiblingError);
1060
1137
  s.listen(resolvedPort, siblingHost, () => {
1061
- s.off("error", onError);
1138
+ s.off("error", onSiblingError);
1062
1139
  sibling = s;
1063
1140
  addresses.push(formatAddress(siblingHost, resolvedPort));
1064
- resolve3({ addresses, warnings });
1141
+ resolve3({ addresses, port: resolvedPort, warnings });
1065
1142
  });
1066
- });
1143
+ };
1144
+ const listen = () => {
1145
+ try {
1146
+ primary.listen(candidate, primaryHost);
1147
+ } catch (e) {
1148
+ primary.removeListener("error", onError);
1149
+ primary.removeListener("listening", onListening);
1150
+ bindFailed(candidate, e.message);
1151
+ }
1152
+ };
1153
+ primary.on("error", onError);
1154
+ primary.once("listening", onListening);
1155
+ listen();
1067
1156
  }
1068
1157
  );
1069
1158
  return {
@@ -1080,10 +1169,14 @@ function printHelp() {
1080
1169
  process.stdout.write(
1081
1170
  `autotel-devtools - Standalone OTLP receiver with web devtools UI
1082
1171
 
1083
- Usage: autotel-devtools [options]
1172
+ Usage: autotel-devtools [port] [options]
1173
+
1174
+ Arguments:
1175
+ port Port to listen on (shorthand for --port; must be a positive integer)
1084
1176
 
1085
1177
  Options:
1086
- -p, --port <port> Port to listen on (default: 4318, env: AUTOTEL_DEVTOOLS_PORT)
1178
+ -p, --port <port> Port to listen on (default: 4318, env: AUTOTEL_DEVTOOLS_PORT).
1179
+ If the port is taken, the next free port is used and a warning is shown.
1087
1180
  -H, --host <host> Host to bind to (default: 127.0.0.1, env: AUTOTEL_DEVTOOLS_HOST)
1088
1181
  -t, --title <title> Dashboard title (env: AUTOTEL_DEVTOOLS_TITLE)
1089
1182
  Env limits: AUTOTEL_MAX_TRACE_COUNT, AUTOTEL_MAX_LOG_COUNT, AUTOTEL_MAX_METRIC_COUNT
@@ -1103,7 +1196,8 @@ Endpoints:
1103
1196
 
1104
1197
  Examples:
1105
1198
  npx autotel-devtools
1106
- npx autotel-devtools -p 4319
1199
+ npx autotel-devtools 4319
1200
+ npx autotel-devtools -p 4319 -H 0.0.0.0
1107
1201
 
1108
1202
  Then point your app:
1109
1203
  OTEL_EXPORTER_OTLP_PROTOCOL=http/json OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318 node app.js
@@ -1130,10 +1224,12 @@ function printVersion() {
1130
1224
  }
1131
1225
  function parseArgs(argv) {
1132
1226
  const options = {
1133
- port: Number(process.env.AUTOTEL_DEVTOOLS_PORT || 4318),
1227
+ port: parsePort(process.env.AUTOTEL_DEVTOOLS_PORT || "4318"),
1134
1228
  host: process.env.AUTOTEL_DEVTOOLS_HOST || "127.0.0.1",
1135
1229
  title: process.env.AUTOTEL_DEVTOOLS_TITLE
1136
1230
  };
1231
+ let portWasExplicit = false;
1232
+ let positionalPortConsumed = false;
1137
1233
  for (let i = 0; i < argv.length; i++) {
1138
1234
  const arg = argv[i];
1139
1235
  const next = argv[i + 1];
@@ -1146,7 +1242,8 @@ function parseArgs(argv) {
1146
1242
  return null;
1147
1243
  }
1148
1244
  if ((arg === "--port" || arg === "-p") && next) {
1149
- options.port = Number(next);
1245
+ options.port = parsePort(next);
1246
+ portWasExplicit = true;
1150
1247
  i++;
1151
1248
  continue;
1152
1249
  }
@@ -1160,9 +1257,23 @@ function parseArgs(argv) {
1160
1257
  i++;
1161
1258
  continue;
1162
1259
  }
1260
+ if (/^\d+$/.test(arg) && !positionalPortConsumed) {
1261
+ if (!portWasExplicit) options.port = parsePort(arg);
1262
+ positionalPortConsumed = true;
1263
+ continue;
1264
+ }
1163
1265
  }
1164
1266
  return options;
1165
1267
  }
1268
+ function parsePort(value) {
1269
+ const n = Number(value);
1270
+ if (!Number.isInteger(n) || n < 0 || n > 65535) {
1271
+ process.stderr.write(`[autotel-devtools] invalid port: ${value}
1272
+ `);
1273
+ process.exit(2);
1274
+ }
1275
+ return n;
1276
+ }
1166
1277
  async function main() {
1167
1278
  const options = parseArgs(process.argv.slice(2));
1168
1279
  if (!options) {
@@ -1177,8 +1288,20 @@ async function main() {
1177
1288
  host: options.host,
1178
1289
  attachSecondary: (s) => attachDevtoolsRoutes(s, wsServer)
1179
1290
  });
1180
- const { addresses, warnings } = await listeners.ready;
1181
- const uiBase = `http://${options.host === "localhost" ? "127.0.0.1" : options.host}:${options.port}`;
1291
+ const { addresses, warnings, port: boundPort } = await listeners.ready;
1292
+ if (boundPort !== options.port) {
1293
+ const holder = await probePortHolder(options.host, options.port);
1294
+ if (holder === "autotel-devtools") {
1295
+ warnings.push(
1296
+ `another autotel-devtools is already running on port ${options.port}; this instance is on ${boundPort}. Use the existing one, or stop it and restart here.`
1297
+ );
1298
+ } else {
1299
+ warnings.push(
1300
+ `port ${options.port} is held by another process that is NOT autotel-devtools. Anything exporting OTLP to :${options.port} is reaching that process, not this devtools. Point your exporter at :${boundPort}, or free :${options.port} and restart.`
1301
+ );
1302
+ }
1303
+ }
1304
+ const uiBase = `http://${options.host === "localhost" ? "127.0.0.1" : options.host}:${boundPort}`;
1182
1305
  const title = options.title || "autotel-devtools";
1183
1306
  process.stdout.write(`
1184
1307
  ${title}