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/README.md +25 -1
- package/dist/cli.cjs +146 -23
- package/dist/cli.cjs.map +1 -1
- package/dist/cli.js +146 -23
- package/dist/cli.js.map +1 -1
- package/dist/index.cjs +84 -16
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +84 -16
- package/dist/index.js.map +1 -1
- package/dist/server/index.cjs +59 -4
- package/dist/server/index.cjs.map +1 -1
- package/dist/server/index.d.cts +17 -1
- package/dist/server/index.d.ts +17 -1
- package/dist/server/index.js +58 -5
- package/dist/server/index.js.map +1 -1
- package/dist/widget.global.js +1 -1
- package/package.json +2 -2
- package/skills/autotel-devtools/SKILL.md +4 -2
package/README.md
CHANGED
|
@@ -111,6 +111,25 @@ const myFunction = trace((ctx) => async () => {
|
|
|
111
111
|
|
|
112
112
|
- **DevtoolsServer** - WebSocket server + in-memory data store
|
|
113
113
|
- **HTTP Routes** - OTLP receivers for traces/logs/metrics (JSON + protobuf)
|
|
114
|
+
|
|
115
|
+
#### Detecting the receiver
|
|
116
|
+
|
|
117
|
+
Every response carries an `x-autotel-devtools: <version>` header, and `GET /healthz`
|
|
118
|
+
returns `{ ok, service: "autotel-devtools", version, clients }`. Use either to confirm
|
|
119
|
+
you are talking to autotel-devtools rather than another OTLP collector that happens to
|
|
120
|
+
share the port — for example before pointing an exporter at `:4318`:
|
|
121
|
+
|
|
122
|
+
```ts
|
|
123
|
+
import { probePortHolder } from 'autotel-devtools/server'
|
|
124
|
+
|
|
125
|
+
// 'autotel-devtools' | 'foreign' | 'none'
|
|
126
|
+
const holder = await probePortHolder('127.0.0.1', 4318)
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
If you start the receiver and the requested port is held by a *foreign* process (some
|
|
130
|
+
IDEs run their own OTLP collector on `:4318`), the CLI falls forward to the next free
|
|
131
|
+
port and warns that exporters still aimed at the busy port are reaching that other
|
|
132
|
+
process — point them at the bound port, or free the original.
|
|
114
133
|
- **Exporters** - OpenTelemetry span/log exporters
|
|
115
134
|
|
|
116
135
|
### Widget (Svelte 5)
|
|
@@ -152,12 +171,17 @@ AUTOTEL_DEVTOOLS_TITLE="My App" # Dashboard title (optional)
|
|
|
152
171
|
### CLI Options
|
|
153
172
|
|
|
154
173
|
```bash
|
|
174
|
+
npx autotel-devtools 4319 # port as a bare positional
|
|
155
175
|
npx autotel-devtools --port 4319 --host 0.0.0.0
|
|
156
176
|
```
|
|
157
177
|
|
|
178
|
+
Arguments:
|
|
179
|
+
|
|
180
|
+
- `[port]` - Port to listen on, shorthand for `--port` (an explicit `--port` always wins)
|
|
181
|
+
|
|
158
182
|
Options:
|
|
159
183
|
|
|
160
|
-
- `--port, -p` - Port to listen on (default: 4318)
|
|
184
|
+
- `--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
185
|
- `--host, -H` - Host to bind to (default: 127.0.0.1)
|
|
162
186
|
- `--title, -t` - Dashboard title
|
|
163
187
|
|
package/dist/cli.cjs
CHANGED
|
@@ -373,6 +373,9 @@ var DevtoolsServer = class {
|
|
|
373
373
|
this.onData = options.onData;
|
|
374
374
|
this.httpServer = options.server ?? http.createServer();
|
|
375
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
|
+
});
|
|
376
379
|
this.wss.on("connection", (ws) => {
|
|
377
380
|
this.clients.add(ws);
|
|
378
381
|
this.log(`Client connected (${this.clients.size} total)`);
|
|
@@ -920,7 +923,38 @@ function decodeOtlpMetricsRequest(body) {
|
|
|
920
923
|
return decodeRequest("opentelemetry.proto.metrics.v1.ExportMetricsServiceRequest", body);
|
|
921
924
|
}
|
|
922
925
|
|
|
926
|
+
// src/server/identity.ts
|
|
927
|
+
var DEVTOOLS_IDENTITY = "autotel-devtools";
|
|
928
|
+
async function probePortHolder(host, port, timeoutMs = 500) {
|
|
929
|
+
const authority = host.includes(":") ? `[${host}]` : host;
|
|
930
|
+
const controller = new AbortController();
|
|
931
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
932
|
+
try {
|
|
933
|
+
const res = await fetch(`http://${authority}:${port}/healthz`, {
|
|
934
|
+
signal: controller.signal
|
|
935
|
+
});
|
|
936
|
+
if (res.headers.get("x-autotel-devtools")) return "autotel-devtools";
|
|
937
|
+
try {
|
|
938
|
+
const body = await res.json();
|
|
939
|
+
if (body && body.service === DEVTOOLS_IDENTITY) return "autotel-devtools";
|
|
940
|
+
} catch {
|
|
941
|
+
}
|
|
942
|
+
return "foreign";
|
|
943
|
+
} catch {
|
|
944
|
+
return "none";
|
|
945
|
+
} finally {
|
|
946
|
+
clearTimeout(timer);
|
|
947
|
+
}
|
|
948
|
+
}
|
|
949
|
+
|
|
923
950
|
// src/server/http.ts
|
|
951
|
+
function sendOtlpError(res, req, e) {
|
|
952
|
+
sendJson(res, 400, {
|
|
953
|
+
error: "Invalid OTLP payload",
|
|
954
|
+
message: e instanceof Error ? e.message : String(e),
|
|
955
|
+
contentType: req.headers["content-type"] ?? null
|
|
956
|
+
});
|
|
957
|
+
}
|
|
924
958
|
var PROTOBUF_DECODERS = {
|
|
925
959
|
traces: decodeOtlpTraceRequest,
|
|
926
960
|
logs: decodeOtlpLogsRequest,
|
|
@@ -941,6 +975,18 @@ function findPackageRoot() {
|
|
|
941
975
|
return dir;
|
|
942
976
|
}
|
|
943
977
|
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>`;
|
|
978
|
+
var cachedVersion = null;
|
|
979
|
+
function getVersion() {
|
|
980
|
+
if (cachedVersion !== null) return cachedVersion;
|
|
981
|
+
let version = "unknown";
|
|
982
|
+
try {
|
|
983
|
+
const pkg = JSON.parse(fs.readFileSync(path.resolve(findPackageRoot(), "package.json"), "utf8"));
|
|
984
|
+
if (typeof pkg.version === "string") version = pkg.version;
|
|
985
|
+
} catch {
|
|
986
|
+
}
|
|
987
|
+
cachedVersion = version;
|
|
988
|
+
return version;
|
|
989
|
+
}
|
|
944
990
|
var cachedWidgetJs = null;
|
|
945
991
|
function getWidgetJs() {
|
|
946
992
|
if (!cachedWidgetJs) {
|
|
@@ -967,6 +1013,8 @@ function attachDevtoolsRoutes(httpServer, devtools) {
|
|
|
967
1013
|
res.setHeader("Access-Control-Allow-Origin", "*");
|
|
968
1014
|
res.setHeader("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS");
|
|
969
1015
|
res.setHeader("Access-Control-Allow-Headers", "Content-Type");
|
|
1016
|
+
res.setHeader("x-autotel-devtools", getVersion());
|
|
1017
|
+
res.setHeader("Access-Control-Expose-Headers", "x-autotel-devtools");
|
|
970
1018
|
if (req.method === "OPTIONS") {
|
|
971
1019
|
res.writeHead(204);
|
|
972
1020
|
res.end();
|
|
@@ -985,7 +1033,12 @@ function attachDevtoolsRoutes(httpServer, devtools) {
|
|
|
985
1033
|
return;
|
|
986
1034
|
}
|
|
987
1035
|
if (req.method === "GET" && url === "/healthz") {
|
|
988
|
-
sendJson(res, 200, {
|
|
1036
|
+
sendJson(res, 200, {
|
|
1037
|
+
ok: true,
|
|
1038
|
+
service: DEVTOOLS_IDENTITY,
|
|
1039
|
+
version: getVersion(),
|
|
1040
|
+
clients: devtools.clientCount
|
|
1041
|
+
});
|
|
989
1042
|
return;
|
|
990
1043
|
}
|
|
991
1044
|
if (req.method === "GET" && url === "/v1/traces") {
|
|
@@ -1005,7 +1058,7 @@ function attachDevtoolsRoutes(httpServer, devtools) {
|
|
|
1005
1058
|
devtools.addTraces(traces);
|
|
1006
1059
|
sendJson(res, 200, { acceptedTraces: traces.length });
|
|
1007
1060
|
} catch (e) {
|
|
1008
|
-
|
|
1061
|
+
sendOtlpError(res, req, e);
|
|
1009
1062
|
}
|
|
1010
1063
|
return;
|
|
1011
1064
|
}
|
|
@@ -1016,7 +1069,7 @@ function attachDevtoolsRoutes(httpServer, devtools) {
|
|
|
1016
1069
|
devtools.addLogs(logs);
|
|
1017
1070
|
sendJson(res, 200, { acceptedLogs: logs.length });
|
|
1018
1071
|
} catch (e) {
|
|
1019
|
-
|
|
1072
|
+
sendOtlpError(res, req, e);
|
|
1020
1073
|
}
|
|
1021
1074
|
return;
|
|
1022
1075
|
}
|
|
@@ -1026,7 +1079,7 @@ function attachDevtoolsRoutes(httpServer, devtools) {
|
|
|
1026
1079
|
const count = countOtlpMetrics(payload);
|
|
1027
1080
|
sendJson(res, 200, { acceptedMetrics: count });
|
|
1028
1081
|
} catch (e) {
|
|
1029
|
-
|
|
1082
|
+
sendOtlpError(res, req, e);
|
|
1030
1083
|
}
|
|
1031
1084
|
return;
|
|
1032
1085
|
}
|
|
@@ -1034,43 +1087,79 @@ function attachDevtoolsRoutes(httpServer, devtools) {
|
|
|
1034
1087
|
});
|
|
1035
1088
|
}
|
|
1036
1089
|
var LOOPBACK = /* @__PURE__ */ new Set(["localhost", "127.0.0.1", "::1"]);
|
|
1090
|
+
var DEFAULT_MAX_PORT_TRIES = 20;
|
|
1037
1091
|
function formatAddress(host, port) {
|
|
1038
1092
|
return host.includes(":") ? `[${host}]:${port}` : `${host}:${port}`;
|
|
1039
1093
|
}
|
|
1040
1094
|
function listenLoopbackDualStack(args) {
|
|
1041
|
-
const { primary, port, host, attachSecondary } = args;
|
|
1095
|
+
const { primary, port, host, attachSecondary, maxTries } = args;
|
|
1096
|
+
const maxAttempts = Math.max(1, maxTries ?? DEFAULT_MAX_PORT_TRIES);
|
|
1042
1097
|
let sibling;
|
|
1043
1098
|
const ready = new Promise(
|
|
1044
|
-
(resolve3) => {
|
|
1099
|
+
(resolve3, reject) => {
|
|
1045
1100
|
const addresses = [];
|
|
1046
1101
|
const warnings = [];
|
|
1047
1102
|
const primaryHost = host === "localhost" ? "127.0.0.1" : host;
|
|
1048
|
-
|
|
1103
|
+
let candidate = port;
|
|
1104
|
+
let attempt = 0;
|
|
1105
|
+
const bindFailed = (atPort, msg) => reject(
|
|
1106
|
+
new Error(`could not bind ${formatAddress(primaryHost, atPort)}: ${msg}`)
|
|
1107
|
+
);
|
|
1108
|
+
const onError = (e) => {
|
|
1109
|
+
if (e.code !== "EADDRINUSE") return bindFailed(candidate, e.message);
|
|
1110
|
+
if (++attempt >= maxAttempts) {
|
|
1111
|
+
reject(
|
|
1112
|
+
new Error(
|
|
1113
|
+
`could not bind ${formatAddress(primaryHost, port)}: ${maxAttempts} consecutive ports in use`
|
|
1114
|
+
)
|
|
1115
|
+
);
|
|
1116
|
+
return;
|
|
1117
|
+
}
|
|
1118
|
+
candidate++;
|
|
1119
|
+
listen();
|
|
1120
|
+
};
|
|
1121
|
+
const onListening = () => {
|
|
1122
|
+
primary.removeListener("error", onError);
|
|
1123
|
+
if (candidate !== port) {
|
|
1124
|
+
warnings.push(`port ${port} was busy; using ${candidate} instead`);
|
|
1125
|
+
}
|
|
1049
1126
|
const addr = primary.address();
|
|
1050
|
-
const resolvedPort = addr && typeof addr === "object" ? addr.port :
|
|
1127
|
+
const resolvedPort = addr && typeof addr === "object" ? addr.port : candidate;
|
|
1051
1128
|
addresses.push(formatAddress(primaryHost, resolvedPort));
|
|
1052
1129
|
if (!LOOPBACK.has(host)) {
|
|
1053
|
-
resolve3({ addresses, warnings });
|
|
1130
|
+
resolve3({ addresses, port: resolvedPort, warnings });
|
|
1054
1131
|
return;
|
|
1055
1132
|
}
|
|
1056
1133
|
const siblingHost = primaryHost === "::1" ? "127.0.0.1" : "::1";
|
|
1057
1134
|
const s = http.createServer();
|
|
1058
1135
|
attachSecondary(s);
|
|
1059
|
-
const
|
|
1136
|
+
const onSiblingError = (se) => {
|
|
1060
1137
|
s.close();
|
|
1061
1138
|
warnings.push(
|
|
1062
|
-
`could not also bind ${formatAddress(siblingHost, resolvedPort)} (${
|
|
1139
|
+
`could not also bind ${formatAddress(siblingHost, resolvedPort)} (${se.message}); clients using the ${siblingHost === "::1" ? "IPv6" : "IPv4"} form of "localhost" may not connect.`
|
|
1063
1140
|
);
|
|
1064
|
-
resolve3({ addresses, warnings });
|
|
1141
|
+
resolve3({ addresses, port: resolvedPort, warnings });
|
|
1065
1142
|
};
|
|
1066
|
-
s.once("error",
|
|
1143
|
+
s.once("error", onSiblingError);
|
|
1067
1144
|
s.listen(resolvedPort, siblingHost, () => {
|
|
1068
|
-
s.off("error",
|
|
1145
|
+
s.off("error", onSiblingError);
|
|
1069
1146
|
sibling = s;
|
|
1070
1147
|
addresses.push(formatAddress(siblingHost, resolvedPort));
|
|
1071
|
-
resolve3({ addresses, warnings });
|
|
1148
|
+
resolve3({ addresses, port: resolvedPort, warnings });
|
|
1072
1149
|
});
|
|
1073
|
-
}
|
|
1150
|
+
};
|
|
1151
|
+
const listen = () => {
|
|
1152
|
+
try {
|
|
1153
|
+
primary.listen(candidate, primaryHost);
|
|
1154
|
+
} catch (e) {
|
|
1155
|
+
primary.removeListener("error", onError);
|
|
1156
|
+
primary.removeListener("listening", onListening);
|
|
1157
|
+
bindFailed(candidate, e.message);
|
|
1158
|
+
}
|
|
1159
|
+
};
|
|
1160
|
+
primary.on("error", onError);
|
|
1161
|
+
primary.once("listening", onListening);
|
|
1162
|
+
listen();
|
|
1074
1163
|
}
|
|
1075
1164
|
);
|
|
1076
1165
|
return {
|
|
@@ -1087,10 +1176,14 @@ function printHelp() {
|
|
|
1087
1176
|
process.stdout.write(
|
|
1088
1177
|
`autotel-devtools - Standalone OTLP receiver with web devtools UI
|
|
1089
1178
|
|
|
1090
|
-
Usage: autotel-devtools [options]
|
|
1179
|
+
Usage: autotel-devtools [port] [options]
|
|
1180
|
+
|
|
1181
|
+
Arguments:
|
|
1182
|
+
port Port to listen on (shorthand for --port; must be a positive integer)
|
|
1091
1183
|
|
|
1092
1184
|
Options:
|
|
1093
|
-
-p, --port <port> Port to listen on (default: 4318, env: AUTOTEL_DEVTOOLS_PORT)
|
|
1185
|
+
-p, --port <port> Port to listen on (default: 4318, env: AUTOTEL_DEVTOOLS_PORT).
|
|
1186
|
+
If the port is taken, the next free port is used and a warning is shown.
|
|
1094
1187
|
-H, --host <host> Host to bind to (default: 127.0.0.1, env: AUTOTEL_DEVTOOLS_HOST)
|
|
1095
1188
|
-t, --title <title> Dashboard title (env: AUTOTEL_DEVTOOLS_TITLE)
|
|
1096
1189
|
Env limits: AUTOTEL_MAX_TRACE_COUNT, AUTOTEL_MAX_LOG_COUNT, AUTOTEL_MAX_METRIC_COUNT
|
|
@@ -1110,7 +1203,8 @@ Endpoints:
|
|
|
1110
1203
|
|
|
1111
1204
|
Examples:
|
|
1112
1205
|
npx autotel-devtools
|
|
1113
|
-
npx autotel-devtools
|
|
1206
|
+
npx autotel-devtools 4319
|
|
1207
|
+
npx autotel-devtools -p 4319 -H 0.0.0.0
|
|
1114
1208
|
|
|
1115
1209
|
Then point your app:
|
|
1116
1210
|
OTEL_EXPORTER_OTLP_PROTOCOL=http/json OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318 node app.js
|
|
@@ -1137,10 +1231,12 @@ function printVersion() {
|
|
|
1137
1231
|
}
|
|
1138
1232
|
function parseArgs(argv) {
|
|
1139
1233
|
const options = {
|
|
1140
|
-
port:
|
|
1234
|
+
port: parsePort(process.env.AUTOTEL_DEVTOOLS_PORT || "4318"),
|
|
1141
1235
|
host: process.env.AUTOTEL_DEVTOOLS_HOST || "127.0.0.1",
|
|
1142
1236
|
title: process.env.AUTOTEL_DEVTOOLS_TITLE
|
|
1143
1237
|
};
|
|
1238
|
+
let portWasExplicit = false;
|
|
1239
|
+
let positionalPortConsumed = false;
|
|
1144
1240
|
for (let i = 0; i < argv.length; i++) {
|
|
1145
1241
|
const arg = argv[i];
|
|
1146
1242
|
const next = argv[i + 1];
|
|
@@ -1153,7 +1249,8 @@ function parseArgs(argv) {
|
|
|
1153
1249
|
return null;
|
|
1154
1250
|
}
|
|
1155
1251
|
if ((arg === "--port" || arg === "-p") && next) {
|
|
1156
|
-
options.port =
|
|
1252
|
+
options.port = parsePort(next);
|
|
1253
|
+
portWasExplicit = true;
|
|
1157
1254
|
i++;
|
|
1158
1255
|
continue;
|
|
1159
1256
|
}
|
|
@@ -1167,9 +1264,23 @@ function parseArgs(argv) {
|
|
|
1167
1264
|
i++;
|
|
1168
1265
|
continue;
|
|
1169
1266
|
}
|
|
1267
|
+
if (/^\d+$/.test(arg) && !positionalPortConsumed) {
|
|
1268
|
+
if (!portWasExplicit) options.port = parsePort(arg);
|
|
1269
|
+
positionalPortConsumed = true;
|
|
1270
|
+
continue;
|
|
1271
|
+
}
|
|
1170
1272
|
}
|
|
1171
1273
|
return options;
|
|
1172
1274
|
}
|
|
1275
|
+
function parsePort(value) {
|
|
1276
|
+
const n = Number(value);
|
|
1277
|
+
if (!Number.isInteger(n) || n < 0 || n > 65535) {
|
|
1278
|
+
process.stderr.write(`[autotel-devtools] invalid port: ${value}
|
|
1279
|
+
`);
|
|
1280
|
+
process.exit(2);
|
|
1281
|
+
}
|
|
1282
|
+
return n;
|
|
1283
|
+
}
|
|
1173
1284
|
async function main() {
|
|
1174
1285
|
const options = parseArgs(process.argv.slice(2));
|
|
1175
1286
|
if (!options) {
|
|
@@ -1184,8 +1295,20 @@ async function main() {
|
|
|
1184
1295
|
host: options.host,
|
|
1185
1296
|
attachSecondary: (s) => attachDevtoolsRoutes(s, wsServer)
|
|
1186
1297
|
});
|
|
1187
|
-
const { addresses, warnings } = await listeners.ready;
|
|
1188
|
-
|
|
1298
|
+
const { addresses, warnings, port: boundPort } = await listeners.ready;
|
|
1299
|
+
if (boundPort !== options.port) {
|
|
1300
|
+
const holder = await probePortHolder(options.host, options.port);
|
|
1301
|
+
if (holder === "autotel-devtools") {
|
|
1302
|
+
warnings.push(
|
|
1303
|
+
`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.`
|
|
1304
|
+
);
|
|
1305
|
+
} else {
|
|
1306
|
+
warnings.push(
|
|
1307
|
+
`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.`
|
|
1308
|
+
);
|
|
1309
|
+
}
|
|
1310
|
+
}
|
|
1311
|
+
const uiBase = `http://${options.host === "localhost" ? "127.0.0.1" : options.host}:${boundPort}`;
|
|
1189
1312
|
const title = options.title || "autotel-devtools";
|
|
1190
1313
|
process.stdout.write(`
|
|
1191
1314
|
${title}
|