autotel-devtools 21.0.1 → 23.0.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 +183 -28
- package/dist/cli.cjs +80 -13
- package/dist/cli.js +80 -13
- package/dist/compile-JDFHUCo5.d.cts +177 -0
- package/dist/compile-JDFHUCo5.d.ts +177 -0
- package/dist/{error-aggregator-D8VKciLY.d.ts → error-aggregator-B52JI8jL.d.ts} +1 -1
- package/dist/{error-aggregator-DCEKMOm3.d.cts → error-aggregator-DSwUvgFF.d.cts} +1 -1
- package/dist/exporter-BrEcnzoT.d.ts +546 -0
- package/dist/exporter-C4uKgo7l.d.cts +546 -0
- package/dist/fullpage.global.js +39 -0
- package/dist/grpc-CSWjMRiB.cjs +82 -0
- package/dist/grpc-CZEDUYCM.js +77 -0
- package/dist/http-Cqm_MAtc.cjs +3942 -0
- package/dist/http-Dbd9TIYU.js +3776 -0
- package/dist/index.cjs +25 -7
- package/dist/index.d.cts +30 -3
- package/dist/index.d.ts +30 -3
- package/dist/index.js +25 -7
- package/dist/{listen-DBfsfcdd.js → listen-D-lLgfro.js} +5 -2
- package/dist/{listen-CEJ3nYJf.cjs → listen-l09RRHht.cjs} +5 -2
- package/dist/parse-BRlosZft.cjs +642 -0
- package/dist/parse-D_RmPPQs.js +612 -0
- package/dist/query/index.cjs +8 -0
- package/dist/query/index.d.cts +16 -0
- package/dist/query/index.d.ts +16 -0
- package/dist/query/index.js +3 -0
- package/dist/server/exporter.d.cts +1 -1
- package/dist/server/exporter.d.ts +1 -1
- package/dist/server/index.cjs +6 -2
- package/dist/server/index.d.cts +19 -6
- package/dist/server/index.d.ts +18 -5
- package/dist/server/index.js +3 -2
- package/dist/types-B0tjwFqj.d.cts +107 -0
- package/dist/types-DM8y4A9Z.d.ts +107 -0
- package/dist/widget.global.js +15 -23
- package/dist/wire/index.cjs +7 -0
- package/dist/wire/index.d.cts +26 -0
- package/dist/wire/index.d.ts +26 -0
- package/dist/wire/index.js +3 -0
- package/dist/wire-2Rmfg6IT.js +51 -0
- package/dist/wire-CHU1PkMo.cjs +75 -0
- package/package.json +22 -6
- package/dist/exporter-Dt4kx128.d.cts +0 -207
- package/dist/exporter-Due9Rd4s.d.ts +0 -207
- package/dist/http-CNZMrnzv.js +0 -1453
- package/dist/http-CXSzX4ee.cjs +0 -1607
|
@@ -0,0 +1,3942 @@
|
|
|
1
|
+
//#region \0rolldown/runtime.js
|
|
2
|
+
var __create = Object.create;
|
|
3
|
+
var __defProp = Object.defineProperty;
|
|
4
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
5
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
6
|
+
var __getProtoOf = Object.getPrototypeOf;
|
|
7
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
8
|
+
var __copyProps = (to, from, except, desc) => {
|
|
9
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
10
|
+
for (var keys = __getOwnPropNames(from), i = 0, n = keys.length, key; i < n; i++) {
|
|
11
|
+
key = keys[i];
|
|
12
|
+
if (!__hasOwnProp.call(to, key) && key !== except) {
|
|
13
|
+
__defProp(to, key, {
|
|
14
|
+
get: ((k) => from[k]).bind(null, key),
|
|
15
|
+
enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
return to;
|
|
21
|
+
};
|
|
22
|
+
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", {
|
|
23
|
+
value: mod,
|
|
24
|
+
enumerable: true
|
|
25
|
+
}) : target, mod));
|
|
26
|
+
|
|
27
|
+
//#endregion
|
|
28
|
+
const require_wire = require('./wire-CHU1PkMo.cjs');
|
|
29
|
+
const require_json_fields = require('./json-fields-DZFlNqT2.cjs');
|
|
30
|
+
const require_trace_root = require('./trace-root-BMa667jK.cjs');
|
|
31
|
+
const require_parse = require('./parse-BRlosZft.cjs');
|
|
32
|
+
const require_resource_utils = require('./resource-utils-DjHJB6uc.cjs');
|
|
33
|
+
let node_http = require("node:http");
|
|
34
|
+
let ws = require("ws");
|
|
35
|
+
let autotel_agents = require("autotel-agents");
|
|
36
|
+
let node_sqlite = require("node:sqlite");
|
|
37
|
+
let node_crypto = require("node:crypto");
|
|
38
|
+
let node_zlib = require("node:zlib");
|
|
39
|
+
let node_fs = require("node:fs");
|
|
40
|
+
let node_path = require("node:path");
|
|
41
|
+
node_path = __toESM(node_path, 1);
|
|
42
|
+
let node_url = require("node:url");
|
|
43
|
+
let protobufjs = require("protobufjs");
|
|
44
|
+
protobufjs = __toESM(protobufjs, 1);
|
|
45
|
+
|
|
46
|
+
//#region src/server/error-aggregator.ts
|
|
47
|
+
var ErrorAggregator = class {
|
|
48
|
+
errorGroups = /* @__PURE__ */ new Map();
|
|
49
|
+
options;
|
|
50
|
+
constructor(options = {}) {
|
|
51
|
+
this.options = {
|
|
52
|
+
maxGroups: options.maxGroups ?? 100,
|
|
53
|
+
maxAffectedTraces: options.maxAffectedTraces ?? 10,
|
|
54
|
+
maxAffectedSpans: options.maxAffectedSpans ?? 5,
|
|
55
|
+
stackFramesForFingerprint: options.stackFramesForFingerprint ?? 5
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Add an error occurrence to the aggregator
|
|
60
|
+
*/
|
|
61
|
+
addError(occurrence) {
|
|
62
|
+
const fingerprint = this.generateFingerprint(occurrence);
|
|
63
|
+
const existing = this.errorGroups.get(fingerprint);
|
|
64
|
+
if (existing) {
|
|
65
|
+
existing.count++;
|
|
66
|
+
existing.lastSeen = occurrence.timestamp;
|
|
67
|
+
if (!existing.affectedTraces.includes(occurrence.traceId)) {
|
|
68
|
+
existing.affectedTraces.push(occurrence.traceId);
|
|
69
|
+
if (existing.affectedTraces.length > this.options.maxAffectedTraces) existing.affectedTraces.shift();
|
|
70
|
+
}
|
|
71
|
+
if (!existing.affectedSpans.includes(occurrence.spanName)) {
|
|
72
|
+
existing.affectedSpans.push(occurrence.spanName);
|
|
73
|
+
if (existing.affectedSpans.length > this.options.maxAffectedSpans) existing.affectedSpans.shift();
|
|
74
|
+
}
|
|
75
|
+
return existing;
|
|
76
|
+
}
|
|
77
|
+
const newGroup = {
|
|
78
|
+
fingerprint,
|
|
79
|
+
type: occurrence.error.type,
|
|
80
|
+
message: occurrence.error.message,
|
|
81
|
+
stackTrace: this.normalizeStackTrace(occurrence.error.stackTrace),
|
|
82
|
+
count: 1,
|
|
83
|
+
firstSeen: occurrence.timestamp,
|
|
84
|
+
lastSeen: occurrence.timestamp,
|
|
85
|
+
affectedTraces: [occurrence.traceId],
|
|
86
|
+
affectedSpans: [occurrence.spanName],
|
|
87
|
+
service: occurrence.service,
|
|
88
|
+
attributes: occurrence.attributes
|
|
89
|
+
};
|
|
90
|
+
if (this.errorGroups.size >= this.options.maxGroups) this.evictOldestGroup();
|
|
91
|
+
this.errorGroups.set(fingerprint, newGroup);
|
|
92
|
+
return newGroup;
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Extract errors from a trace and add them to the aggregator
|
|
96
|
+
*/
|
|
97
|
+
addErrorsFromTrace(trace) {
|
|
98
|
+
const addedGroups = [];
|
|
99
|
+
for (const span of trace.spans) if (span.status.code === "ERROR") {
|
|
100
|
+
const occurrence = this.extractErrorFromSpan(span, trace);
|
|
101
|
+
if (occurrence) {
|
|
102
|
+
const group = this.addError(occurrence);
|
|
103
|
+
addedGroups.push(group);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
return addedGroups;
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Extract error occurrence from a span
|
|
110
|
+
*/
|
|
111
|
+
extractErrorFromSpan(span, trace) {
|
|
112
|
+
const exceptionEvent = span.events?.find((e) => e.name === "exception");
|
|
113
|
+
const errorType = require_json_fields.stringAttr(span.attributes, "exception.type", "error.type") ?? require_json_fields.stringAttr(exceptionEvent?.attributes, "exception.type") ?? "Error";
|
|
114
|
+
const errorMessage = span.status.message || require_json_fields.stringAttr(span.attributes, "exception.message", "error.message") || "Unknown error";
|
|
115
|
+
const stackTrace = require_json_fields.stringAttr(span.attributes, "exception.stacktrace", "exception.stack", "error.stack") ?? this.extractStackFromEvents(span);
|
|
116
|
+
return {
|
|
117
|
+
traceId: trace.traceId,
|
|
118
|
+
spanId: span.spanId,
|
|
119
|
+
spanName: span.name,
|
|
120
|
+
service: trace.service,
|
|
121
|
+
timestamp: span.endTime,
|
|
122
|
+
error: {
|
|
123
|
+
type: errorType,
|
|
124
|
+
message: errorMessage,
|
|
125
|
+
stackTrace,
|
|
126
|
+
fingerprint: require_json_fields.stringAttr(span.attributes, "exception.fingerprint") ?? require_json_fields.stringAttr(exceptionEvent?.attributes, "exception.fingerprint")
|
|
127
|
+
},
|
|
128
|
+
attributes: this.extractRelevantAttributes(span.attributes)
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* Extract stack trace from span events (exception events)
|
|
133
|
+
*/
|
|
134
|
+
extractStackFromEvents(span) {
|
|
135
|
+
if (!span.events) return void 0;
|
|
136
|
+
const exceptionEvent = span.events.find((e) => e.name === "exception");
|
|
137
|
+
if (exceptionEvent?.attributes) return require_json_fields.stringAttr(exceptionEvent.attributes, "exception.stacktrace", "exception.stack");
|
|
138
|
+
}
|
|
139
|
+
/**
|
|
140
|
+
* Extract relevant attributes for error context
|
|
141
|
+
*/
|
|
142
|
+
extractRelevantAttributes(attributes) {
|
|
143
|
+
const relevant = {};
|
|
144
|
+
for (const key of [
|
|
145
|
+
"http.method",
|
|
146
|
+
"http.url",
|
|
147
|
+
"http.route",
|
|
148
|
+
"http.status_code",
|
|
149
|
+
"db.system",
|
|
150
|
+
"db.operation",
|
|
151
|
+
"rpc.method",
|
|
152
|
+
"rpc.service",
|
|
153
|
+
"code.function",
|
|
154
|
+
"code.filepath",
|
|
155
|
+
"user.id",
|
|
156
|
+
"operation.name"
|
|
157
|
+
]) if (key in attributes) relevant[key] = attributes[key];
|
|
158
|
+
return relevant;
|
|
159
|
+
}
|
|
160
|
+
/**
|
|
161
|
+
* Generate a fingerprint for error grouping
|
|
162
|
+
*
|
|
163
|
+
* Uses error type + first N stack frames (normalized)
|
|
164
|
+
*/
|
|
165
|
+
generateFingerprint(occurrence) {
|
|
166
|
+
if (occurrence.error.fingerprint) return occurrence.error.fingerprint;
|
|
167
|
+
const parts = [occurrence.error.type];
|
|
168
|
+
if (occurrence.error.stackTrace) {
|
|
169
|
+
const frames = this.extractStackFrames(occurrence.error.stackTrace, this.options.stackFramesForFingerprint);
|
|
170
|
+
parts.push(...frames);
|
|
171
|
+
} else parts.push(this.normalizeMessage(occurrence.error.message));
|
|
172
|
+
return this.simpleHash(parts.join("|"));
|
|
173
|
+
}
|
|
174
|
+
/**
|
|
175
|
+
* Extract and normalize stack frames from a stack trace
|
|
176
|
+
*/
|
|
177
|
+
extractStackFrames(stackTrace, count) {
|
|
178
|
+
const lines = stackTrace.split("\n");
|
|
179
|
+
const frames = [];
|
|
180
|
+
for (const line of lines) {
|
|
181
|
+
if (frames.length >= count) break;
|
|
182
|
+
const trimmed = line.trim();
|
|
183
|
+
const nodeMatch = trimmed.match(/^at\s+(.+?)\s+\((.+?):(\d+):\d+\)$/);
|
|
184
|
+
if (nodeMatch) {
|
|
185
|
+
frames.push(`${nodeMatch[1]}@${this.normalizeFilePath(nodeMatch[2])}`);
|
|
186
|
+
continue;
|
|
187
|
+
}
|
|
188
|
+
const anonMatch = trimmed.match(/^at\s+(.+?):(\d+):\d+$/);
|
|
189
|
+
if (anonMatch) {
|
|
190
|
+
frames.push(`anonymous@${this.normalizeFilePath(anonMatch[1])}`);
|
|
191
|
+
continue;
|
|
192
|
+
}
|
|
193
|
+
const browserMatch = trimmed.match(/^(.+?)@(.+?):(\d+):\d+$/);
|
|
194
|
+
if (browserMatch) {
|
|
195
|
+
frames.push(`${browserMatch[1]}@${this.normalizeFilePath(browserMatch[2])}`);
|
|
196
|
+
continue;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
return frames;
|
|
200
|
+
}
|
|
201
|
+
/**
|
|
202
|
+
* Normalize file path by removing absolute path prefixes and node_modules paths
|
|
203
|
+
*/
|
|
204
|
+
normalizeFilePath(filePath) {
|
|
205
|
+
const nodeModulesMatch = filePath.match(/node_modules\/(@[^/]+\/[^/]+|[^/]+)/);
|
|
206
|
+
if (nodeModulesMatch) return `[npm]/${nodeModulesMatch[1]}`;
|
|
207
|
+
return filePath.replace(/^.*?\/src\//, "src/").replace(/^.*?\/dist\//, "dist/").replace(/^.*?\/lib\//, "lib/").replace(/^file:\/\//, "");
|
|
208
|
+
}
|
|
209
|
+
/**
|
|
210
|
+
* Normalize error message by removing dynamic parts
|
|
211
|
+
*/
|
|
212
|
+
normalizeMessage(message) {
|
|
213
|
+
return message.replaceAll(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi, "[UUID]").replaceAll(/\b[0-9a-f]{16,}\b/gi, "[ID]").replaceAll(/\d+/g, "[N]").replaceAll(/"[^"]*"/g, "\"[STR]\"").replaceAll(/'[^']*'/g, "'[STR]'").slice(0, 200);
|
|
214
|
+
}
|
|
215
|
+
/**
|
|
216
|
+
* Normalize stack trace for display
|
|
217
|
+
*/
|
|
218
|
+
normalizeStackTrace(stackTrace) {
|
|
219
|
+
if (!stackTrace) return void 0;
|
|
220
|
+
return stackTrace.split("\n").slice(0, 10).join("\n");
|
|
221
|
+
}
|
|
222
|
+
/**
|
|
223
|
+
* Simple hash function for fingerprinting
|
|
224
|
+
*/
|
|
225
|
+
simpleHash(str) {
|
|
226
|
+
let hash = 0;
|
|
227
|
+
for (let i = 0; i < str.length; i++) {
|
|
228
|
+
const char = str.charCodeAt(i);
|
|
229
|
+
hash = (hash << 5) - hash + char;
|
|
230
|
+
hash = hash & hash;
|
|
231
|
+
}
|
|
232
|
+
return Math.abs(hash).toString(16).padStart(8, "0");
|
|
233
|
+
}
|
|
234
|
+
/**
|
|
235
|
+
* Evict the oldest error group
|
|
236
|
+
*/
|
|
237
|
+
evictOldestGroup() {
|
|
238
|
+
let oldest = null;
|
|
239
|
+
for (const [fingerprint, group] of this.errorGroups) if (!oldest || group.lastSeen < oldest.lastSeen) oldest = {
|
|
240
|
+
fingerprint,
|
|
241
|
+
lastSeen: group.lastSeen
|
|
242
|
+
};
|
|
243
|
+
if (oldest) this.errorGroups.delete(oldest.fingerprint);
|
|
244
|
+
}
|
|
245
|
+
/**
|
|
246
|
+
* Get all error groups, sorted by most recent
|
|
247
|
+
*/
|
|
248
|
+
getErrorGroups() {
|
|
249
|
+
return [...this.errorGroups.values()].sort((a, b) => b.lastSeen - a.lastSeen);
|
|
250
|
+
}
|
|
251
|
+
/**
|
|
252
|
+
* Get error groups sorted by count (most frequent first)
|
|
253
|
+
*/
|
|
254
|
+
getErrorGroupsByFrequency() {
|
|
255
|
+
return [...this.errorGroups.values()].sort((a, b) => b.count - a.count);
|
|
256
|
+
}
|
|
257
|
+
/**
|
|
258
|
+
* Get a specific error group by fingerprint
|
|
259
|
+
*/
|
|
260
|
+
getErrorGroup(fingerprint) {
|
|
261
|
+
return this.errorGroups.get(fingerprint);
|
|
262
|
+
}
|
|
263
|
+
/**
|
|
264
|
+
* Get error groups for a specific service
|
|
265
|
+
*/
|
|
266
|
+
getErrorGroupsByService(service) {
|
|
267
|
+
return this.getErrorGroups().filter((g) => g.service === service);
|
|
268
|
+
}
|
|
269
|
+
/**
|
|
270
|
+
* Get total error count across all groups
|
|
271
|
+
*/
|
|
272
|
+
getTotalErrorCount() {
|
|
273
|
+
let total = 0;
|
|
274
|
+
for (const group of this.errorGroups.values()) total += group.count;
|
|
275
|
+
return total;
|
|
276
|
+
}
|
|
277
|
+
/**
|
|
278
|
+
* Get error statistics
|
|
279
|
+
*/
|
|
280
|
+
getStats() {
|
|
281
|
+
const oneHourAgo = Date.now() - 3600 * 1e3;
|
|
282
|
+
let recentErrors = 0;
|
|
283
|
+
const typeCount = /* @__PURE__ */ new Map();
|
|
284
|
+
for (const group of this.errorGroups.values()) {
|
|
285
|
+
if (group.lastSeen > oneHourAgo) recentErrors += group.count;
|
|
286
|
+
typeCount.set(group.type, (typeCount.get(group.type) || 0) + group.count);
|
|
287
|
+
}
|
|
288
|
+
const topErrorTypes = [...typeCount.entries()].map(([type, count]) => ({
|
|
289
|
+
type,
|
|
290
|
+
count
|
|
291
|
+
})).sort((a, b) => b.count - a.count).slice(0, 5);
|
|
292
|
+
return {
|
|
293
|
+
totalGroups: this.errorGroups.size,
|
|
294
|
+
totalErrors: this.getTotalErrorCount(),
|
|
295
|
+
recentErrors,
|
|
296
|
+
topErrorTypes
|
|
297
|
+
};
|
|
298
|
+
}
|
|
299
|
+
/**
|
|
300
|
+
* Clear all error groups
|
|
301
|
+
*/
|
|
302
|
+
clear() {
|
|
303
|
+
this.errorGroups.clear();
|
|
304
|
+
}
|
|
305
|
+
/**
|
|
306
|
+
* Clear old error groups (not seen in given time window)
|
|
307
|
+
*/
|
|
308
|
+
clearOlderThan(maxAgeMs) {
|
|
309
|
+
const cutoff = Date.now() - maxAgeMs;
|
|
310
|
+
let cleared = 0;
|
|
311
|
+
for (const [fingerprint, group] of this.errorGroups) if (group.lastSeen < cutoff) {
|
|
312
|
+
this.errorGroups.delete(fingerprint);
|
|
313
|
+
cleared++;
|
|
314
|
+
}
|
|
315
|
+
return cleared;
|
|
316
|
+
}
|
|
317
|
+
};
|
|
318
|
+
|
|
319
|
+
//#endregion
|
|
320
|
+
//#region src/server/telemetry-limits.ts
|
|
321
|
+
const defaultLimit = 100;
|
|
322
|
+
function parseLimit(value) {
|
|
323
|
+
if (!value) return void 0;
|
|
324
|
+
const parsed = Number.parseInt(value, 10);
|
|
325
|
+
return Number.isFinite(parsed) && parsed > 0 ? parsed : void 0;
|
|
326
|
+
}
|
|
327
|
+
function resolveTelemetryLimits(args = {}) {
|
|
328
|
+
const env = args.env ?? process.env;
|
|
329
|
+
const fallback = args.maxHistory ?? defaultLimit;
|
|
330
|
+
return {
|
|
331
|
+
maxTraceCount: args.maxTraceCount ?? parseLimit(env.AUTOTEL_MAX_TRACE_COUNT) ?? fallback,
|
|
332
|
+
maxLogCount: args.maxLogCount ?? parseLimit(env.AUTOTEL_MAX_LOG_COUNT) ?? fallback
|
|
333
|
+
};
|
|
334
|
+
}
|
|
335
|
+
function appendWithLimit(items, item, limit) {
|
|
336
|
+
if (limit <= 0) return [];
|
|
337
|
+
const next = [...items, item];
|
|
338
|
+
return next.length > limit ? next.slice(next.length - limit) : next;
|
|
339
|
+
}
|
|
340
|
+
function appendManyWithLimit(items, incoming, limit) {
|
|
341
|
+
if (limit <= 0 || incoming.length === 0) return limit <= 0 ? [] : items;
|
|
342
|
+
const next = [...items, ...incoming];
|
|
343
|
+
return next.length > limit ? next.slice(next.length - limit) : next;
|
|
344
|
+
}
|
|
345
|
+
function applyTelemetryLimits(data, limits) {
|
|
346
|
+
return {
|
|
347
|
+
...data,
|
|
348
|
+
traces: data.traces.slice(-limits.maxTraceCount),
|
|
349
|
+
logs: data.logs.slice(-limits.maxLogCount)
|
|
350
|
+
};
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
//#endregion
|
|
354
|
+
//#region src/server/origin-guard.ts
|
|
355
|
+
const LOOPBACK_IPV6 = /* @__PURE__ */ new Set(["::1", "0:0:0:0:0:0:0:1"]);
|
|
356
|
+
/** True for `localhost`, any `127.x.x.x`, and IPv6 loopback. Case-insensitive. */
|
|
357
|
+
function isLoopbackHostname(hostname) {
|
|
358
|
+
const h = hostname.toLowerCase().replace(/^\[|\]$/g, "");
|
|
359
|
+
return h === "localhost" || /^127\./.test(h) || LOOPBACK_IPV6.has(h);
|
|
360
|
+
}
|
|
361
|
+
/** Hostname from a `Host` header (`host`, `host:port`, `[::1]:port`). */
|
|
362
|
+
function hostnameFromHostHeader(host) {
|
|
363
|
+
const h = host.trim();
|
|
364
|
+
if (h.startsWith("[")) {
|
|
365
|
+
const end = h.indexOf("]");
|
|
366
|
+
return end > 0 ? h.slice(1, end) : h;
|
|
367
|
+
}
|
|
368
|
+
const colon = h.indexOf(":");
|
|
369
|
+
return colon === -1 ? h : h.slice(0, colon);
|
|
370
|
+
}
|
|
371
|
+
/** True when the `Host` header names a loopback host. */
|
|
372
|
+
function hostHeaderIsLoopback(host) {
|
|
373
|
+
return isLoopbackHostname(hostnameFromHostHeader(host));
|
|
374
|
+
}
|
|
375
|
+
/** True when an `Origin` header names a loopback origin. A malformed or opaque
|
|
376
|
+
* origin (e.g. the literal `null` from a sandboxed iframe) is treated as
|
|
377
|
+
* non-loopback. */
|
|
378
|
+
function originIsLoopback(origin) {
|
|
379
|
+
try {
|
|
380
|
+
return isLoopbackHostname(new URL(origin).hostname);
|
|
381
|
+
} catch {
|
|
382
|
+
return false;
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
/**
|
|
386
|
+
* Decide whether a request to a sensitive (read/mutate) endpoint is allowed.
|
|
387
|
+
* - A present, non-loopback `Origin` is always rejected (cross-origin read).
|
|
388
|
+
* - When `loopbackOnly`, a present, non-loopback `Host` is rejected (DNS
|
|
389
|
+
* rebinding). Skipped when the receiver is bound to a non-loopback host.
|
|
390
|
+
*/
|
|
391
|
+
function allowSensitiveRequest(headers, loopbackOnly) {
|
|
392
|
+
const { origin, host } = headers;
|
|
393
|
+
if (origin && origin.length > 0 && !originIsLoopback(origin)) return false;
|
|
394
|
+
if (loopbackOnly && host && host.length > 0 && !hostHeaderIsLoopback(host)) return false;
|
|
395
|
+
return true;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
//#endregion
|
|
399
|
+
//#region src/server/metric-reduction.ts
|
|
400
|
+
const DEFAULT_MAX_EXEMPLARS = 32;
|
|
401
|
+
/** Reduce a metric series to a chart-sized result before it crosses the wire. */
|
|
402
|
+
function reduceMetricPoints(points, kind, maxPoints, options = {}) {
|
|
403
|
+
const maxExemplars = options.maxExemplars ?? DEFAULT_MAX_EXEMPLARS;
|
|
404
|
+
const limit = Math.max(4, Math.floor(maxPoints));
|
|
405
|
+
if (points.length <= limit) return capExemplars(points, maxExemplars);
|
|
406
|
+
if (kind === "histogram" || kind === "exponentialHistogram" || kind === "summary") {
|
|
407
|
+
if (options.temporality === "cumulative") return sampleCumulativeSnapshots(points, limit, maxExemplars);
|
|
408
|
+
return mergeDistributionBuckets(points, limit, maxExemplars);
|
|
409
|
+
}
|
|
410
|
+
return capExemplars(m4(points, limit), maxExemplars);
|
|
411
|
+
}
|
|
412
|
+
/** Cumulative points are complete snapshots; summing them double-counts. */
|
|
413
|
+
function sampleCumulativeSnapshots(points, limit, maxExemplars) {
|
|
414
|
+
const bucketSize = Math.ceil(points.length / limit);
|
|
415
|
+
const snapshots = [];
|
|
416
|
+
for (let start = 0; start < points.length; start += bucketSize) snapshots.push(points[Math.min(start + bucketSize, points.length) - 1]);
|
|
417
|
+
return capExemplars(snapshots, maxExemplars);
|
|
418
|
+
}
|
|
419
|
+
/** First/min/max/last sampling preserves spikes and troughs in every time bucket. */
|
|
420
|
+
function m4(points, limit) {
|
|
421
|
+
const bucketSize = Math.max(1, Math.ceil(points.length / Math.max(1, Math.floor(limit / 4))));
|
|
422
|
+
const selected = [];
|
|
423
|
+
for (let start = 0; start < points.length; start += bucketSize) {
|
|
424
|
+
const bucket = points.slice(start, start + bucketSize);
|
|
425
|
+
const byValue = [...bucket].sort((a, b) => (a.value ?? 0) - (b.value ?? 0));
|
|
426
|
+
const candidates = [
|
|
427
|
+
bucket[0],
|
|
428
|
+
byValue[0],
|
|
429
|
+
byValue[byValue.length - 1],
|
|
430
|
+
bucket[bucket.length - 1]
|
|
431
|
+
];
|
|
432
|
+
const unique = /* @__PURE__ */ new Map();
|
|
433
|
+
for (const point of candidates) unique.set(point.timestamp, point);
|
|
434
|
+
selected.push(...[...unique.values()].sort((a, b) => a.timestamp - b.timestamp));
|
|
435
|
+
}
|
|
436
|
+
return selected.slice(0, limit);
|
|
437
|
+
}
|
|
438
|
+
function mergeDistributionBuckets(points, limit, maxExemplars) {
|
|
439
|
+
const bucketSize = Math.ceil(points.length / limit);
|
|
440
|
+
const out = [];
|
|
441
|
+
let remainingExemplars = maxExemplars;
|
|
442
|
+
for (let start = 0; start < points.length; start += bucketSize) {
|
|
443
|
+
const merged = mergePoints(points.slice(start, start + bucketSize), remainingExemplars);
|
|
444
|
+
remainingExemplars -= merged.exemplars?.length ?? 0;
|
|
445
|
+
out.push(merged);
|
|
446
|
+
}
|
|
447
|
+
return out;
|
|
448
|
+
}
|
|
449
|
+
function mergePoints(points, maxExemplars) {
|
|
450
|
+
const last = points[points.length - 1];
|
|
451
|
+
const counts = points.map((point) => point.count ?? 0);
|
|
452
|
+
const totalCount = counts.reduce((sum, value) => sum + value, 0);
|
|
453
|
+
const result = {
|
|
454
|
+
...last,
|
|
455
|
+
startTimestamp: points[0].startTimestamp,
|
|
456
|
+
count: totalCount,
|
|
457
|
+
sum: sumDefined(points.map((point) => point.sum)),
|
|
458
|
+
min: minDefined(points.map((point) => point.min)),
|
|
459
|
+
max: maxDefined(points.map((point) => point.max)),
|
|
460
|
+
exemplars: maxExemplars <= 0 ? [] : points.flatMap((point) => point.exemplars ?? []).slice(-maxExemplars)
|
|
461
|
+
};
|
|
462
|
+
if (sameArrays(points.map((point) => point.explicitBounds))) result.bucketCounts = sumArrays(points.map((point) => point.bucketCounts));
|
|
463
|
+
if (points.every((point) => point.scale === last.scale)) {
|
|
464
|
+
result.zeroCount = points.reduce((sum, point) => sum + (point.zeroCount ?? 0), 0);
|
|
465
|
+
result.positive = mergeExponential(points.map((point) => point.positive));
|
|
466
|
+
result.negative = mergeExponential(points.map((point) => point.negative));
|
|
467
|
+
}
|
|
468
|
+
if (last.quantiles) result.quantiles = last.quantiles.map(({ quantile }) => {
|
|
469
|
+
let weight = 0;
|
|
470
|
+
let value = 0;
|
|
471
|
+
for (let index = 0; index < points.length; index++) {
|
|
472
|
+
const found = points[index].quantiles?.find((item) => item.quantile === quantile);
|
|
473
|
+
if (!found) continue;
|
|
474
|
+
const pointWeight = counts[index] || 1;
|
|
475
|
+
weight += pointWeight;
|
|
476
|
+
value += found.value * pointWeight;
|
|
477
|
+
}
|
|
478
|
+
return {
|
|
479
|
+
quantile,
|
|
480
|
+
value: weight === 0 ? 0 : value / weight
|
|
481
|
+
};
|
|
482
|
+
});
|
|
483
|
+
return result;
|
|
484
|
+
}
|
|
485
|
+
function mergeExponential(values) {
|
|
486
|
+
const buckets = values.filter((value) => value !== void 0);
|
|
487
|
+
if (buckets.length === 0) return void 0;
|
|
488
|
+
const start = Math.min(...buckets.map((bucket) => bucket.offset));
|
|
489
|
+
const end = Math.max(...buckets.map((bucket) => bucket.offset + bucket.bucketCounts.length));
|
|
490
|
+
const bucketCounts = Array.from({ length: end - start }, () => 0);
|
|
491
|
+
for (const bucket of buckets) for (let index = 0; index < bucket.bucketCounts.length; index++) bucketCounts[bucket.offset - start + index] += bucket.bucketCounts[index];
|
|
492
|
+
return {
|
|
493
|
+
offset: start,
|
|
494
|
+
bucketCounts
|
|
495
|
+
};
|
|
496
|
+
}
|
|
497
|
+
function sameArrays(values) {
|
|
498
|
+
const first = JSON.stringify(values[0]);
|
|
499
|
+
return values.every((value) => JSON.stringify(value) === first);
|
|
500
|
+
}
|
|
501
|
+
function sumArrays(values) {
|
|
502
|
+
if (values.some((value) => value === void 0)) return void 0;
|
|
503
|
+
return values.reduce((sum, value) => value.map((item, index) => item + (sum[index] ?? 0)), []);
|
|
504
|
+
}
|
|
505
|
+
function sumDefined(values) {
|
|
506
|
+
const found = values.filter((value) => value !== void 0);
|
|
507
|
+
return found.length === 0 ? void 0 : found.reduce((sum, value) => sum + value, 0);
|
|
508
|
+
}
|
|
509
|
+
function minDefined(values) {
|
|
510
|
+
const found = values.filter((value) => value !== void 0);
|
|
511
|
+
return found.length === 0 ? void 0 : Math.min(...found);
|
|
512
|
+
}
|
|
513
|
+
function maxDefined(values) {
|
|
514
|
+
const found = values.filter((value) => value !== void 0);
|
|
515
|
+
return found.length === 0 ? void 0 : Math.max(...found);
|
|
516
|
+
}
|
|
517
|
+
function capExemplars(points, max) {
|
|
518
|
+
let remaining = max;
|
|
519
|
+
return points.map((point) => {
|
|
520
|
+
if (!point.exemplars) return point;
|
|
521
|
+
const exemplars = point.exemplars.slice(0, remaining);
|
|
522
|
+
remaining -= exemplars.length;
|
|
523
|
+
return {
|
|
524
|
+
...point,
|
|
525
|
+
exemplars
|
|
526
|
+
};
|
|
527
|
+
});
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
//#endregion
|
|
531
|
+
//#region src/widget/timeWindow.ts
|
|
532
|
+
const PRESETS = [
|
|
533
|
+
{
|
|
534
|
+
id: "all",
|
|
535
|
+
label: "All time",
|
|
536
|
+
durationMs: null
|
|
537
|
+
},
|
|
538
|
+
{
|
|
539
|
+
id: "5m",
|
|
540
|
+
label: "Last 5m",
|
|
541
|
+
durationMs: 5 * 6e4
|
|
542
|
+
},
|
|
543
|
+
{
|
|
544
|
+
id: "15m",
|
|
545
|
+
label: "Last 15m",
|
|
546
|
+
durationMs: 15 * 6e4
|
|
547
|
+
},
|
|
548
|
+
{
|
|
549
|
+
id: "30m",
|
|
550
|
+
label: "Last 30m",
|
|
551
|
+
durationMs: 30 * 6e4
|
|
552
|
+
},
|
|
553
|
+
{
|
|
554
|
+
id: "1h",
|
|
555
|
+
label: "Last 1h",
|
|
556
|
+
durationMs: 60 * 6e4
|
|
557
|
+
},
|
|
558
|
+
{
|
|
559
|
+
id: "3h",
|
|
560
|
+
label: "Last 3h",
|
|
561
|
+
durationMs: 180 * 6e4
|
|
562
|
+
},
|
|
563
|
+
{
|
|
564
|
+
id: "6h",
|
|
565
|
+
label: "Last 6h",
|
|
566
|
+
durationMs: 360 * 6e4
|
|
567
|
+
},
|
|
568
|
+
{
|
|
569
|
+
id: "24h",
|
|
570
|
+
label: "Last 24h",
|
|
571
|
+
durationMs: 1440 * 6e4
|
|
572
|
+
},
|
|
573
|
+
{
|
|
574
|
+
id: "7d",
|
|
575
|
+
label: "Last 7d",
|
|
576
|
+
durationMs: 10080 * 6e4
|
|
577
|
+
}
|
|
578
|
+
];
|
|
579
|
+
/**
|
|
580
|
+
* Typed as the preset arm rather than the union: `serializeWindow` compares
|
|
581
|
+
* against `DEFAULT_SELECTION.preset`, which the widened union does not expose.
|
|
582
|
+
*/
|
|
583
|
+
const DEFAULT_SELECTION = {
|
|
584
|
+
type: "preset",
|
|
585
|
+
preset: "all"
|
|
586
|
+
};
|
|
587
|
+
const PRESET_BY_ID = new Map(PRESETS.map((p) => [p.id, p]));
|
|
588
|
+
/**
|
|
589
|
+
* Serialize for the URL hash, or `null` when the selection is the default.
|
|
590
|
+
*
|
|
591
|
+
* Omitting the default keeps shared links free of parameters that say nothing.
|
|
592
|
+
*/
|
|
593
|
+
function serializeWindow(selection) {
|
|
594
|
+
if (selection.type === "preset") return selection.preset === DEFAULT_SELECTION.preset ? null : selection.preset;
|
|
595
|
+
return `custom:${selection.start}:${selection.end}`;
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
//#endregion
|
|
599
|
+
//#region src/server/store/store.ts
|
|
600
|
+
/**
|
|
601
|
+
* Telemetry store, backed by `node:sqlite`.
|
|
602
|
+
*
|
|
603
|
+
* `node:sqlite` is in the standard library on Node 24, which this package
|
|
604
|
+
* already requires — so persistence, indexes and a real query engine cost no
|
|
605
|
+
* dependency at all. The store is what lets the viewer hold more than a
|
|
606
|
+
* screenful of telemetry, survive a restart, and answer a query without
|
|
607
|
+
* shipping every span to the browser first.
|
|
608
|
+
*
|
|
609
|
+
* Layout: `traces` holds one row per trace (the list view reads only this), and
|
|
610
|
+
* `spans` holds the detail. `service` is denormalised onto each span row so a
|
|
611
|
+
* span-level filter (`service = api AND duration > 100`) never needs a join.
|
|
612
|
+
*
|
|
613
|
+
* Concurrency: sqlite serialises writes itself and every method here is
|
|
614
|
+
* synchronous, so there is no interleaving to guard. WAL is enabled so a long
|
|
615
|
+
* read cannot block ingest.
|
|
616
|
+
*/
|
|
617
|
+
/**
|
|
618
|
+
* How far either side of a trace the deep-linked window reaches.
|
|
619
|
+
*
|
|
620
|
+
* A window of exactly the trace's own bounds is a correct answer that reads
|
|
621
|
+
* badly: the trace touches both edges with nothing around it. A minute of air
|
|
622
|
+
* shows what else was happening, and gives a zero-duration trace a window with
|
|
623
|
+
* width at all.
|
|
624
|
+
*/
|
|
625
|
+
const DEEP_LINK_PAD_MS = 6e4;
|
|
626
|
+
/**
|
|
627
|
+
* A link back into the viewer, pointing at a trace and optionally one span.
|
|
628
|
+
*
|
|
629
|
+
* **The window is the part that is easy to leave out and expensive to omit.**
|
|
630
|
+
* A link is read later than it is made, by an agent handing one to a person or
|
|
631
|
+
* a person pasting one into an incident channel, and by then the viewer's
|
|
632
|
+
* default range has rolled past the trace. The telemetry is still there and
|
|
633
|
+
* the view is not looking at it, which reads as "the tool lost my data".
|
|
634
|
+
*
|
|
635
|
+
* Serialized with the widget's own `serializeWindow`, and pinned by a test
|
|
636
|
+
* that parses the result with the widget's own `parseNavHash`, so the two ends
|
|
637
|
+
* cannot drift into agreeing on nothing.
|
|
638
|
+
*/
|
|
639
|
+
function traceDeepLink(traceId, bounds, spanId) {
|
|
640
|
+
const params = new URLSearchParams({
|
|
641
|
+
tab: "traces",
|
|
642
|
+
trace: traceId
|
|
643
|
+
});
|
|
644
|
+
if (spanId) params.set("span", spanId);
|
|
645
|
+
params.set("window", serializeWindow({
|
|
646
|
+
type: "custom",
|
|
647
|
+
start: Number(bounds.start_time) - DEEP_LINK_PAD_MS,
|
|
648
|
+
end: Number(bounds.end_time) + DEEP_LINK_PAD_MS
|
|
649
|
+
}) ?? "");
|
|
650
|
+
return `/#${params.toString()}`;
|
|
651
|
+
}
|
|
652
|
+
/**
|
|
653
|
+
* Shape of the schema this build knows how to read.
|
|
654
|
+
*
|
|
655
|
+
* Bump it whenever a change to the DDL cannot be reached by the migrations in
|
|
656
|
+
* this file. A `--db` file written by a newer build is refused rather than
|
|
657
|
+
* queried, because the alternative is an opaque "no such column" thrown from
|
|
658
|
+
* whichever query happens to run first.
|
|
659
|
+
*
|
|
660
|
+
* `0` means a file written before this guard existed. Those are adopted and
|
|
661
|
+
* stamped, since the migrations above already cover every shape they can be in.
|
|
662
|
+
*/
|
|
663
|
+
const SCHEMA_VERSION = 1;
|
|
664
|
+
const DEFAULT_MAX_TRACES = 1e5;
|
|
665
|
+
const DEFAULT_MAX_METRIC_POINTS = 5e3;
|
|
666
|
+
const DEFAULT_MAX_LOGS = 1e5;
|
|
667
|
+
const DEFAULT_LIMIT = 100;
|
|
668
|
+
const MAX_QUERY_PAGE_SIZE = 1e3;
|
|
669
|
+
/**
|
|
670
|
+
* Spans scanned per side of a comparison.
|
|
671
|
+
*
|
|
672
|
+
* A cohort is a statistical population, not a page: the fractions are only as
|
|
673
|
+
* honest as the sample, so this is deliberately far larger than a list page.
|
|
674
|
+
*/
|
|
675
|
+
const COHORT_ROW_LIMIT = 2e4;
|
|
676
|
+
/**
|
|
677
|
+
* Query vocabulary for logs.
|
|
678
|
+
*
|
|
679
|
+
* `severity` and `severity_number` are both exposed deliberately: the text is
|
|
680
|
+
* what people read, but "error and above" is a numeric comparison and string
|
|
681
|
+
* ordering cannot express it. Anything not named here is a log attribute.
|
|
682
|
+
*/
|
|
683
|
+
const LOG_SCHEMA = {
|
|
684
|
+
columns: {
|
|
685
|
+
service: {
|
|
686
|
+
column: "service",
|
|
687
|
+
type: "string"
|
|
688
|
+
},
|
|
689
|
+
severity: {
|
|
690
|
+
column: "severity_text",
|
|
691
|
+
type: "string"
|
|
692
|
+
},
|
|
693
|
+
severity_number: {
|
|
694
|
+
column: "severity_number",
|
|
695
|
+
type: "number"
|
|
696
|
+
},
|
|
697
|
+
trace_id: {
|
|
698
|
+
column: "trace_id",
|
|
699
|
+
type: "string"
|
|
700
|
+
},
|
|
701
|
+
span_id: {
|
|
702
|
+
column: "span_id",
|
|
703
|
+
type: "string"
|
|
704
|
+
},
|
|
705
|
+
body: {
|
|
706
|
+
column: "body_text",
|
|
707
|
+
type: "string"
|
|
708
|
+
}
|
|
709
|
+
},
|
|
710
|
+
attributesColumn: "attributes",
|
|
711
|
+
freeTextColumns: [
|
|
712
|
+
"body",
|
|
713
|
+
"service",
|
|
714
|
+
"severity",
|
|
715
|
+
"trace_id"
|
|
716
|
+
],
|
|
717
|
+
attributeIndex: {
|
|
718
|
+
table: "attribute_occurrences",
|
|
719
|
+
signal: "logs",
|
|
720
|
+
entitySql: "id"
|
|
721
|
+
}
|
|
722
|
+
};
|
|
723
|
+
/**
|
|
724
|
+
* Query vocabulary for spans.
|
|
725
|
+
*
|
|
726
|
+
* Anything not named here is treated as a span attribute, so every attribute a
|
|
727
|
+
* service emits is queryable without being declared.
|
|
728
|
+
*/
|
|
729
|
+
/**
|
|
730
|
+
* Ties a child row to the span row being filtered.
|
|
731
|
+
*
|
|
732
|
+
* `s` is the alias `queryTraces` gives the spans table, the same coupling
|
|
733
|
+
* `attributeIndex.entitySql` already carries: these are trusted SQL fragments
|
|
734
|
+
* that must stay in step with the FROM clause the store writes. The event and
|
|
735
|
+
* link tests fail if the alias moves, which is what keeps them in step.
|
|
736
|
+
*/
|
|
737
|
+
const SPAN_ROW_JOIN = "rel.trace_id = s.trace_id AND rel.span_id = s.span_id";
|
|
738
|
+
const SPAN_SCHEMA = {
|
|
739
|
+
columns: {
|
|
740
|
+
service: {
|
|
741
|
+
column: "service",
|
|
742
|
+
type: "string"
|
|
743
|
+
},
|
|
744
|
+
name: {
|
|
745
|
+
column: "name",
|
|
746
|
+
type: "string"
|
|
747
|
+
},
|
|
748
|
+
kind: {
|
|
749
|
+
column: "kind",
|
|
750
|
+
type: "string"
|
|
751
|
+
},
|
|
752
|
+
duration: {
|
|
753
|
+
column: "duration",
|
|
754
|
+
type: "number"
|
|
755
|
+
},
|
|
756
|
+
status: {
|
|
757
|
+
column: "status_code",
|
|
758
|
+
type: "string"
|
|
759
|
+
},
|
|
760
|
+
trace_id: {
|
|
761
|
+
column: "trace_id",
|
|
762
|
+
type: "string"
|
|
763
|
+
},
|
|
764
|
+
span_id: {
|
|
765
|
+
column: "span_id",
|
|
766
|
+
type: "string"
|
|
767
|
+
},
|
|
768
|
+
parent_span_id: {
|
|
769
|
+
column: "parent_span_id",
|
|
770
|
+
type: "string"
|
|
771
|
+
}
|
|
772
|
+
},
|
|
773
|
+
attributesColumn: "attributes",
|
|
774
|
+
freeTextColumns: [
|
|
775
|
+
"name",
|
|
776
|
+
"service",
|
|
777
|
+
"trace_id"
|
|
778
|
+
],
|
|
779
|
+
attributeIndex: {
|
|
780
|
+
table: "attribute_occurrences",
|
|
781
|
+
signal: "traces",
|
|
782
|
+
entitySql: "(trace_id || ':' || span_id)"
|
|
783
|
+
},
|
|
784
|
+
related: {
|
|
785
|
+
"event.name": {
|
|
786
|
+
table: "span_events",
|
|
787
|
+
column: "name",
|
|
788
|
+
joinSql: SPAN_ROW_JOIN
|
|
789
|
+
},
|
|
790
|
+
"link.trace_id": {
|
|
791
|
+
table: "span_links",
|
|
792
|
+
column: "linked_trace_id",
|
|
793
|
+
joinSql: SPAN_ROW_JOIN
|
|
794
|
+
},
|
|
795
|
+
"link.span_id": {
|
|
796
|
+
table: "span_links",
|
|
797
|
+
column: "linked_span_id",
|
|
798
|
+
joinSql: SPAN_ROW_JOIN
|
|
799
|
+
}
|
|
800
|
+
}
|
|
801
|
+
};
|
|
802
|
+
const METRIC_SCHEMA = {
|
|
803
|
+
columns: {
|
|
804
|
+
name: {
|
|
805
|
+
column: "name",
|
|
806
|
+
type: "string"
|
|
807
|
+
},
|
|
808
|
+
kind: {
|
|
809
|
+
column: "kind",
|
|
810
|
+
type: "string"
|
|
811
|
+
},
|
|
812
|
+
unit: {
|
|
813
|
+
column: "unit",
|
|
814
|
+
type: "string"
|
|
815
|
+
},
|
|
816
|
+
description: {
|
|
817
|
+
column: "description",
|
|
818
|
+
type: "string"
|
|
819
|
+
},
|
|
820
|
+
service: {
|
|
821
|
+
column: "service",
|
|
822
|
+
type: "string"
|
|
823
|
+
}
|
|
824
|
+
},
|
|
825
|
+
attributesColumn: "resource",
|
|
826
|
+
freeTextColumns: [
|
|
827
|
+
"name",
|
|
828
|
+
"description",
|
|
829
|
+
"unit",
|
|
830
|
+
"service"
|
|
831
|
+
]
|
|
832
|
+
};
|
|
833
|
+
const SPANS_TABLE_DDL = `
|
|
834
|
+
CREATE TABLE IF NOT EXISTS spans (
|
|
835
|
+
span_id TEXT NOT NULL,
|
|
836
|
+
trace_id TEXT NOT NULL,
|
|
837
|
+
parent_span_id TEXT,
|
|
838
|
+
name TEXT NOT NULL,
|
|
839
|
+
kind TEXT NOT NULL,
|
|
840
|
+
service TEXT,
|
|
841
|
+
start_time INTEGER NOT NULL,
|
|
842
|
+
end_time INTEGER NOT NULL,
|
|
843
|
+
duration INTEGER NOT NULL,
|
|
844
|
+
status_code TEXT NOT NULL,
|
|
845
|
+
status_message TEXT,
|
|
846
|
+
attributes TEXT NOT NULL DEFAULT '{}',
|
|
847
|
+
events TEXT,
|
|
848
|
+
links TEXT,
|
|
849
|
+
scope TEXT,
|
|
850
|
+
PRIMARY KEY (trace_id, span_id)
|
|
851
|
+
);
|
|
852
|
+
`;
|
|
853
|
+
const DDL = `
|
|
854
|
+
PRAGMA journal_mode = WAL;
|
|
855
|
+
PRAGMA synchronous = NORMAL;
|
|
856
|
+
|
|
857
|
+
CREATE TABLE IF NOT EXISTS traces (
|
|
858
|
+
trace_id TEXT PRIMARY KEY,
|
|
859
|
+
correlation_id TEXT,
|
|
860
|
+
service TEXT,
|
|
861
|
+
root_span_id TEXT,
|
|
862
|
+
start_time INTEGER NOT NULL,
|
|
863
|
+
end_time INTEGER NOT NULL,
|
|
864
|
+
duration INTEGER NOT NULL,
|
|
865
|
+
status TEXT NOT NULL,
|
|
866
|
+
partial INTEGER NOT NULL DEFAULT 0
|
|
867
|
+
);
|
|
868
|
+
|
|
869
|
+
${SPANS_TABLE_DDL}
|
|
870
|
+
|
|
871
|
+
CREATE TABLE IF NOT EXISTS metric_series (
|
|
872
|
+
series_id TEXT PRIMARY KEY,
|
|
873
|
+
name TEXT NOT NULL,
|
|
874
|
+
unit TEXT,
|
|
875
|
+
description TEXT,
|
|
876
|
+
kind TEXT NOT NULL,
|
|
877
|
+
temporality TEXT,
|
|
878
|
+
monotonic INTEGER,
|
|
879
|
+
service TEXT NOT NULL,
|
|
880
|
+
scope_name TEXT,
|
|
881
|
+
scope_version TEXT,
|
|
882
|
+
resource TEXT NOT NULL DEFAULT '{}',
|
|
883
|
+
attributes TEXT NOT NULL DEFAULT '{}'
|
|
884
|
+
);
|
|
885
|
+
|
|
886
|
+
CREATE TABLE IF NOT EXISTS metric_points (
|
|
887
|
+
series_id TEXT NOT NULL,
|
|
888
|
+
timestamp INTEGER NOT NULL,
|
|
889
|
+
start_timestamp INTEGER,
|
|
890
|
+
value REAL,
|
|
891
|
+
count REAL,
|
|
892
|
+
sum REAL,
|
|
893
|
+
min REAL,
|
|
894
|
+
max REAL,
|
|
895
|
+
bucket_counts TEXT,
|
|
896
|
+
explicit_bounds TEXT,
|
|
897
|
+
exp_scale INTEGER,
|
|
898
|
+
zero_count REAL,
|
|
899
|
+
zero_threshold REAL,
|
|
900
|
+
positive_buckets TEXT,
|
|
901
|
+
negative_buckets TEXT,
|
|
902
|
+
quantiles TEXT,
|
|
903
|
+
exemplars TEXT,
|
|
904
|
+
PRIMARY KEY (series_id, timestamp)
|
|
905
|
+
);
|
|
906
|
+
|
|
907
|
+
CREATE TABLE IF NOT EXISTS logs (
|
|
908
|
+
id TEXT PRIMARY KEY,
|
|
909
|
+
timestamp INTEGER NOT NULL,
|
|
910
|
+
service TEXT,
|
|
911
|
+
severity_text TEXT,
|
|
912
|
+
severity_number INTEGER,
|
|
913
|
+
trace_id TEXT,
|
|
914
|
+
span_id TEXT,
|
|
915
|
+
-- The body as displayed: what free-text search matches against.
|
|
916
|
+
body_text TEXT NOT NULL DEFAULT '',
|
|
917
|
+
-- The body as sent. Structured bodies must survive as structure; a JSON body
|
|
918
|
+
-- flattened to text is a log nobody can read.
|
|
919
|
+
body_json TEXT,
|
|
920
|
+
attributes TEXT NOT NULL DEFAULT '{}',
|
|
921
|
+
resource TEXT
|
|
922
|
+
);
|
|
923
|
+
|
|
924
|
+
CREATE TABLE IF NOT EXISTS attribute_values (
|
|
925
|
+
signal TEXT NOT NULL,
|
|
926
|
+
key TEXT NOT NULL,
|
|
927
|
+
value_json TEXT NOT NULL,
|
|
928
|
+
value_text TEXT NOT NULL,
|
|
929
|
+
seen_count INTEGER NOT NULL DEFAULT 1,
|
|
930
|
+
last_seen INTEGER NOT NULL,
|
|
931
|
+
PRIMARY KEY (signal, key, value_json)
|
|
932
|
+
);
|
|
933
|
+
|
|
934
|
+
-- Span events and links, normalized so they can be filtered.
|
|
935
|
+
--
|
|
936
|
+
-- The JSON on the span row stays: it is the payload the waterfall renders, and
|
|
937
|
+
-- these two tables are the query index over it, the same split that
|
|
938
|
+
-- attribute_occurrences already makes against the attributes blob. Both are
|
|
939
|
+
-- written and deleted with their span, and backfilled on open for a --db
|
|
940
|
+
-- file written before they existed.
|
|
941
|
+
CREATE TABLE IF NOT EXISTS span_events (
|
|
942
|
+
trace_id TEXT NOT NULL,
|
|
943
|
+
span_id TEXT NOT NULL,
|
|
944
|
+
idx INTEGER NOT NULL,
|
|
945
|
+
name TEXT NOT NULL,
|
|
946
|
+
timestamp INTEGER,
|
|
947
|
+
PRIMARY KEY (trace_id, span_id, idx)
|
|
948
|
+
);
|
|
949
|
+
|
|
950
|
+
CREATE TABLE IF NOT EXISTS span_links (
|
|
951
|
+
trace_id TEXT NOT NULL,
|
|
952
|
+
span_id TEXT NOT NULL,
|
|
953
|
+
idx INTEGER NOT NULL,
|
|
954
|
+
linked_trace_id TEXT NOT NULL,
|
|
955
|
+
linked_span_id TEXT NOT NULL,
|
|
956
|
+
PRIMARY KEY (trace_id, span_id, idx)
|
|
957
|
+
);
|
|
958
|
+
|
|
959
|
+
CREATE TABLE IF NOT EXISTS attribute_occurrences (
|
|
960
|
+
signal TEXT NOT NULL,
|
|
961
|
+
entity_id TEXT NOT NULL,
|
|
962
|
+
key TEXT NOT NULL,
|
|
963
|
+
value_json TEXT NOT NULL,
|
|
964
|
+
PRIMARY KEY (signal, entity_id, key)
|
|
965
|
+
);
|
|
966
|
+
|
|
967
|
+
CREATE INDEX IF NOT EXISTS idx_traces_start ON traces(start_time DESC);
|
|
968
|
+
CREATE INDEX IF NOT EXISTS idx_logs_time ON logs(timestamp DESC);
|
|
969
|
+
CREATE INDEX IF NOT EXISTS idx_logs_trace ON logs(trace_id);
|
|
970
|
+
CREATE INDEX IF NOT EXISTS idx_logs_severity ON logs(severity_number, timestamp DESC);
|
|
971
|
+
CREATE INDEX IF NOT EXISTS idx_series_name ON metric_series(name);
|
|
972
|
+
CREATE INDEX IF NOT EXISTS idx_points_time ON metric_points(series_id, timestamp);
|
|
973
|
+
CREATE INDEX IF NOT EXISTS idx_spans_trace ON spans(trace_id);
|
|
974
|
+
CREATE INDEX IF NOT EXISTS idx_spans_start ON spans(start_time DESC);
|
|
975
|
+
CREATE INDEX IF NOT EXISTS idx_spans_service ON spans(service, start_time DESC);
|
|
976
|
+
CREATE INDEX IF NOT EXISTS idx_spans_duration ON spans(duration DESC);
|
|
977
|
+
CREATE INDEX IF NOT EXISTS idx_attribute_value ON attribute_values(signal, value_text, key);
|
|
978
|
+
CREATE INDEX IF NOT EXISTS idx_attribute_equality ON attribute_occurrences(signal, key, value_json, entity_id);
|
|
979
|
+
CREATE INDEX IF NOT EXISTS idx_span_events_name ON span_events(name);
|
|
980
|
+
CREATE INDEX IF NOT EXISTS idx_span_links_target ON span_links(linked_trace_id);
|
|
981
|
+
`;
|
|
982
|
+
var DevtoolsStore = class {
|
|
983
|
+
db;
|
|
984
|
+
maxTraces;
|
|
985
|
+
maxMetricPoints;
|
|
986
|
+
maxLogs;
|
|
987
|
+
maxBytes;
|
|
988
|
+
constructor(options = {}) {
|
|
989
|
+
this.db = new node_sqlite.DatabaseSync(options.path ?? ":memory:");
|
|
990
|
+
this.maxTraces = options.maxTraces ?? DEFAULT_MAX_TRACES;
|
|
991
|
+
this.maxMetricPoints = options.maxMetricPoints ?? DEFAULT_MAX_METRIC_POINTS;
|
|
992
|
+
this.maxLogs = options.maxLogs ?? DEFAULT_MAX_LOGS;
|
|
993
|
+
this.maxBytes = options.maxBytes ?? (options.path ? 2 * 1024 ** 3 : 512 * 1024 ** 2);
|
|
994
|
+
this.guardSchemaVersion(options.path);
|
|
995
|
+
this.migrateSpanIdentity();
|
|
996
|
+
this.db.exec(DDL);
|
|
997
|
+
this.migrateMetricFidelity();
|
|
998
|
+
this.backfillAttributeDictionary();
|
|
999
|
+
this.backfillSpanChildren();
|
|
1000
|
+
this.registerRegexp();
|
|
1001
|
+
this.db.exec(`PRAGMA user_version = ${SCHEMA_VERSION}`);
|
|
1002
|
+
}
|
|
1003
|
+
/** Refuse a file this build cannot read, before any query touches it. */
|
|
1004
|
+
guardSchemaVersion(path) {
|
|
1005
|
+
const found = pragmaNumber(this.db, "user_version");
|
|
1006
|
+
if (found === 0 || found === SCHEMA_VERSION) return;
|
|
1007
|
+
this.db.close();
|
|
1008
|
+
throw new Error(`${path ?? ":memory:"} was written by a different autotel-devtools schema (found version ${found}, this build reads ${SCHEMA_VERSION}). Point --db at another file, or delete this one to start fresh.`);
|
|
1009
|
+
}
|
|
1010
|
+
/** Upgrade databases created before span identity included the trace id. */
|
|
1011
|
+
migrateSpanIdentity() {
|
|
1012
|
+
if (!this.db.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'spans'").get()) return;
|
|
1013
|
+
if (this.db.prepare("PRAGMA table_info(spans)").all().filter((column) => Number(column.pk) > 0).sort((left, right) => Number(left.pk) - Number(right.pk)).map((column) => column.name).join(",") === "trace_id,span_id") return;
|
|
1014
|
+
this.db.exec("BEGIN");
|
|
1015
|
+
try {
|
|
1016
|
+
this.db.exec("ALTER TABLE spans RENAME TO spans_legacy_span_id_pk");
|
|
1017
|
+
this.db.exec(SPANS_TABLE_DDL);
|
|
1018
|
+
this.db.exec(`
|
|
1019
|
+
INSERT INTO spans (span_id, trace_id, parent_span_id, name, kind, service,
|
|
1020
|
+
start_time, end_time, duration, status_code,
|
|
1021
|
+
status_message, attributes, events, links, scope)
|
|
1022
|
+
SELECT span_id, trace_id, parent_span_id, name, kind, service,
|
|
1023
|
+
start_time, end_time, duration, status_code,
|
|
1024
|
+
status_message, attributes, events, links, scope
|
|
1025
|
+
FROM spans_legacy_span_id_pk
|
|
1026
|
+
`);
|
|
1027
|
+
this.db.exec("DROP TABLE spans_legacy_span_id_pk");
|
|
1028
|
+
this.db.exec("COMMIT");
|
|
1029
|
+
} catch (error) {
|
|
1030
|
+
this.db.exec("ROLLBACK");
|
|
1031
|
+
throw error;
|
|
1032
|
+
}
|
|
1033
|
+
}
|
|
1034
|
+
/** Add lossless metric fields to databases created by earlier releases. */
|
|
1035
|
+
migrateMetricFidelity() {
|
|
1036
|
+
const hadResource = this.hasColumn("metric_series", "resource");
|
|
1037
|
+
this.addColumnIfMissing("metric_series", "resource", "TEXT NOT NULL DEFAULT '{}'");
|
|
1038
|
+
this.addColumnIfMissing("metric_points", "exp_scale", "INTEGER");
|
|
1039
|
+
this.addColumnIfMissing("metric_points", "zero_count", "REAL");
|
|
1040
|
+
this.addColumnIfMissing("metric_points", "zero_threshold", "REAL");
|
|
1041
|
+
this.addColumnIfMissing("metric_points", "positive_buckets", "TEXT");
|
|
1042
|
+
this.addColumnIfMissing("metric_points", "negative_buckets", "TEXT");
|
|
1043
|
+
if (!hadResource) this.migrateLegacyMetricSeriesIdentity();
|
|
1044
|
+
}
|
|
1045
|
+
hasColumn(table, column) {
|
|
1046
|
+
return this.db.prepare(`PRAGMA table_info(${table})`).all().some((item) => item.name === column);
|
|
1047
|
+
}
|
|
1048
|
+
addColumnIfMissing(table, column, declaration) {
|
|
1049
|
+
if (this.hasColumn(table, column)) return;
|
|
1050
|
+
this.db.exec(`ALTER TABLE ${table} ADD COLUMN ${column} ${declaration}`);
|
|
1051
|
+
}
|
|
1052
|
+
migrateLegacyMetricSeriesIdentity() {
|
|
1053
|
+
const rows = this.db.prepare("SELECT * FROM metric_series").all();
|
|
1054
|
+
if (rows.length === 0) return;
|
|
1055
|
+
const updatePoints = this.db.prepare("UPDATE metric_points SET series_id = ? WHERE series_id = ?");
|
|
1056
|
+
const updateSeries = this.db.prepare("UPDATE metric_series SET series_id = ?, resource = ? WHERE series_id = ?");
|
|
1057
|
+
this.db.exec("BEGIN");
|
|
1058
|
+
try {
|
|
1059
|
+
for (const row of rows) {
|
|
1060
|
+
const resource = { "service.name": row.service };
|
|
1061
|
+
const nextId = seriesIdentity({
|
|
1062
|
+
name: row.name,
|
|
1063
|
+
unit: row.unit ?? void 0,
|
|
1064
|
+
kind: row.kind,
|
|
1065
|
+
temporality: row.temporality ?? void 0,
|
|
1066
|
+
monotonic: row.monotonic === null ? void 0 : row.monotonic === 1,
|
|
1067
|
+
service: row.service,
|
|
1068
|
+
scope: row.scope_name ? {
|
|
1069
|
+
name: row.scope_name,
|
|
1070
|
+
version: row.scope_version ?? void 0
|
|
1071
|
+
} : void 0,
|
|
1072
|
+
resource,
|
|
1073
|
+
points: []
|
|
1074
|
+
}, parseJson(row.attributes) ?? {});
|
|
1075
|
+
updatePoints.run(nextId, row.series_id);
|
|
1076
|
+
updateSeries.run(nextId, stableJson(resource), row.series_id);
|
|
1077
|
+
}
|
|
1078
|
+
this.db.exec("COMMIT");
|
|
1079
|
+
} catch (error) {
|
|
1080
|
+
this.db.exec("ROLLBACK");
|
|
1081
|
+
throw error;
|
|
1082
|
+
}
|
|
1083
|
+
}
|
|
1084
|
+
/**
|
|
1085
|
+
* SQLite parses `REGEXP` but ships no implementation — using it without
|
|
1086
|
+
* registering one is a runtime error, not a syntax error. The language offers
|
|
1087
|
+
* regex matching, so the function has to exist.
|
|
1088
|
+
*
|
|
1089
|
+
* An invalid pattern matches nothing rather than throwing: the user is
|
|
1090
|
+
* probably mid-typing, and a thrown error would blank the whole result list.
|
|
1091
|
+
*/
|
|
1092
|
+
registerRegexp() {
|
|
1093
|
+
this.db.function("regexp", (pattern, value) => {
|
|
1094
|
+
if (value == null) return 0;
|
|
1095
|
+
try {
|
|
1096
|
+
return new RegExp(String(pattern)).test(String(value)) ? 1 : 0;
|
|
1097
|
+
} catch {
|
|
1098
|
+
return 0;
|
|
1099
|
+
}
|
|
1100
|
+
});
|
|
1101
|
+
}
|
|
1102
|
+
ingestTraces(traces) {
|
|
1103
|
+
if (traces.length === 0) return;
|
|
1104
|
+
const upsertTrace = this.db.prepare(`
|
|
1105
|
+
INSERT INTO traces (trace_id, correlation_id, service, root_span_id,
|
|
1106
|
+
start_time, end_time, duration, status, partial)
|
|
1107
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
1108
|
+
ON CONFLICT(trace_id) DO UPDATE SET
|
|
1109
|
+
-- A trace grows as spans arrive: widen the bounds rather than replacing
|
|
1110
|
+
-- them, so a late span cannot shrink the trace it belongs to.
|
|
1111
|
+
start_time = min(start_time, excluded.start_time),
|
|
1112
|
+
end_time = max(end_time, excluded.end_time),
|
|
1113
|
+
duration = max(end_time, excluded.end_time) - min(start_time, excluded.start_time),
|
|
1114
|
+
status = CASE WHEN excluded.status = 'ERROR' THEN 'ERROR' ELSE status END,
|
|
1115
|
+
service = COALESCE(excluded.service, service),
|
|
1116
|
+
root_span_id = CASE
|
|
1117
|
+
WHEN traces.partial = 1 AND excluded.partial = 0
|
|
1118
|
+
THEN COALESCE(excluded.root_span_id, root_span_id)
|
|
1119
|
+
ELSE COALESCE(root_span_id, excluded.root_span_id)
|
|
1120
|
+
END,
|
|
1121
|
+
-- Once the true root has arrived, a delayed partial replay cannot make
|
|
1122
|
+
-- the stored trace provisional again.
|
|
1123
|
+
partial = min(partial, excluded.partial)
|
|
1124
|
+
`);
|
|
1125
|
+
const upsertSpan = this.db.prepare(`
|
|
1126
|
+
INSERT INTO spans (span_id, trace_id, parent_span_id, name, kind, service,
|
|
1127
|
+
start_time, end_time, duration, status_code,
|
|
1128
|
+
status_message, attributes, events, links, scope)
|
|
1129
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
1130
|
+
ON CONFLICT(trace_id, span_id) DO UPDATE SET
|
|
1131
|
+
name = excluded.name,
|
|
1132
|
+
end_time = excluded.end_time,
|
|
1133
|
+
duration = excluded.duration,
|
|
1134
|
+
status_code = excluded.status_code,
|
|
1135
|
+
status_message = excluded.status_message,
|
|
1136
|
+
attributes = excluded.attributes,
|
|
1137
|
+
events = excluded.events,
|
|
1138
|
+
links = excluded.links
|
|
1139
|
+
`);
|
|
1140
|
+
const upsertAttribute = this.attributeUpsert();
|
|
1141
|
+
const upsertOccurrence = this.attributeOccurrenceUpsert();
|
|
1142
|
+
const clearOccurrences = this.db.prepare("DELETE FROM attribute_occurrences WHERE signal = 'traces' AND entity_id = ?");
|
|
1143
|
+
const clearEvents = this.db.prepare("DELETE FROM span_events WHERE trace_id = ? AND span_id = ?");
|
|
1144
|
+
const clearLinks = this.db.prepare("DELETE FROM span_links WHERE trace_id = ? AND span_id = ?");
|
|
1145
|
+
const insertEvent = this.db.prepare("INSERT INTO span_events (trace_id, span_id, idx, name, timestamp) VALUES (?, ?, ?, ?, ?)");
|
|
1146
|
+
const insertLink = this.db.prepare("INSERT INTO span_links (trace_id, span_id, idx, linked_trace_id, linked_span_id) VALUES (?, ?, ?, ?, ?)");
|
|
1147
|
+
this.db.exec("BEGIN");
|
|
1148
|
+
try {
|
|
1149
|
+
for (const trace of traces) {
|
|
1150
|
+
upsertTrace.run(trace.traceId, trace.correlationId ?? null, trace.service ?? null, trace.rootSpan?.spanId ?? null, trace.startTime, trace.endTime, trace.duration, trace.status, trace.partial ? 1 : 0);
|
|
1151
|
+
for (const span of trace.spans ?? []) {
|
|
1152
|
+
upsertSpan.run(span.spanId, span.traceId, span.parentSpanId ?? null, span.name, span.kind, spanService(span, trace.service), span.startTime, span.endTime, span.duration, span.status?.code ?? "UNSET", span.status?.message ?? null, JSON.stringify(span.attributes ?? {}), span.events?.length ? JSON.stringify(span.events) : null, span.links?.length ? JSON.stringify(span.links) : null, span.scope ? JSON.stringify(span.scope) : null);
|
|
1153
|
+
const entityId = `${span.traceId}:${span.spanId}`;
|
|
1154
|
+
clearOccurrences.run(entityId);
|
|
1155
|
+
this.indexAttributes(upsertAttribute, upsertOccurrence, "traces", entityId, span.attributes ?? {}, span.startTime);
|
|
1156
|
+
clearEvents.run(span.traceId, span.spanId);
|
|
1157
|
+
span.events?.forEach((event, index) => {
|
|
1158
|
+
insertEvent.run(span.traceId, span.spanId, index, event.name, event.timestamp ?? null);
|
|
1159
|
+
});
|
|
1160
|
+
clearLinks.run(span.traceId, span.spanId);
|
|
1161
|
+
span.links?.forEach((link, index) => {
|
|
1162
|
+
insertLink.run(span.traceId, span.spanId, index, link.traceId, link.spanId);
|
|
1163
|
+
});
|
|
1164
|
+
}
|
|
1165
|
+
}
|
|
1166
|
+
this.db.exec("COMMIT");
|
|
1167
|
+
} catch (error) {
|
|
1168
|
+
this.db.exec("ROLLBACK");
|
|
1169
|
+
throw error;
|
|
1170
|
+
}
|
|
1171
|
+
}
|
|
1172
|
+
/**
|
|
1173
|
+
* Find traces matching a query.
|
|
1174
|
+
*
|
|
1175
|
+
* The filter runs against *spans* and the results are traces: a trace matches
|
|
1176
|
+
* when any of its spans does. That is what makes `http.status_code = 500`
|
|
1177
|
+
* useful — the attribute is on one span, but the thing you want to open is
|
|
1178
|
+
* the trace containing it.
|
|
1179
|
+
*/
|
|
1180
|
+
queryTraces(args) {
|
|
1181
|
+
const parsed = require_parse.parse(args.query ?? "");
|
|
1182
|
+
if (!parsed.ok) return {
|
|
1183
|
+
traces: [],
|
|
1184
|
+
nextCursor: null,
|
|
1185
|
+
errors: parsed.errors
|
|
1186
|
+
};
|
|
1187
|
+
const { sql: whereSql, params } = require_parse.compileWhere(parsed.node, SPAN_SCHEMA);
|
|
1188
|
+
const limit = Math.min(MAX_QUERY_PAGE_SIZE, Math.max(1, args.limit ?? DEFAULT_LIMIT));
|
|
1189
|
+
const clauses = [`s.trace_id = t.trace_id`, whereSql];
|
|
1190
|
+
const queryParams = [...params];
|
|
1191
|
+
if (args.window) {
|
|
1192
|
+
clauses.push("t.start_time >= ? AND t.start_time <= ?");
|
|
1193
|
+
queryParams.push(args.window.start, args.window.end);
|
|
1194
|
+
}
|
|
1195
|
+
const cursor = decodeCursor(args.cursor);
|
|
1196
|
+
if (cursor) {
|
|
1197
|
+
clauses.push("(t.start_time < ? OR (t.start_time = ? AND t.trace_id < ?))");
|
|
1198
|
+
queryParams.push(cursor.startTime, cursor.startTime, cursor.traceId);
|
|
1199
|
+
}
|
|
1200
|
+
const rows = this.db.prepare(`SELECT t.* FROM traces t
|
|
1201
|
+
WHERE EXISTS (SELECT 1 FROM spans s WHERE ${clauses.join(" AND ")})
|
|
1202
|
+
ORDER BY t.start_time DESC, t.trace_id DESC
|
|
1203
|
+
LIMIT ?`).all(...queryParams, limit + 1);
|
|
1204
|
+
const hasMore = rows.length > limit;
|
|
1205
|
+
const page = hasMore ? rows.slice(0, limit) : rows;
|
|
1206
|
+
const last = page[page.length - 1];
|
|
1207
|
+
return {
|
|
1208
|
+
traces: this.hydrateTracePage(page),
|
|
1209
|
+
nextCursor: hasMore && last ? encodeCursor({
|
|
1210
|
+
startTime: Number(last.start_time),
|
|
1211
|
+
traceId: last.trace_id
|
|
1212
|
+
}) : null
|
|
1213
|
+
};
|
|
1214
|
+
}
|
|
1215
|
+
ingestLogs(logs) {
|
|
1216
|
+
if (logs.length === 0) return;
|
|
1217
|
+
const upsert = this.db.prepare(`
|
|
1218
|
+
INSERT INTO logs (id, timestamp, service, severity_text, severity_number,
|
|
1219
|
+
trace_id, span_id, body_text, body_json, attributes,
|
|
1220
|
+
resource)
|
|
1221
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
1222
|
+
ON CONFLICT(id) DO NOTHING
|
|
1223
|
+
`);
|
|
1224
|
+
const upsertAttribute = this.attributeUpsert();
|
|
1225
|
+
const upsertOccurrence = this.attributeOccurrenceUpsert();
|
|
1226
|
+
this.db.exec("BEGIN");
|
|
1227
|
+
try {
|
|
1228
|
+
for (const log of logs) {
|
|
1229
|
+
const structured = typeof log.body === "object" && log.body !== null;
|
|
1230
|
+
const inserted = upsert.run(log.id, log.timestamp, log.resourceName ?? null, log.severityText ?? null, log.severityNumber ?? null, log.traceId ?? null, log.spanId ?? null, structured ? JSON.stringify(log.body) : String(log.body ?? ""), structured ? JSON.stringify(log.body) : null, JSON.stringify(log.attributes ?? {}), log.resource ? JSON.stringify(log.resource) : null);
|
|
1231
|
+
if (Number(inserted.changes) === 0) continue;
|
|
1232
|
+
this.indexAttributes(upsertAttribute, upsertOccurrence, "logs", log.id, log.attributes ?? {}, log.timestamp);
|
|
1233
|
+
}
|
|
1234
|
+
this.db.exec("COMMIT");
|
|
1235
|
+
} catch (error) {
|
|
1236
|
+
this.db.exec("ROLLBACK");
|
|
1237
|
+
throw error;
|
|
1238
|
+
}
|
|
1239
|
+
}
|
|
1240
|
+
queryLogs(args) {
|
|
1241
|
+
const parsed = require_parse.parse(args.query ?? "");
|
|
1242
|
+
if (!parsed.ok) return {
|
|
1243
|
+
logs: [],
|
|
1244
|
+
nextCursor: null,
|
|
1245
|
+
errors: parsed.errors
|
|
1246
|
+
};
|
|
1247
|
+
const { sql: whereSql, params } = require_parse.compileWhere(parsed.node, LOG_SCHEMA);
|
|
1248
|
+
const limit = Math.min(MAX_QUERY_PAGE_SIZE, Math.max(1, args.limit ?? DEFAULT_LIMIT));
|
|
1249
|
+
const clauses = [whereSql];
|
|
1250
|
+
const queryParams = [...params];
|
|
1251
|
+
if (args.window) {
|
|
1252
|
+
clauses.push("timestamp >= ? AND timestamp <= ?");
|
|
1253
|
+
queryParams.push(args.window.start, args.window.end);
|
|
1254
|
+
}
|
|
1255
|
+
const cursor = decodeCursor(args.cursor);
|
|
1256
|
+
if (cursor) {
|
|
1257
|
+
clauses.push("(timestamp < ? OR (timestamp = ? AND id < ?))");
|
|
1258
|
+
queryParams.push(cursor.startTime, cursor.startTime, cursor.traceId);
|
|
1259
|
+
}
|
|
1260
|
+
const rows = this.db.prepare(`SELECT * FROM logs
|
|
1261
|
+
WHERE ${clauses.join(" AND ")}
|
|
1262
|
+
ORDER BY timestamp DESC, id DESC
|
|
1263
|
+
LIMIT ?`).all(...queryParams, limit + 1);
|
|
1264
|
+
const hasMore = rows.length > limit;
|
|
1265
|
+
const page = hasMore ? rows.slice(0, limit) : rows;
|
|
1266
|
+
const last = page[page.length - 1];
|
|
1267
|
+
return {
|
|
1268
|
+
logs: page.map(hydrateLog),
|
|
1269
|
+
nextCursor: hasMore && last ? encodeCursor({
|
|
1270
|
+
startTime: Number(last.timestamp),
|
|
1271
|
+
traceId: last.id
|
|
1272
|
+
}) : null
|
|
1273
|
+
};
|
|
1274
|
+
}
|
|
1275
|
+
countLogs() {
|
|
1276
|
+
const row = this.db.prepare("SELECT count(*) AS n FROM logs").get();
|
|
1277
|
+
return Number(row.n);
|
|
1278
|
+
}
|
|
1279
|
+
/**
|
|
1280
|
+
* Store metric points, grouped into series.
|
|
1281
|
+
*
|
|
1282
|
+
* One series is one chart line: `(name, kind, unit, service, scope, point
|
|
1283
|
+
* attributes)`. Both directions of getting this wrong are bad — too coarse
|
|
1284
|
+
* and every line collapses into one meaningless average, too fine and one
|
|
1285
|
+
* logical series sprouts a new line on every export — so the identity is a
|
|
1286
|
+
* content hash over exactly those fields, with attribute keys sorted so an
|
|
1287
|
+
* exporter's key ordering cannot split a series in two.
|
|
1288
|
+
*/
|
|
1289
|
+
ingestMetrics(streams) {
|
|
1290
|
+
if (streams.length === 0) return;
|
|
1291
|
+
const upsertSeries = this.db.prepare(`
|
|
1292
|
+
INSERT INTO metric_series (series_id, name, unit, description, kind,
|
|
1293
|
+
temporality, monotonic, service, scope_name,
|
|
1294
|
+
scope_version, resource, attributes)
|
|
1295
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
1296
|
+
ON CONFLICT(series_id) DO UPDATE SET
|
|
1297
|
+
-- Description and unit can be filled in by a later export; the identity
|
|
1298
|
+
-- fields cannot change without producing a different series_id.
|
|
1299
|
+
description = COALESCE(excluded.description, description),
|
|
1300
|
+
unit = COALESCE(excluded.unit, unit),
|
|
1301
|
+
temporality = COALESCE(excluded.temporality, temporality)
|
|
1302
|
+
`);
|
|
1303
|
+
const upsertPoint = this.db.prepare(`
|
|
1304
|
+
INSERT INTO metric_points (series_id, timestamp, start_timestamp, value,
|
|
1305
|
+
count, sum, min, max, bucket_counts,
|
|
1306
|
+
explicit_bounds, exp_scale, zero_count,
|
|
1307
|
+
zero_threshold, positive_buckets,
|
|
1308
|
+
negative_buckets, quantiles, exemplars)
|
|
1309
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
1310
|
+
ON CONFLICT(series_id, timestamp) DO UPDATE SET
|
|
1311
|
+
value = excluded.value,
|
|
1312
|
+
count = excluded.count,
|
|
1313
|
+
sum = excluded.sum,
|
|
1314
|
+
min = excluded.min,
|
|
1315
|
+
max = excluded.max,
|
|
1316
|
+
bucket_counts = excluded.bucket_counts,
|
|
1317
|
+
explicit_bounds = excluded.explicit_bounds,
|
|
1318
|
+
exp_scale = excluded.exp_scale,
|
|
1319
|
+
zero_count = excluded.zero_count,
|
|
1320
|
+
zero_threshold = excluded.zero_threshold,
|
|
1321
|
+
positive_buckets = excluded.positive_buckets,
|
|
1322
|
+
negative_buckets = excluded.negative_buckets,
|
|
1323
|
+
quantiles = excluded.quantiles,
|
|
1324
|
+
exemplars = excluded.exemplars
|
|
1325
|
+
`);
|
|
1326
|
+
this.db.exec("BEGIN");
|
|
1327
|
+
try {
|
|
1328
|
+
for (const stream of streams) for (const point of stream.points) {
|
|
1329
|
+
const seriesId = seriesIdentity(stream, point.attributes);
|
|
1330
|
+
upsertSeries.run(seriesId, stream.name, stream.unit ?? null, stream.description ?? null, stream.kind, stream.temporality ?? null, stream.monotonic === void 0 ? null : stream.monotonic ? 1 : 0, stream.service, stream.scope?.name ?? null, stream.scope?.version ?? null, stableJson(stream.resource), stableJson(point.attributes));
|
|
1331
|
+
upsertPoint.run(seriesId, point.timestamp, point.startTimestamp ?? null, point.value ?? null, point.count ?? null, point.sum ?? null, point.min ?? null, point.max ?? null, point.bucketCounts ? JSON.stringify(point.bucketCounts) : null, point.explicitBounds ? JSON.stringify(point.explicitBounds) : null, point.scale ?? null, point.zeroCount ?? null, point.zeroThreshold ?? null, point.positive ? JSON.stringify(point.positive) : null, point.negative ? JSON.stringify(point.negative) : null, point.quantiles ? JSON.stringify(point.quantiles) : null, point.exemplars ? JSON.stringify(point.exemplars) : null);
|
|
1332
|
+
}
|
|
1333
|
+
this.db.exec("COMMIT");
|
|
1334
|
+
} catch (error) {
|
|
1335
|
+
this.db.exec("ROLLBACK");
|
|
1336
|
+
throw error;
|
|
1337
|
+
}
|
|
1338
|
+
}
|
|
1339
|
+
/** Every metric name held, for the catalogue the Metrics tab lists. */
|
|
1340
|
+
listMetricNames() {
|
|
1341
|
+
return this.queryMetricCatalog("").metrics;
|
|
1342
|
+
}
|
|
1343
|
+
queryMetricCatalog(query) {
|
|
1344
|
+
const parsed = require_parse.parse(query);
|
|
1345
|
+
if (!parsed.ok) return {
|
|
1346
|
+
metrics: [],
|
|
1347
|
+
errors: parsed.errors
|
|
1348
|
+
};
|
|
1349
|
+
const compiled = require_parse.compileWhere(parsed.node, METRIC_SCHEMA);
|
|
1350
|
+
return { metrics: this.db.prepare(`SELECT name, kind, unit, description, count(*) AS series_count
|
|
1351
|
+
FROM metric_series
|
|
1352
|
+
WHERE ${compiled.sql}
|
|
1353
|
+
GROUP BY name, kind
|
|
1354
|
+
ORDER BY name ASC`).all(...compiled.params).map((row) => ({
|
|
1355
|
+
name: row.name,
|
|
1356
|
+
kind: row.kind,
|
|
1357
|
+
unit: row.unit ?? void 0,
|
|
1358
|
+
description: row.description ?? void 0,
|
|
1359
|
+
seriesCount: Number(row.series_count)
|
|
1360
|
+
})) };
|
|
1361
|
+
}
|
|
1362
|
+
/**
|
|
1363
|
+
* The series for one metric, with their points.
|
|
1364
|
+
*
|
|
1365
|
+
* A series whose points all fall outside the window is omitted rather than
|
|
1366
|
+
* returned empty: an empty line in the legend claims data exists where none
|
|
1367
|
+
* does, and the caller cannot tell the two apart.
|
|
1368
|
+
*/
|
|
1369
|
+
queryMetricSeries(args) {
|
|
1370
|
+
const seriesRows = this.db.prepare("SELECT * FROM metric_series WHERE name = ? ORDER BY series_id ASC").all(args.name);
|
|
1371
|
+
const pointsSql = args.window ? `SELECT * FROM metric_points
|
|
1372
|
+
WHERE series_id = ? AND timestamp >= ? AND timestamp <= ?
|
|
1373
|
+
ORDER BY timestamp ASC` : `SELECT * FROM metric_points WHERE series_id = ? ORDER BY timestamp ASC`;
|
|
1374
|
+
const pointsStmt = this.db.prepare(pointsSql);
|
|
1375
|
+
const out = [];
|
|
1376
|
+
for (const row of seriesRows) {
|
|
1377
|
+
const pointRows = args.window ? pointsStmt.all(row.series_id, args.window.start, args.window.end) : pointsStmt.all(row.series_id);
|
|
1378
|
+
if (pointRows.length === 0) continue;
|
|
1379
|
+
out.push({
|
|
1380
|
+
seriesId: row.series_id,
|
|
1381
|
+
name: row.name,
|
|
1382
|
+
unit: row.unit ?? void 0,
|
|
1383
|
+
description: row.description ?? void 0,
|
|
1384
|
+
kind: row.kind,
|
|
1385
|
+
temporality: row.temporality ?? void 0,
|
|
1386
|
+
monotonic: row.monotonic === null ? void 0 : row.monotonic === 1,
|
|
1387
|
+
service: row.service,
|
|
1388
|
+
scope: row.scope_name ? {
|
|
1389
|
+
name: row.scope_name,
|
|
1390
|
+
version: row.scope_version ?? void 0
|
|
1391
|
+
} : void 0,
|
|
1392
|
+
resource: parseJson(row.resource) ?? {},
|
|
1393
|
+
attributes: parseJson(row.attributes) ?? {},
|
|
1394
|
+
points: reduceMetricPoints(pointRows.map(hydratePoint), row.kind, Math.max(4, Math.min(args.maxPoints ?? 2e3, 2e4)), { temporality: row.temporality ?? void 0 })
|
|
1395
|
+
});
|
|
1396
|
+
}
|
|
1397
|
+
return out;
|
|
1398
|
+
}
|
|
1399
|
+
getTrace(traceId) {
|
|
1400
|
+
const row = this.db.prepare("SELECT * FROM traces WHERE trace_id = ?").get(traceId);
|
|
1401
|
+
return row ? this.hydrateTrace(row) : null;
|
|
1402
|
+
}
|
|
1403
|
+
describeTrace(traceId) {
|
|
1404
|
+
const trace = this.db.prepare("SELECT * FROM traces WHERE trace_id = ?").get(traceId);
|
|
1405
|
+
if (!trace) return null;
|
|
1406
|
+
const spans = this.db.prepare(`
|
|
1407
|
+
SELECT span_id, name, service, duration, status_code, attributes
|
|
1408
|
+
FROM spans WHERE trace_id = ? ORDER BY duration DESC
|
|
1409
|
+
`).all(traceId);
|
|
1410
|
+
const operations = /* @__PURE__ */ new Map();
|
|
1411
|
+
const services = /* @__PURE__ */ new Set();
|
|
1412
|
+
const models = /* @__PURE__ */ new Set();
|
|
1413
|
+
let errors = 0;
|
|
1414
|
+
let llmSpans = 0;
|
|
1415
|
+
let totalTokens = 0;
|
|
1416
|
+
for (const span of spans) {
|
|
1417
|
+
operations.set(span.name, (operations.get(span.name) ?? 0) + 1);
|
|
1418
|
+
services.add(span.service ?? "unknown");
|
|
1419
|
+
if (span.status_code === "ERROR") errors++;
|
|
1420
|
+
const attributes = parseJson(span.attributes) ?? {};
|
|
1421
|
+
const model = attributes["gen_ai.response.model"] ?? attributes["gen_ai.request.model"];
|
|
1422
|
+
if (typeof model === "string") {
|
|
1423
|
+
models.add(model);
|
|
1424
|
+
llmSpans++;
|
|
1425
|
+
}
|
|
1426
|
+
const tokens = attributes["gen_ai.usage.total_tokens"] ?? Number(attributes["gen_ai.usage.input_tokens"] ?? 0) + Number(attributes["gen_ai.usage.output_tokens"] ?? 0);
|
|
1427
|
+
totalTokens += Number(tokens) || 0;
|
|
1428
|
+
}
|
|
1429
|
+
return {
|
|
1430
|
+
traceId,
|
|
1431
|
+
serviceName: trace.service ?? "unknown",
|
|
1432
|
+
durationMs: Number(trace.duration),
|
|
1433
|
+
statusCode: trace.status,
|
|
1434
|
+
spanCount: spans.length,
|
|
1435
|
+
errorSpanCount: errors,
|
|
1436
|
+
serviceCount: services.size,
|
|
1437
|
+
llmSpanCount: llmSpans,
|
|
1438
|
+
totalTokens,
|
|
1439
|
+
modelsUsed: [...models].sort(),
|
|
1440
|
+
topOperations: [...operations].sort((a, b) => b[1] - a[1]).slice(0, 5).map(([operation, count]) => ({
|
|
1441
|
+
operation,
|
|
1442
|
+
count
|
|
1443
|
+
})),
|
|
1444
|
+
slowestSpans: spans.slice(0, 5).map((span) => ({
|
|
1445
|
+
spanId: span.span_id,
|
|
1446
|
+
name: span.name,
|
|
1447
|
+
service: span.service ?? "unknown",
|
|
1448
|
+
durationMs: Number(span.duration),
|
|
1449
|
+
deepLink: traceDeepLink(traceId, trace, span.span_id)
|
|
1450
|
+
})),
|
|
1451
|
+
deepLink: traceDeepLink(traceId, trace)
|
|
1452
|
+
};
|
|
1453
|
+
}
|
|
1454
|
+
findSlowest(limit = 10) {
|
|
1455
|
+
return this.db.prepare("SELECT trace_id FROM traces ORDER BY duration DESC LIMIT ?").all(Math.max(1, Math.min(limit, 100))).flatMap((row) => {
|
|
1456
|
+
const projection = this.describeTrace(row.trace_id);
|
|
1457
|
+
return projection ? [projection] : [];
|
|
1458
|
+
});
|
|
1459
|
+
}
|
|
1460
|
+
countSpans() {
|
|
1461
|
+
const row = this.db.prepare("SELECT count(*) AS n FROM spans").get();
|
|
1462
|
+
return Number(row.n);
|
|
1463
|
+
}
|
|
1464
|
+
countTraces() {
|
|
1465
|
+
const row = this.db.prepare("SELECT count(*) AS n FROM traces").get();
|
|
1466
|
+
return Number(row.n);
|
|
1467
|
+
}
|
|
1468
|
+
/**
|
|
1469
|
+
* One row per matching span, for a cohort comparison.
|
|
1470
|
+
*
|
|
1471
|
+
* Attributes plus the first-class columns, because "the slow ones are all
|
|
1472
|
+
* `service=payments`" is exactly the shape of answer wanted and `service` is
|
|
1473
|
+
* a column rather than an attribute. Ids are left out: they take a distinct
|
|
1474
|
+
* value per span, so they can never describe a group, and including them
|
|
1475
|
+
* only gives the ranking noise to wade through.
|
|
1476
|
+
*
|
|
1477
|
+
* Throws on a query that does not parse. Returning an empty cohort instead
|
|
1478
|
+
* would surface as "no difference found", which is a different and much
|
|
1479
|
+
* more misleading answer than "your query is wrong".
|
|
1480
|
+
*/
|
|
1481
|
+
cohortRows(args) {
|
|
1482
|
+
const parsed = require_parse.parse(args.query ?? "");
|
|
1483
|
+
if (!parsed.ok) throw new Error(`Cohort query did not parse: ${parsed.errors.map((e) => e.message).join("; ")}`);
|
|
1484
|
+
const { sql: whereSql, params } = require_parse.compileWhere(parsed.node, SPAN_SCHEMA);
|
|
1485
|
+
const clauses = [whereSql];
|
|
1486
|
+
const queryParams = [...params];
|
|
1487
|
+
if (args.window) {
|
|
1488
|
+
clauses.push("s.start_time >= ? AND s.start_time <= ?");
|
|
1489
|
+
queryParams.push(args.window.start, args.window.end);
|
|
1490
|
+
}
|
|
1491
|
+
return this.db.prepare(`SELECT s.service, s.name, s.kind, s.duration, s.status_code, s.attributes
|
|
1492
|
+
FROM spans s
|
|
1493
|
+
WHERE ${clauses.join(" AND ")}
|
|
1494
|
+
ORDER BY s.start_time DESC
|
|
1495
|
+
LIMIT ?`).all(...queryParams, Math.min(args.limit ?? COHORT_ROW_LIMIT, COHORT_ROW_LIMIT)).map((row) => ({
|
|
1496
|
+
...parseJson(row.attributes) ?? {},
|
|
1497
|
+
service: row.service ?? "unknown",
|
|
1498
|
+
name: row.name,
|
|
1499
|
+
kind: row.kind,
|
|
1500
|
+
status: row.status_code
|
|
1501
|
+
}));
|
|
1502
|
+
}
|
|
1503
|
+
/**
|
|
1504
|
+
* What the store has actually seen, for the coverage join.
|
|
1505
|
+
*
|
|
1506
|
+
* Both routes in are counted: the `http.route` attribute a framework
|
|
1507
|
+
* integration sets, and the span name, which is what `trace('name', fn)`
|
|
1508
|
+
* produces and is all a non-HTTP entry point ever has. Counting is cheap
|
|
1509
|
+
* here because `attribute_values` already carries per-value totals.
|
|
1510
|
+
*/
|
|
1511
|
+
observedSpans() {
|
|
1512
|
+
const routeRows = this.db.prepare(`SELECT value_text AS value, seen_count AS count FROM attribute_values
|
|
1513
|
+
WHERE signal = 'traces' AND key = 'http.route'`).all();
|
|
1514
|
+
const nameRows = this.db.prepare("SELECT name, count(*) AS count FROM spans GROUP BY name").all();
|
|
1515
|
+
return {
|
|
1516
|
+
routeCounts: Object.fromEntries(routeRows.map((row) => [row.value, Number(row.count)])),
|
|
1517
|
+
spanNameCounts: Object.fromEntries(nameRows.map((row) => [row.name, Number(row.count)]))
|
|
1518
|
+
};
|
|
1519
|
+
}
|
|
1520
|
+
/** Query fields currently present, for editor completion. */
|
|
1521
|
+
listQueryFields(signal, limit = 200) {
|
|
1522
|
+
const schema = signal === "traces" ? SPAN_SCHEMA : LOG_SCHEMA;
|
|
1523
|
+
const table = signal === "traces" ? "spans" : "logs";
|
|
1524
|
+
const rows = this.db.prepare(`SELECT DISTINCT json_each.key AS key
|
|
1525
|
+
FROM ${table}, json_each(${table}.attributes)
|
|
1526
|
+
WHERE json_valid(${table}.attributes)
|
|
1527
|
+
ORDER BY key ASC
|
|
1528
|
+
LIMIT ?`).all(Math.max(1, Math.min(limit, 500)));
|
|
1529
|
+
return [.../* @__PURE__ */ new Set([...Object.keys(schema.columns), ...rows.map((row) => row.key)])];
|
|
1530
|
+
}
|
|
1531
|
+
searchAttributes(signal, value, limit = 50) {
|
|
1532
|
+
return this.db.prepare(`
|
|
1533
|
+
SELECT key, value_json, seen_count
|
|
1534
|
+
FROM attribute_values
|
|
1535
|
+
WHERE signal = ? AND value_text LIKE ? ESCAPE '\\'
|
|
1536
|
+
ORDER BY seen_count DESC, key ASC
|
|
1537
|
+
LIMIT ?
|
|
1538
|
+
`).all(signal, `%${escapeLike(value)}%`, Math.max(1, Math.min(limit, 200))).map((row) => ({
|
|
1539
|
+
key: row.key,
|
|
1540
|
+
value: parseJson(row.value_json),
|
|
1541
|
+
count: Number(row.seen_count)
|
|
1542
|
+
}));
|
|
1543
|
+
}
|
|
1544
|
+
attributeUpsert() {
|
|
1545
|
+
return this.db.prepare(`
|
|
1546
|
+
INSERT INTO attribute_values (signal, key, value_json, value_text, last_seen)
|
|
1547
|
+
VALUES (?, ?, ?, ?, ?)
|
|
1548
|
+
ON CONFLICT(signal, key, value_json) DO UPDATE SET
|
|
1549
|
+
seen_count = seen_count + 1,
|
|
1550
|
+
last_seen = max(last_seen, excluded.last_seen)
|
|
1551
|
+
`);
|
|
1552
|
+
}
|
|
1553
|
+
attributeOccurrenceUpsert() {
|
|
1554
|
+
return this.db.prepare(`
|
|
1555
|
+
INSERT INTO attribute_occurrences(signal, entity_id, key, value_json)
|
|
1556
|
+
VALUES (?, ?, ?, ?)
|
|
1557
|
+
ON CONFLICT(signal, entity_id, key) DO UPDATE SET value_json = excluded.value_json
|
|
1558
|
+
`);
|
|
1559
|
+
}
|
|
1560
|
+
indexAttributes(statement, occurrence, signal, entityId, attributes, timestamp) {
|
|
1561
|
+
for (const [key, value] of Object.entries(attributes)) {
|
|
1562
|
+
const encoded = JSON.stringify(value);
|
|
1563
|
+
statement.run(signal, key, encoded, String(value), timestamp);
|
|
1564
|
+
occurrence.run(signal, entityId, key, encoded);
|
|
1565
|
+
}
|
|
1566
|
+
}
|
|
1567
|
+
/**
|
|
1568
|
+
* Index the events and links already sitting in the span JSON.
|
|
1569
|
+
*
|
|
1570
|
+
* A `--db` file written before these tables existed holds spans whose events
|
|
1571
|
+
* no query can reach. Reading them out of the JSON on open is what makes
|
|
1572
|
+
* `event.name = …` work against telemetry captured yesterday. Keyed on the
|
|
1573
|
+
* table being empty while spans exist, so it runs once rather than on every
|
|
1574
|
+
* open.
|
|
1575
|
+
*/
|
|
1576
|
+
backfillSpanChildren() {
|
|
1577
|
+
const pending = this.db.prepare(`SELECT (SELECT count(*) FROM spans WHERE events IS NOT NULL OR links IS NOT NULL) AS carriers,
|
|
1578
|
+
(SELECT count(*) FROM span_events) AS events,
|
|
1579
|
+
(SELECT count(*) FROM span_links) AS links`).get();
|
|
1580
|
+
if (Number(pending.carriers) === 0) return;
|
|
1581
|
+
if (Number(pending.events) > 0 || Number(pending.links) > 0) return;
|
|
1582
|
+
this.db.exec(`
|
|
1583
|
+
INSERT OR IGNORE INTO span_events(trace_id, span_id, idx, name, timestamp)
|
|
1584
|
+
SELECT spans.trace_id, spans.span_id, json_each.key,
|
|
1585
|
+
json_extract(json_each.value, '$.name'),
|
|
1586
|
+
json_extract(json_each.value, '$.timestamp')
|
|
1587
|
+
FROM spans, json_each(spans.events)
|
|
1588
|
+
WHERE spans.events IS NOT NULL AND json_valid(spans.events)
|
|
1589
|
+
AND json_extract(json_each.value, '$.name') IS NOT NULL;
|
|
1590
|
+
INSERT OR IGNORE INTO span_links(trace_id, span_id, idx, linked_trace_id, linked_span_id)
|
|
1591
|
+
SELECT spans.trace_id, spans.span_id, json_each.key,
|
|
1592
|
+
json_extract(json_each.value, '$.traceId'),
|
|
1593
|
+
json_extract(json_each.value, '$.spanId')
|
|
1594
|
+
FROM spans, json_each(spans.links)
|
|
1595
|
+
WHERE spans.links IS NOT NULL AND json_valid(spans.links)
|
|
1596
|
+
AND json_extract(json_each.value, '$.traceId') IS NOT NULL;
|
|
1597
|
+
`);
|
|
1598
|
+
}
|
|
1599
|
+
backfillAttributeDictionary() {
|
|
1600
|
+
const row = this.db.prepare("SELECT count(*) AS n FROM attribute_values").get();
|
|
1601
|
+
if (Number(row.n) === 0) this.db.exec(`
|
|
1602
|
+
INSERT OR IGNORE INTO attribute_values(signal, key, value_json, value_text, seen_count, last_seen)
|
|
1603
|
+
SELECT 'traces', json_each.key, json_quote(json_each.value), CAST(json_each.value AS TEXT), count(*), max(spans.start_time)
|
|
1604
|
+
FROM spans, json_each(spans.attributes) GROUP BY json_each.key, json_quote(json_each.value);
|
|
1605
|
+
INSERT OR IGNORE INTO attribute_values(signal, key, value_json, value_text, seen_count, last_seen)
|
|
1606
|
+
SELECT 'logs', json_each.key, json_quote(json_each.value), CAST(json_each.value AS TEXT), count(*), max(logs.timestamp)
|
|
1607
|
+
FROM logs, json_each(logs.attributes) GROUP BY json_each.key, json_quote(json_each.value);
|
|
1608
|
+
`);
|
|
1609
|
+
const occurrences = this.db.prepare("SELECT count(*) AS n FROM attribute_occurrences").get();
|
|
1610
|
+
if (Number(occurrences.n) === 0) this.db.exec(`
|
|
1611
|
+
INSERT OR IGNORE INTO attribute_occurrences(signal, entity_id, key, value_json)
|
|
1612
|
+
SELECT 'traces', spans.trace_id || ':' || spans.span_id, json_each.key, json_quote(json_each.value)
|
|
1613
|
+
FROM spans, json_each(spans.attributes);
|
|
1614
|
+
INSERT OR IGNORE INTO attribute_occurrences(signal, entity_id, key, value_json)
|
|
1615
|
+
SELECT 'logs', logs.id, json_each.key, json_quote(json_each.value)
|
|
1616
|
+
FROM logs, json_each(logs.attributes);
|
|
1617
|
+
`);
|
|
1618
|
+
}
|
|
1619
|
+
/**
|
|
1620
|
+
* Prune the oldest traces past the row cap, and their spans with them.
|
|
1621
|
+
*
|
|
1622
|
+
* Spans are deleted in the same transaction as their traces: a pruned trace
|
|
1623
|
+
* that left its spans behind would leave rows that no query can reach and no
|
|
1624
|
+
* later sweep would find, since every sweep starts from `traces`.
|
|
1625
|
+
*/
|
|
1626
|
+
enforceRetention() {
|
|
1627
|
+
this.enforceMetricRetention();
|
|
1628
|
+
this.enforceLogRetention();
|
|
1629
|
+
if (this.maxTraces <= 0) {
|
|
1630
|
+
this.enforceByteRetention();
|
|
1631
|
+
return;
|
|
1632
|
+
}
|
|
1633
|
+
const total = this.countTraces();
|
|
1634
|
+
if (total <= this.maxTraces) {
|
|
1635
|
+
this.enforceByteRetention();
|
|
1636
|
+
return;
|
|
1637
|
+
}
|
|
1638
|
+
const excess = total - this.maxTraces;
|
|
1639
|
+
this.db.exec("BEGIN");
|
|
1640
|
+
try {
|
|
1641
|
+
const doomed = this.db.prepare(`SELECT trace_id FROM traces
|
|
1642
|
+
ORDER BY start_time ASC, trace_id ASC
|
|
1643
|
+
LIMIT ?`).all(excess);
|
|
1644
|
+
const deleteSpans = this.db.prepare("DELETE FROM spans WHERE trace_id = ?");
|
|
1645
|
+
const deleteTrace = this.db.prepare("DELETE FROM traces WHERE trace_id = ?");
|
|
1646
|
+
for (const { trace_id } of doomed) {
|
|
1647
|
+
deleteSpans.run(trace_id);
|
|
1648
|
+
deleteTrace.run(trace_id);
|
|
1649
|
+
}
|
|
1650
|
+
this.db.exec("COMMIT");
|
|
1651
|
+
} catch (error) {
|
|
1652
|
+
this.db.exec("ROLLBACK");
|
|
1653
|
+
throw error;
|
|
1654
|
+
}
|
|
1655
|
+
this.removeOrphanedAttributes();
|
|
1656
|
+
this.enforceByteRetention();
|
|
1657
|
+
}
|
|
1658
|
+
getStats() {
|
|
1659
|
+
const pageCount = pragmaNumber(this.db, "page_count");
|
|
1660
|
+
const freePages = pragmaNumber(this.db, "freelist_count");
|
|
1661
|
+
const pageSize = pragmaNumber(this.db, "page_size");
|
|
1662
|
+
const metric = this.db.prepare("SELECT count(DISTINCT series_id) AS series, count(*) AS points FROM metric_points").get();
|
|
1663
|
+
return {
|
|
1664
|
+
bytesUsed: Math.max(0, pageCount - freePages) * pageSize,
|
|
1665
|
+
maxBytes: this.maxBytes,
|
|
1666
|
+
traceCount: this.countTraces(),
|
|
1667
|
+
spanCount: this.countSpans(),
|
|
1668
|
+
logCount: this.countLogs(),
|
|
1669
|
+
metricSeriesCount: Number(metric.series),
|
|
1670
|
+
metricPointCount: Number(metric.points)
|
|
1671
|
+
};
|
|
1672
|
+
}
|
|
1673
|
+
enforceByteRetention() {
|
|
1674
|
+
if (this.maxBytes <= 0) return;
|
|
1675
|
+
for (let pass = 0; pass < 100 && this.getStats().bytesUsed > this.maxBytes; pass++) {
|
|
1676
|
+
const before = this.getStats();
|
|
1677
|
+
if (before.traceCount + before.logCount + before.metricPointCount === 0) break;
|
|
1678
|
+
this.db.exec("BEGIN");
|
|
1679
|
+
try {
|
|
1680
|
+
const traceBatch = Math.max(1, Math.ceil(before.traceCount * .05));
|
|
1681
|
+
this.db.prepare(`DELETE FROM spans WHERE trace_id IN (
|
|
1682
|
+
SELECT trace_id FROM traces ORDER BY start_time ASC LIMIT ?
|
|
1683
|
+
)`).run(traceBatch);
|
|
1684
|
+
this.db.prepare(`DELETE FROM traces WHERE trace_id IN (
|
|
1685
|
+
SELECT trace_id FROM traces ORDER BY start_time ASC LIMIT ?
|
|
1686
|
+
)`).run(traceBatch);
|
|
1687
|
+
this.db.prepare(`DELETE FROM logs WHERE id IN (
|
|
1688
|
+
SELECT id FROM logs ORDER BY timestamp ASC LIMIT ?
|
|
1689
|
+
)`).run(Math.max(1, Math.ceil(before.logCount * .05)));
|
|
1690
|
+
this.db.prepare(`DELETE FROM metric_points WHERE rowid IN (
|
|
1691
|
+
SELECT rowid FROM metric_points ORDER BY timestamp ASC LIMIT ?
|
|
1692
|
+
)`).run(Math.max(1, Math.ceil(before.metricPointCount * .05)));
|
|
1693
|
+
this.db.exec(`DELETE FROM metric_series WHERE series_id NOT IN (
|
|
1694
|
+
SELECT DISTINCT series_id FROM metric_points
|
|
1695
|
+
)`);
|
|
1696
|
+
this.db.exec("COMMIT");
|
|
1697
|
+
} catch (error) {
|
|
1698
|
+
this.db.exec("ROLLBACK");
|
|
1699
|
+
throw error;
|
|
1700
|
+
}
|
|
1701
|
+
this.removeOrphanedAttributes();
|
|
1702
|
+
}
|
|
1703
|
+
}
|
|
1704
|
+
/**
|
|
1705
|
+
* Trim each series to its newest `maxMetricPoints`.
|
|
1706
|
+
*
|
|
1707
|
+
* Per series, not globally: a global cap would let one chatty instrument
|
|
1708
|
+
* evict every other series, blanking the quiet chart someone was watching.
|
|
1709
|
+
*/
|
|
1710
|
+
enforceMetricRetention() {
|
|
1711
|
+
if (this.maxMetricPoints <= 0) return;
|
|
1712
|
+
this.db.exec("BEGIN");
|
|
1713
|
+
try {
|
|
1714
|
+
this.db.prepare(`DELETE FROM metric_points
|
|
1715
|
+
WHERE rowid IN (
|
|
1716
|
+
SELECT rowid FROM (
|
|
1717
|
+
SELECT rowid,
|
|
1718
|
+
row_number() OVER (
|
|
1719
|
+
PARTITION BY series_id ORDER BY timestamp DESC
|
|
1720
|
+
) AS rn
|
|
1721
|
+
FROM metric_points
|
|
1722
|
+
) WHERE rn > ?
|
|
1723
|
+
)`).run(this.maxMetricPoints);
|
|
1724
|
+
this.db.exec("COMMIT");
|
|
1725
|
+
} catch (error) {
|
|
1726
|
+
this.db.exec("ROLLBACK");
|
|
1727
|
+
throw error;
|
|
1728
|
+
}
|
|
1729
|
+
}
|
|
1730
|
+
/** Trim the log table to its newest `maxLogs` rows. */
|
|
1731
|
+
enforceLogRetention() {
|
|
1732
|
+
if (this.maxLogs <= 0) return;
|
|
1733
|
+
const total = this.countLogs();
|
|
1734
|
+
if (total <= this.maxLogs) return;
|
|
1735
|
+
this.db.prepare(`DELETE FROM logs WHERE id IN (
|
|
1736
|
+
SELECT id FROM logs ORDER BY timestamp ASC, id ASC LIMIT ?
|
|
1737
|
+
)`).run(total - this.maxLogs);
|
|
1738
|
+
this.removeOrphanedAttributes();
|
|
1739
|
+
}
|
|
1740
|
+
removeOrphanedAttributes() {
|
|
1741
|
+
this.db.exec(`
|
|
1742
|
+
DELETE FROM attribute_occurrences
|
|
1743
|
+
WHERE signal = 'logs' AND entity_id NOT IN (SELECT id FROM logs);
|
|
1744
|
+
DELETE FROM attribute_occurrences
|
|
1745
|
+
WHERE signal = 'traces' AND entity_id NOT IN (
|
|
1746
|
+
SELECT trace_id || ':' || span_id FROM spans
|
|
1747
|
+
);
|
|
1748
|
+
DELETE FROM span_events WHERE trace_id || ':' || span_id NOT IN (
|
|
1749
|
+
SELECT trace_id || ':' || span_id FROM spans
|
|
1750
|
+
);
|
|
1751
|
+
DELETE FROM span_links WHERE trace_id || ':' || span_id NOT IN (
|
|
1752
|
+
SELECT trace_id || ':' || span_id FROM spans
|
|
1753
|
+
);
|
|
1754
|
+
`);
|
|
1755
|
+
}
|
|
1756
|
+
clear() {
|
|
1757
|
+
this.db.exec("DELETE FROM spans; DELETE FROM traces; DELETE FROM logs; DELETE FROM metric_points; DELETE FROM metric_series; DELETE FROM attribute_values; DELETE FROM attribute_occurrences; DELETE FROM span_events; DELETE FROM span_links;");
|
|
1758
|
+
}
|
|
1759
|
+
clearSignal(signal) {
|
|
1760
|
+
if (signal === "traces") this.db.exec("DELETE FROM spans; DELETE FROM traces; DELETE FROM attribute_values WHERE signal = 'traces'; DELETE FROM attribute_occurrences WHERE signal = 'traces'; DELETE FROM span_events; DELETE FROM span_links;");
|
|
1761
|
+
else if (signal === "logs") this.db.exec("DELETE FROM logs; DELETE FROM attribute_values WHERE signal = 'logs'; DELETE FROM attribute_occurrences WHERE signal = 'logs';");
|
|
1762
|
+
else this.db.exec("DELETE FROM metric_points; DELETE FROM metric_series;");
|
|
1763
|
+
}
|
|
1764
|
+
deleteMetric(name) {
|
|
1765
|
+
const ids = this.db.prepare("SELECT series_id FROM metric_series WHERE name = ?").all(name);
|
|
1766
|
+
const removePoints = this.db.prepare("DELETE FROM metric_points WHERE series_id = ?");
|
|
1767
|
+
this.db.exec("BEGIN");
|
|
1768
|
+
try {
|
|
1769
|
+
for (const { series_id: id } of ids) removePoints.run(id);
|
|
1770
|
+
this.db.prepare("DELETE FROM metric_series WHERE name = ?").run(name);
|
|
1771
|
+
this.db.exec("COMMIT");
|
|
1772
|
+
return ids.length;
|
|
1773
|
+
} catch (error) {
|
|
1774
|
+
this.db.exec("ROLLBACK");
|
|
1775
|
+
throw error;
|
|
1776
|
+
}
|
|
1777
|
+
}
|
|
1778
|
+
deleteTraces(traceIds) {
|
|
1779
|
+
const ids = [...new Set(traceIds)].slice(0, 1e3);
|
|
1780
|
+
const removeSpans = this.db.prepare("DELETE FROM spans WHERE trace_id = ?");
|
|
1781
|
+
const removeTrace = this.db.prepare("DELETE FROM traces WHERE trace_id = ?");
|
|
1782
|
+
let deleted = 0;
|
|
1783
|
+
this.db.exec("BEGIN");
|
|
1784
|
+
try {
|
|
1785
|
+
for (const id of ids) {
|
|
1786
|
+
removeSpans.run(id);
|
|
1787
|
+
deleted += Number(removeTrace.run(id).changes);
|
|
1788
|
+
}
|
|
1789
|
+
this.db.exec("COMMIT");
|
|
1790
|
+
} catch (error) {
|
|
1791
|
+
this.db.exec("ROLLBACK");
|
|
1792
|
+
throw error;
|
|
1793
|
+
}
|
|
1794
|
+
this.removeOrphanedAttributes();
|
|
1795
|
+
return deleted;
|
|
1796
|
+
}
|
|
1797
|
+
close() {
|
|
1798
|
+
try {
|
|
1799
|
+
this.db.close();
|
|
1800
|
+
} catch {}
|
|
1801
|
+
}
|
|
1802
|
+
/**
|
|
1803
|
+
* Hydrate a page of traces with one span query rather than one per trace.
|
|
1804
|
+
*
|
|
1805
|
+
* A list of 100 traces was 101 statements: the page, then a `SELECT` per
|
|
1806
|
+
* trace. Grouping in memory costs the same rows and one round of planning.
|
|
1807
|
+
* Ordering is `trace_id, start_time` so each group arrives already in the
|
|
1808
|
+
* order a waterfall draws it, which is what the list-hydration test pins.
|
|
1809
|
+
*/
|
|
1810
|
+
hydrateTracePage(rows) {
|
|
1811
|
+
if (rows.length === 0) return [];
|
|
1812
|
+
const placeholders = rows.map(() => "?").join(", ");
|
|
1813
|
+
const spanRows = this.db.prepare(`SELECT * FROM spans WHERE trace_id IN (${placeholders})
|
|
1814
|
+
ORDER BY trace_id ASC, start_time ASC`).all(...rows.map((row) => row.trace_id));
|
|
1815
|
+
const byTrace = /* @__PURE__ */ new Map();
|
|
1816
|
+
for (const spanRow of spanRows) {
|
|
1817
|
+
const list = byTrace.get(spanRow.trace_id);
|
|
1818
|
+
if (list) list.push(hydrateSpan(spanRow));
|
|
1819
|
+
else byTrace.set(spanRow.trace_id, [hydrateSpan(spanRow)]);
|
|
1820
|
+
}
|
|
1821
|
+
return rows.map((row) => this.assembleTrace(row, byTrace.get(row.trace_id) ?? []));
|
|
1822
|
+
}
|
|
1823
|
+
hydrateTrace(row) {
|
|
1824
|
+
const spanRows = this.db.prepare("SELECT * FROM spans WHERE trace_id = ? ORDER BY start_time ASC").all(row.trace_id);
|
|
1825
|
+
return this.assembleTrace(row, spanRows.map(hydrateSpan));
|
|
1826
|
+
}
|
|
1827
|
+
/** The row-to-trace mapping, shared by the single and batched paths. */
|
|
1828
|
+
assembleTrace(row, spans) {
|
|
1829
|
+
return {
|
|
1830
|
+
traceId: row.trace_id,
|
|
1831
|
+
correlationId: row.correlation_id ?? row.trace_id,
|
|
1832
|
+
service: row.service ?? "unknown",
|
|
1833
|
+
spans,
|
|
1834
|
+
rootSpan: spans.find((s) => s.spanId === row.root_span_id) ?? spans[0],
|
|
1835
|
+
startTime: Number(row.start_time),
|
|
1836
|
+
endTime: Number(row.end_time),
|
|
1837
|
+
duration: Number(row.duration),
|
|
1838
|
+
status: row.status,
|
|
1839
|
+
partial: row.partial === 1
|
|
1840
|
+
};
|
|
1841
|
+
}
|
|
1842
|
+
};
|
|
1843
|
+
function hydrateLog(row) {
|
|
1844
|
+
const structured = row.body_json ? parseJson(row.body_json) : void 0;
|
|
1845
|
+
return {
|
|
1846
|
+
id: row.id,
|
|
1847
|
+
timestamp: Number(row.timestamp),
|
|
1848
|
+
body: structured ?? row.body_text,
|
|
1849
|
+
resourceName: row.service ?? void 0,
|
|
1850
|
+
severityText: row.severity_text ?? void 0,
|
|
1851
|
+
severityNumber: row.severity_number ?? void 0,
|
|
1852
|
+
traceId: row.trace_id ?? void 0,
|
|
1853
|
+
spanId: row.span_id ?? void 0,
|
|
1854
|
+
attributes: parseJson(row.attributes) ?? {},
|
|
1855
|
+
resource: parseJson(row.resource)
|
|
1856
|
+
};
|
|
1857
|
+
}
|
|
1858
|
+
function hydratePoint(row) {
|
|
1859
|
+
return {
|
|
1860
|
+
timestamp: Number(row.timestamp),
|
|
1861
|
+
startTimestamp: row.start_timestamp === null ? void 0 : Number(row.start_timestamp),
|
|
1862
|
+
attributes: {},
|
|
1863
|
+
value: row.value ?? void 0,
|
|
1864
|
+
count: row.count ?? void 0,
|
|
1865
|
+
sum: row.sum ?? void 0,
|
|
1866
|
+
min: row.min ?? void 0,
|
|
1867
|
+
max: row.max ?? void 0,
|
|
1868
|
+
bucketCounts: parseJson(row.bucket_counts),
|
|
1869
|
+
explicitBounds: parseJson(row.explicit_bounds),
|
|
1870
|
+
scale: row.exp_scale ?? void 0,
|
|
1871
|
+
zeroCount: row.zero_count ?? void 0,
|
|
1872
|
+
zeroThreshold: row.zero_threshold ?? void 0,
|
|
1873
|
+
positive: parseJson(row.positive_buckets),
|
|
1874
|
+
negative: parseJson(row.negative_buckets),
|
|
1875
|
+
quantiles: parseJson(row.quantiles),
|
|
1876
|
+
exemplars: parseJson(row.exemplars)
|
|
1877
|
+
};
|
|
1878
|
+
}
|
|
1879
|
+
/**
|
|
1880
|
+
* Content hash identifying one series.
|
|
1881
|
+
*
|
|
1882
|
+
* Attribute keys are sorted before hashing so an exporter that emits the same
|
|
1883
|
+
* attributes in a different order — which nothing forbids — cannot split one
|
|
1884
|
+
* logical series into two chart lines.
|
|
1885
|
+
*/
|
|
1886
|
+
function seriesIdentity(stream, attributes) {
|
|
1887
|
+
return (0, node_crypto.createHash)("sha256").update([
|
|
1888
|
+
stream.name,
|
|
1889
|
+
stream.kind,
|
|
1890
|
+
stream.unit ?? "",
|
|
1891
|
+
stream.service,
|
|
1892
|
+
stream.scope?.name ?? "",
|
|
1893
|
+
stream.scope?.version ?? "",
|
|
1894
|
+
stableJson(stream.resource),
|
|
1895
|
+
stableJson(attributes)
|
|
1896
|
+
].join("\0")).digest("hex").slice(0, 32);
|
|
1897
|
+
}
|
|
1898
|
+
/** JSON with keys sorted, so equal maps always serialize identically. */
|
|
1899
|
+
function stableJson(value) {
|
|
1900
|
+
const sorted = {};
|
|
1901
|
+
for (const key of Object.keys(value).sort()) sorted[key] = value[key];
|
|
1902
|
+
return JSON.stringify(sorted);
|
|
1903
|
+
}
|
|
1904
|
+
function hydrateSpan(row) {
|
|
1905
|
+
return {
|
|
1906
|
+
spanId: row.span_id,
|
|
1907
|
+
traceId: row.trace_id,
|
|
1908
|
+
parentSpanId: row.parent_span_id ?? void 0,
|
|
1909
|
+
name: row.name,
|
|
1910
|
+
kind: row.kind,
|
|
1911
|
+
startTime: Number(row.start_time),
|
|
1912
|
+
endTime: Number(row.end_time),
|
|
1913
|
+
duration: Number(row.duration),
|
|
1914
|
+
attributes: parseJson(row.attributes) ?? {},
|
|
1915
|
+
status: {
|
|
1916
|
+
code: row.status_code,
|
|
1917
|
+
message: row.status_message ?? void 0
|
|
1918
|
+
},
|
|
1919
|
+
events: parseJson(row.events) ?? [],
|
|
1920
|
+
links: parseJson(row.links) ?? void 0,
|
|
1921
|
+
scope: parseJson(row.scope) ?? void 0
|
|
1922
|
+
};
|
|
1923
|
+
}
|
|
1924
|
+
/** Prefer the service that produced the span, falling back to its trace root. */
|
|
1925
|
+
function spanService(span, traceService) {
|
|
1926
|
+
const service = span.attributes?.["service.name"];
|
|
1927
|
+
return typeof service === "string" && service.length > 0 ? service : traceService ?? null;
|
|
1928
|
+
}
|
|
1929
|
+
/**
|
|
1930
|
+
* Decode stored JSON, tolerating corruption.
|
|
1931
|
+
*
|
|
1932
|
+
* A single unparseable attribute blob should cost one span its attributes, not
|
|
1933
|
+
* fail the whole query — which is what a throw here would do.
|
|
1934
|
+
*/
|
|
1935
|
+
function parseJson(text) {
|
|
1936
|
+
if (!text) return void 0;
|
|
1937
|
+
try {
|
|
1938
|
+
return JSON.parse(text);
|
|
1939
|
+
} catch {
|
|
1940
|
+
return;
|
|
1941
|
+
}
|
|
1942
|
+
}
|
|
1943
|
+
function pragmaNumber(db, name) {
|
|
1944
|
+
const row = db.prepare(`PRAGMA ${name}`).get();
|
|
1945
|
+
return Number(Object.values(row)[0] ?? 0);
|
|
1946
|
+
}
|
|
1947
|
+
function escapeLike(value) {
|
|
1948
|
+
return value.replaceAll("\\", "\\\\").replaceAll("%", "\\%").replaceAll("_", "\\_");
|
|
1949
|
+
}
|
|
1950
|
+
function encodeCursor(cursor) {
|
|
1951
|
+
return Buffer.from(`${cursor.startTime}:${cursor.traceId}`).toString("base64url");
|
|
1952
|
+
}
|
|
1953
|
+
/** Decode a cursor, treating anything malformed as "start from the beginning". */
|
|
1954
|
+
function decodeCursor(raw) {
|
|
1955
|
+
if (!raw) return null;
|
|
1956
|
+
try {
|
|
1957
|
+
const text = Buffer.from(raw, "base64url").toString("utf8");
|
|
1958
|
+
const separator = text.indexOf(":");
|
|
1959
|
+
if (separator < 0) return null;
|
|
1960
|
+
const startTime = Number(text.slice(0, separator));
|
|
1961
|
+
const traceId = text.slice(separator + 1);
|
|
1962
|
+
if (!Number.isFinite(startTime) || !traceId) return null;
|
|
1963
|
+
return {
|
|
1964
|
+
startTime,
|
|
1965
|
+
traceId
|
|
1966
|
+
};
|
|
1967
|
+
} catch {
|
|
1968
|
+
return null;
|
|
1969
|
+
}
|
|
1970
|
+
}
|
|
1971
|
+
|
|
1972
|
+
//#endregion
|
|
1973
|
+
//#region src/server/otlp-types.ts
|
|
1974
|
+
/**
|
|
1975
|
+
* An exporter's payload, read as the envelope it claims to be.
|
|
1976
|
+
*
|
|
1977
|
+
* SAFETY: this is the one place the receiver trusts the wire. Every field of
|
|
1978
|
+
* every envelope above is optional, so a payload that is not what it claims
|
|
1979
|
+
* reads back as empty arrays and undefined fields rather than throwing - which
|
|
1980
|
+
* is what the callers below rely on when they find no spans to add.
|
|
1981
|
+
*/
|
|
1982
|
+
function otlpEnvelope(payload) {
|
|
1983
|
+
if (typeof payload !== "object" || payload === null) return void 0;
|
|
1984
|
+
return payload;
|
|
1985
|
+
}
|
|
1986
|
+
|
|
1987
|
+
//#endregion
|
|
1988
|
+
//#region src/server/otlp.ts
|
|
1989
|
+
function resolveOtlpValue(v) {
|
|
1990
|
+
if (!v) return void 0;
|
|
1991
|
+
if (v.stringValue !== void 0) return v.stringValue;
|
|
1992
|
+
if (v.boolValue !== void 0) return v.boolValue;
|
|
1993
|
+
if (v.intValue !== void 0) return Number(v.intValue);
|
|
1994
|
+
if (v.doubleValue !== void 0) return v.doubleValue;
|
|
1995
|
+
if (v.bytesValue !== void 0) return v.bytesValue;
|
|
1996
|
+
if (v.arrayValue?.values) return v.arrayValue.values.map(resolveOtlpValue);
|
|
1997
|
+
if (v.kvlistValue?.values) return flattenAttributes(v.kvlistValue.values);
|
|
1998
|
+
}
|
|
1999
|
+
function flattenAttributes(attrs) {
|
|
2000
|
+
const out = {};
|
|
2001
|
+
if (!attrs) return out;
|
|
2002
|
+
for (const { key, value } of attrs) out[key] = resolveOtlpValue(value);
|
|
2003
|
+
return out;
|
|
2004
|
+
}
|
|
2005
|
+
/**
|
|
2006
|
+
* A log record's body: the text it carried, or the structure it carried when
|
|
2007
|
+
* the sender used an OTLP kvlist or array rather than a string.
|
|
2008
|
+
*/
|
|
2009
|
+
function logBody(body) {
|
|
2010
|
+
const text = require_json_fields.asString(body);
|
|
2011
|
+
if (text !== void 0) return text;
|
|
2012
|
+
if (body === void 0 || body === null) return "";
|
|
2013
|
+
const structured = require_json_fields.asObject(body);
|
|
2014
|
+
if (!structured) return String(body);
|
|
2015
|
+
return structured;
|
|
2016
|
+
}
|
|
2017
|
+
/**
|
|
2018
|
+
* Attributes handed to the agent layer, whose `Attributes` is OTel's own -
|
|
2019
|
+
* scalars and arrays of scalars, nothing nested.
|
|
2020
|
+
*
|
|
2021
|
+
* SAFETY: a coding agent's metrics and events carry scalar attributes only,
|
|
2022
|
+
* so the two shapes agree in practice. A sender that nests one anyway is
|
|
2023
|
+
* rendered by the Agents tab as whatever it is rather than being dropped.
|
|
2024
|
+
*/
|
|
2025
|
+
function agentAttributes(attributes) {
|
|
2026
|
+
return attributes;
|
|
2027
|
+
}
|
|
2028
|
+
function nanoToMs(nano) {
|
|
2029
|
+
if (!nano) return 0;
|
|
2030
|
+
const ns = BigInt(nano);
|
|
2031
|
+
const ms = ns / 1000000n;
|
|
2032
|
+
const remNs = ns % 1000000n;
|
|
2033
|
+
return Number(ms) + Number(remNs) / 1e6;
|
|
2034
|
+
}
|
|
2035
|
+
const SPAN_KIND_MAP = /* @__PURE__ */ new Map([
|
|
2036
|
+
[0, "INTERNAL"],
|
|
2037
|
+
[1, "INTERNAL"],
|
|
2038
|
+
[2, "SERVER"],
|
|
2039
|
+
[3, "CLIENT"],
|
|
2040
|
+
[4, "PRODUCER"],
|
|
2041
|
+
[5, "CONSUMER"],
|
|
2042
|
+
["SPAN_KIND_INTERNAL", "INTERNAL"],
|
|
2043
|
+
["SPAN_KIND_SERVER", "SERVER"],
|
|
2044
|
+
["SPAN_KIND_CLIENT", "CLIENT"],
|
|
2045
|
+
["SPAN_KIND_PRODUCER", "PRODUCER"],
|
|
2046
|
+
["SPAN_KIND_CONSUMER", "CONSUMER"]
|
|
2047
|
+
]);
|
|
2048
|
+
function normalizeHexId(id) {
|
|
2049
|
+
if (!id) return "";
|
|
2050
|
+
if (/^[A-Za-z0-9+/=]+$/.test(id) && !/^[0-9a-f]+$/i.test(id) && (id.length === 12 || id.length === 24 || id.length === 28 || id.length === 44 || id.length === 48)) try {
|
|
2051
|
+
return Buffer.from(id, "base64").toString("hex");
|
|
2052
|
+
} catch {}
|
|
2053
|
+
return id;
|
|
2054
|
+
}
|
|
2055
|
+
function parseOtlpTraces(payload) {
|
|
2056
|
+
const resourceSpans = otlpEnvelope(payload)?.resourceSpans;
|
|
2057
|
+
if (!resourceSpans || resourceSpans.length === 0) return [];
|
|
2058
|
+
const traceMap = /* @__PURE__ */ new Map();
|
|
2059
|
+
for (const rs of resourceSpans) {
|
|
2060
|
+
const resourceAttrs = flattenAttributes(rs.resource?.attributes);
|
|
2061
|
+
const service = String(resourceAttrs["service.name"] || "unknown");
|
|
2062
|
+
for (const ss of rs.scopeSpans ?? []) {
|
|
2063
|
+
const scope = ss.scope?.name ? {
|
|
2064
|
+
name: ss.scope.name,
|
|
2065
|
+
version: ss.scope.version || void 0
|
|
2066
|
+
} : void 0;
|
|
2067
|
+
for (const span of ss.spans || []) {
|
|
2068
|
+
const traceId = normalizeHexId(span.traceId);
|
|
2069
|
+
if (!traceId) continue;
|
|
2070
|
+
const startMs = nanoToMs(span.startTimeUnixNano);
|
|
2071
|
+
const endMs = nanoToMs(span.endTimeUnixNano);
|
|
2072
|
+
const statusCode = span.status?.code;
|
|
2073
|
+
let status = "UNSET";
|
|
2074
|
+
if (statusCode === 1 || statusCode === "STATUS_CODE_OK") status = "OK";
|
|
2075
|
+
if (statusCode === 2 || statusCode === "STATUS_CODE_ERROR") status = "ERROR";
|
|
2076
|
+
const spanData = {
|
|
2077
|
+
traceId,
|
|
2078
|
+
spanId: normalizeHexId(span.spanId),
|
|
2079
|
+
parentSpanId: normalizeHexId(span.parentSpanId) || void 0,
|
|
2080
|
+
name: span.name || "unknown",
|
|
2081
|
+
kind: SPAN_KIND_MAP.get(span.kind ?? 0) ?? "INTERNAL",
|
|
2082
|
+
startTime: startMs,
|
|
2083
|
+
endTime: endMs,
|
|
2084
|
+
duration: endMs - startMs,
|
|
2085
|
+
attributes: {
|
|
2086
|
+
...resourceAttrs,
|
|
2087
|
+
...flattenAttributes(span.attributes)
|
|
2088
|
+
},
|
|
2089
|
+
status: {
|
|
2090
|
+
code: status,
|
|
2091
|
+
message: span.status?.message
|
|
2092
|
+
},
|
|
2093
|
+
events: (span.events ?? []).map((e) => ({
|
|
2094
|
+
name: e.name || "",
|
|
2095
|
+
timestamp: nanoToMs(e.timeUnixNano),
|
|
2096
|
+
attributes: flattenAttributes(e.attributes)
|
|
2097
|
+
})),
|
|
2098
|
+
links: (span.links ?? []).map((l) => ({
|
|
2099
|
+
traceId: normalizeHexId(l.traceId),
|
|
2100
|
+
spanId: normalizeHexId(l.spanId),
|
|
2101
|
+
attributes: flattenAttributes(l.attributes)
|
|
2102
|
+
})),
|
|
2103
|
+
scope
|
|
2104
|
+
};
|
|
2105
|
+
const existing = traceMap.get(traceId);
|
|
2106
|
+
if (existing) existing.spans.push(spanData);
|
|
2107
|
+
else traceMap.set(traceId, {
|
|
2108
|
+
spans: [spanData],
|
|
2109
|
+
service
|
|
2110
|
+
});
|
|
2111
|
+
}
|
|
2112
|
+
}
|
|
2113
|
+
}
|
|
2114
|
+
const traces = [];
|
|
2115
|
+
for (const [traceId, { spans, service }] of traceMap) {
|
|
2116
|
+
const sorted = spans.sort((a, b) => a.startTime - b.startTime);
|
|
2117
|
+
const { rootSpan, partial } = require_trace_root.pickRoot(sorted);
|
|
2118
|
+
const startTime = Math.min(...sorted.map((s) => s.startTime));
|
|
2119
|
+
const endTime = Math.max(...sorted.map((s) => s.endTime));
|
|
2120
|
+
const hasError = sorted.some((s) => s.status.code === "ERROR");
|
|
2121
|
+
const trace = {
|
|
2122
|
+
traceId,
|
|
2123
|
+
correlationId: traceId.slice(0, 16),
|
|
2124
|
+
rootSpan,
|
|
2125
|
+
spans: sorted,
|
|
2126
|
+
startTime,
|
|
2127
|
+
endTime,
|
|
2128
|
+
duration: endTime - startTime,
|
|
2129
|
+
status: hasError ? "ERROR" : "OK",
|
|
2130
|
+
service
|
|
2131
|
+
};
|
|
2132
|
+
if (partial) trace.partial = true;
|
|
2133
|
+
traces.push(trace);
|
|
2134
|
+
}
|
|
2135
|
+
return traces;
|
|
2136
|
+
}
|
|
2137
|
+
function parseOtlpLogs(payload) {
|
|
2138
|
+
const resourceLogs = otlpEnvelope(payload)?.resourceLogs;
|
|
2139
|
+
if (!resourceLogs) return [];
|
|
2140
|
+
const logs = [];
|
|
2141
|
+
for (const rl of resourceLogs) {
|
|
2142
|
+
const resourceAttrs = flattenAttributes(rl.resource?.attributes);
|
|
2143
|
+
for (const sl of rl.scopeLogs ?? []) for (const rec of sl.logRecords ?? []) {
|
|
2144
|
+
const timestamp = nanoToMs(rec.timeUnixNano || rec.observedTimeUnixNano);
|
|
2145
|
+
const traceId = normalizeHexId(rec.traceId) || void 0;
|
|
2146
|
+
const spanId = normalizeHexId(rec.spanId) || void 0;
|
|
2147
|
+
const body = rec.body ? resolveOtlpValue(rec.body) : "";
|
|
2148
|
+
logs.push({
|
|
2149
|
+
id: `${traceId || "no-trace"}:${spanId || "no-span"}:${timestamp}:${rec.severityNumber || 0}`,
|
|
2150
|
+
traceId,
|
|
2151
|
+
spanId,
|
|
2152
|
+
resourceName: require_resource_utils.getResourceName(resourceAttrs),
|
|
2153
|
+
severityText: rec.severityText,
|
|
2154
|
+
severityNumber: rec.severityNumber,
|
|
2155
|
+
body: logBody(body),
|
|
2156
|
+
timestamp,
|
|
2157
|
+
attributes: flattenAttributes(rec.attributes),
|
|
2158
|
+
resource: resourceAttrs
|
|
2159
|
+
});
|
|
2160
|
+
}
|
|
2161
|
+
}
|
|
2162
|
+
return logs;
|
|
2163
|
+
}
|
|
2164
|
+
function countOtlpMetrics(payload) {
|
|
2165
|
+
const resourceMetrics = otlpEnvelope(payload)?.resourceMetrics;
|
|
2166
|
+
if (!resourceMetrics) return 0;
|
|
2167
|
+
let count = 0;
|
|
2168
|
+
for (const rm of resourceMetrics) for (const sm of rm.scopeMetrics ?? []) count += (sm.metrics ?? []).length;
|
|
2169
|
+
return count;
|
|
2170
|
+
}
|
|
2171
|
+
function extractDataPoints(metric) {
|
|
2172
|
+
const points = [];
|
|
2173
|
+
const numberPoints = metric.sum?.dataPoints ?? metric.gauge?.dataPoints;
|
|
2174
|
+
if (Array.isArray(numberPoints)) for (const dp of numberPoints) {
|
|
2175
|
+
const value = dp.asDouble !== void 0 ? Number(dp.asDouble) : dp.asInt !== void 0 ? Number(dp.asInt) : 0;
|
|
2176
|
+
points.push({
|
|
2177
|
+
value,
|
|
2178
|
+
attributes: agentAttributes(flattenAttributes(dp.attributes)),
|
|
2179
|
+
timestamp: nanoToMs(dp.timeUnixNano || dp.startTimeUnixNano)
|
|
2180
|
+
});
|
|
2181
|
+
}
|
|
2182
|
+
const histPoints = metric.histogram?.dataPoints;
|
|
2183
|
+
if (Array.isArray(histPoints)) for (const dp of histPoints) points.push({
|
|
2184
|
+
value: dp.count !== void 0 ? Number(dp.count) : 0,
|
|
2185
|
+
attributes: agentAttributes(flattenAttributes(dp.attributes)),
|
|
2186
|
+
timestamp: nanoToMs(dp.timeUnixNano || dp.startTimeUnixNano)
|
|
2187
|
+
});
|
|
2188
|
+
return points;
|
|
2189
|
+
}
|
|
2190
|
+
/**
|
|
2191
|
+
* Parse OTLP metrics into structured records with data points + attributes,
|
|
2192
|
+
* for the agent layer (and richer metric views). Works for both OTLP/JSON and
|
|
2193
|
+
* decoded OTLP/protobuf — they share the same camelCase shape.
|
|
2194
|
+
*/
|
|
2195
|
+
function readTemporality$1(metric) {
|
|
2196
|
+
const raw = metric.sum?.aggregationTemporality ?? metric.histogram?.aggregationTemporality;
|
|
2197
|
+
if (raw === 2 || raw === "AGGREGATION_TEMPORALITY_CUMULATIVE") return "cumulative";
|
|
2198
|
+
if (raw === 1 || raw === "AGGREGATION_TEMPORALITY_DELTA") return "delta";
|
|
2199
|
+
}
|
|
2200
|
+
function parseOtlpMetrics(payload) {
|
|
2201
|
+
const resourceMetrics = otlpEnvelope(payload)?.resourceMetrics;
|
|
2202
|
+
if (!resourceMetrics) return [];
|
|
2203
|
+
const records = [];
|
|
2204
|
+
for (const rm of resourceMetrics) {
|
|
2205
|
+
const resource = agentAttributes(flattenAttributes(rm.resource?.attributes));
|
|
2206
|
+
for (const sm of rm.scopeMetrics ?? []) {
|
|
2207
|
+
const scope = sm.scope?.name ? {
|
|
2208
|
+
name: sm.scope.name,
|
|
2209
|
+
version: sm.scope.version || void 0
|
|
2210
|
+
} : void 0;
|
|
2211
|
+
for (const metric of sm.metrics ?? []) records.push({
|
|
2212
|
+
name: metric.name ?? "",
|
|
2213
|
+
unit: metric.unit || void 0,
|
|
2214
|
+
description: metric.description || void 0,
|
|
2215
|
+
temporality: readTemporality$1(metric),
|
|
2216
|
+
dataPoints: extractDataPoints(metric),
|
|
2217
|
+
resource,
|
|
2218
|
+
scope
|
|
2219
|
+
});
|
|
2220
|
+
}
|
|
2221
|
+
}
|
|
2222
|
+
return records;
|
|
2223
|
+
}
|
|
2224
|
+
/**
|
|
2225
|
+
* Parse OTLP logs into `AgentRawEvent`s for the agent layer. Keeps the
|
|
2226
|
+
* instrumentation scope and event name (Claude Code emits its events as logs,
|
|
2227
|
+
* with the unprefixed name in the `event.name` attribute). Distinct from
|
|
2228
|
+
* `parseOtlpLogs`, which feeds the generic Logs tab.
|
|
2229
|
+
*/
|
|
2230
|
+
function parseOtlpAgentEvents(payload) {
|
|
2231
|
+
const resourceLogs = otlpEnvelope(payload)?.resourceLogs;
|
|
2232
|
+
if (!resourceLogs) return [];
|
|
2233
|
+
const events = [];
|
|
2234
|
+
for (const rl of resourceLogs) {
|
|
2235
|
+
const resource = agentAttributes(flattenAttributes(rl.resource?.attributes));
|
|
2236
|
+
for (const sl of rl.scopeLogs ?? []) {
|
|
2237
|
+
const scope = sl.scope?.name ? {
|
|
2238
|
+
name: sl.scope.name,
|
|
2239
|
+
version: sl.scope.version || void 0
|
|
2240
|
+
} : void 0;
|
|
2241
|
+
for (const rec of sl.logRecords ?? []) {
|
|
2242
|
+
const attributes = agentAttributes(flattenAttributes(rec.attributes));
|
|
2243
|
+
const eventName = rec.eventName || String(attributes["event.name"] ?? "");
|
|
2244
|
+
events.push({
|
|
2245
|
+
eventName,
|
|
2246
|
+
timestamp: nanoToMs(rec.timeUnixNano || rec.observedTimeUnixNano),
|
|
2247
|
+
body: rec.body ? resolveOtlpValue(rec.body) : void 0,
|
|
2248
|
+
attributes,
|
|
2249
|
+
resource,
|
|
2250
|
+
scope
|
|
2251
|
+
});
|
|
2252
|
+
}
|
|
2253
|
+
}
|
|
2254
|
+
}
|
|
2255
|
+
return events;
|
|
2256
|
+
}
|
|
2257
|
+
async function readJsonBody(req) {
|
|
2258
|
+
return new Promise((resolve, reject) => {
|
|
2259
|
+
const chunks = [];
|
|
2260
|
+
req.on("data", (chunk) => chunks.push(chunk));
|
|
2261
|
+
req.on("end", () => {
|
|
2262
|
+
try {
|
|
2263
|
+
resolve(JSON.parse(Buffer.concat(chunks).toString()));
|
|
2264
|
+
} catch {
|
|
2265
|
+
reject(/* @__PURE__ */ new Error("Invalid JSON"));
|
|
2266
|
+
}
|
|
2267
|
+
});
|
|
2268
|
+
req.on("error", reject);
|
|
2269
|
+
});
|
|
2270
|
+
}
|
|
2271
|
+
async function readRawBody(req) {
|
|
2272
|
+
return new Promise((resolve, reject) => {
|
|
2273
|
+
const chunks = [];
|
|
2274
|
+
req.on("data", (chunk) => chunks.push(chunk));
|
|
2275
|
+
req.on("end", () => resolve(Buffer.concat(chunks)));
|
|
2276
|
+
req.on("error", reject);
|
|
2277
|
+
});
|
|
2278
|
+
}
|
|
2279
|
+
/**
|
|
2280
|
+
* True for OTLP/protobuf bodies. The OpenTelemetry Python/Java/Go SDKs default to
|
|
2281
|
+
* `http/protobuf` over OTLP HTTP, sending `application/x-protobuf`; some clients use
|
|
2282
|
+
* `application/protobuf`. Anything else (JSON, unset) is treated as OTLP/JSON.
|
|
2283
|
+
*/
|
|
2284
|
+
function isProtobufContentType(contentType) {
|
|
2285
|
+
if (!contentType) return false;
|
|
2286
|
+
const value = contentType.toLowerCase();
|
|
2287
|
+
return value.includes("application/x-protobuf") || value.includes("application/protobuf");
|
|
2288
|
+
}
|
|
2289
|
+
/**
|
|
2290
|
+
* Below this, gzip costs more than it saves: the header alone is 18 bytes and
|
|
2291
|
+
* a small body already fits one segment.
|
|
2292
|
+
*/
|
|
2293
|
+
const GZIP_MIN_BYTES = 1024;
|
|
2294
|
+
/**
|
|
2295
|
+
* Send JSON, gzipped when the client accepts it and the body is big enough.
|
|
2296
|
+
*
|
|
2297
|
+
* A trace payload is mostly repeated keys and near-identical ids and strings,
|
|
2298
|
+
* which is the shape deflate handles best: a 4,891-span trace measures 2,078
|
|
2299
|
+
* KiB raw against 41 KiB gzipped. Reshaping the payload to dedupe scopes and
|
|
2300
|
+
* drop the repeated trace id was measured against this and is not worth doing,
|
|
2301
|
+
* since deflate already removes what such a dedupe removes.
|
|
2302
|
+
*
|
|
2303
|
+
* `res.req` is Node's own back-reference to the request, so the negotiation
|
|
2304
|
+
* needs nothing threaded through the twenty-odd call sites.
|
|
2305
|
+
*/
|
|
2306
|
+
function sendJson(res, status, data) {
|
|
2307
|
+
const body = Buffer.from(JSON.stringify(data), "utf8");
|
|
2308
|
+
if (String(res.req?.headers["accept-encoding"] ?? "").includes("gzip") && body.byteLength >= GZIP_MIN_BYTES) {
|
|
2309
|
+
const packed = (0, node_zlib.gzipSync)(body);
|
|
2310
|
+
res.writeHead(status, {
|
|
2311
|
+
"Content-Type": "application/json",
|
|
2312
|
+
"Content-Encoding": "gzip",
|
|
2313
|
+
Vary: "Accept-Encoding",
|
|
2314
|
+
"Content-Length": packed.byteLength
|
|
2315
|
+
});
|
|
2316
|
+
res.end(packed);
|
|
2317
|
+
return;
|
|
2318
|
+
}
|
|
2319
|
+
res.writeHead(status, {
|
|
2320
|
+
"Content-Type": "application/json",
|
|
2321
|
+
Vary: "Accept-Encoding",
|
|
2322
|
+
"Content-Length": body.byteLength
|
|
2323
|
+
});
|
|
2324
|
+
res.end(body);
|
|
2325
|
+
}
|
|
2326
|
+
|
|
2327
|
+
//#endregion
|
|
2328
|
+
//#region src/server/metric-streams.ts
|
|
2329
|
+
/** The aggregation arms of a Metric's oneof, in the order we probe them. */
|
|
2330
|
+
const ARMS = [
|
|
2331
|
+
["gauge", "gauge"],
|
|
2332
|
+
["sum", "sum"],
|
|
2333
|
+
["histogram", "histogram"],
|
|
2334
|
+
["exponentialHistogram", "exponentialHistogram"],
|
|
2335
|
+
["summary", "summary"]
|
|
2336
|
+
];
|
|
2337
|
+
function parseOtlpMetricStreams(payload) {
|
|
2338
|
+
const resourceMetrics = otlpEnvelope(payload)?.resourceMetrics;
|
|
2339
|
+
if (!Array.isArray(resourceMetrics)) return [];
|
|
2340
|
+
const streams = [];
|
|
2341
|
+
for (const rm of resourceMetrics) {
|
|
2342
|
+
const resource = flattenAttributes(rm.resource?.attributes);
|
|
2343
|
+
const service = String(resource["service.name"] ?? "unknown");
|
|
2344
|
+
for (const sm of rm.scopeMetrics ?? []) {
|
|
2345
|
+
const scope = sm.scope?.name ? {
|
|
2346
|
+
name: sm.scope.name,
|
|
2347
|
+
version: sm.scope.version || void 0
|
|
2348
|
+
} : void 0;
|
|
2349
|
+
for (const metric of sm.metrics ?? []) {
|
|
2350
|
+
const found = findAggregation(metric);
|
|
2351
|
+
if (!found) continue;
|
|
2352
|
+
const points = (found.aggregation.dataPoints ?? []).map((dp) => readPoint(dp, found.kind));
|
|
2353
|
+
if (points.length === 0) continue;
|
|
2354
|
+
streams.push({
|
|
2355
|
+
name: metric.name ?? "",
|
|
2356
|
+
unit: metric.unit || void 0,
|
|
2357
|
+
description: metric.description || void 0,
|
|
2358
|
+
kind: found.kind,
|
|
2359
|
+
temporality: readTemporality(found.aggregation),
|
|
2360
|
+
monotonic: found.aggregation.isMonotonic,
|
|
2361
|
+
service,
|
|
2362
|
+
scope,
|
|
2363
|
+
resource,
|
|
2364
|
+
points
|
|
2365
|
+
});
|
|
2366
|
+
}
|
|
2367
|
+
}
|
|
2368
|
+
}
|
|
2369
|
+
return streams;
|
|
2370
|
+
}
|
|
2371
|
+
function findAggregation(metric) {
|
|
2372
|
+
for (const [kind, key] of ARMS) {
|
|
2373
|
+
const aggregation = metric[key];
|
|
2374
|
+
if (aggregation) return {
|
|
2375
|
+
kind,
|
|
2376
|
+
aggregation
|
|
2377
|
+
};
|
|
2378
|
+
}
|
|
2379
|
+
}
|
|
2380
|
+
function readPoint(dp, kind) {
|
|
2381
|
+
const point = {
|
|
2382
|
+
timestamp: nanoToMs(dp.timeUnixNano || dp.startTimeUnixNano),
|
|
2383
|
+
startTimestamp: dp.startTimeUnixNano ? nanoToMs(dp.startTimeUnixNano) : void 0,
|
|
2384
|
+
attributes: flattenAttributes(dp.attributes)
|
|
2385
|
+
};
|
|
2386
|
+
if (kind === "gauge" || kind === "sum") point.value = readNumber(dp.asDouble) ?? readNumber(dp.asInt) ?? 0;
|
|
2387
|
+
if (kind !== "gauge" && kind !== "sum") {
|
|
2388
|
+
point.count = readNumber(dp.count);
|
|
2389
|
+
point.sum = readNumber(dp.sum);
|
|
2390
|
+
point.min = readNumber(dp.min);
|
|
2391
|
+
point.max = readNumber(dp.max);
|
|
2392
|
+
}
|
|
2393
|
+
if (Array.isArray(dp.bucketCounts)) point.bucketCounts = dp.bucketCounts.map((n) => readNumber(n) ?? 0);
|
|
2394
|
+
if (Array.isArray(dp.explicitBounds)) point.explicitBounds = dp.explicitBounds.map((n) => readNumber(n) ?? 0);
|
|
2395
|
+
if (kind === "exponentialHistogram") {
|
|
2396
|
+
point.scale = readNumber(dp.scale);
|
|
2397
|
+
point.zeroCount = readNumber(dp.zeroCount);
|
|
2398
|
+
point.zeroThreshold = readNumber(dp.zeroThreshold);
|
|
2399
|
+
point.positive = readExponentialBuckets(dp.positive);
|
|
2400
|
+
point.negative = readExponentialBuckets(dp.negative);
|
|
2401
|
+
}
|
|
2402
|
+
if (Array.isArray(dp.quantileValues) && dp.quantileValues.length > 0) point.quantiles = dp.quantileValues.map((q) => ({
|
|
2403
|
+
quantile: q.quantile ?? 0,
|
|
2404
|
+
value: q.value ?? 0
|
|
2405
|
+
}));
|
|
2406
|
+
if (Array.isArray(dp.exemplars) && dp.exemplars.length > 0) point.exemplars = dp.exemplars.map((ex) => ({
|
|
2407
|
+
value: readNumber(ex.asDouble) ?? readNumber(ex.asInt) ?? 0,
|
|
2408
|
+
timestamp: nanoToMs(ex.timeUnixNano),
|
|
2409
|
+
traceId: normalizeHexId(ex.traceId) || void 0,
|
|
2410
|
+
spanId: normalizeHexId(ex.spanId) || void 0
|
|
2411
|
+
}));
|
|
2412
|
+
return point;
|
|
2413
|
+
}
|
|
2414
|
+
function readExponentialBuckets(buckets) {
|
|
2415
|
+
if (!buckets || !Array.isArray(buckets.bucketCounts)) return void 0;
|
|
2416
|
+
return {
|
|
2417
|
+
offset: buckets.offset ?? 0,
|
|
2418
|
+
bucketCounts: buckets.bucketCounts.map((count) => readNumber(count) ?? 0)
|
|
2419
|
+
};
|
|
2420
|
+
}
|
|
2421
|
+
/**
|
|
2422
|
+
* Read an OTLP number.
|
|
2423
|
+
*
|
|
2424
|
+
* int64 arrives as a string in OTLP/JSON (it does not survive JSON) and as a
|
|
2425
|
+
* number once protobuf is decoded, so both spellings have to work.
|
|
2426
|
+
*/
|
|
2427
|
+
function readNumber(raw) {
|
|
2428
|
+
if (raw === void 0 || raw === null || raw === "") return void 0;
|
|
2429
|
+
const value = Number(raw);
|
|
2430
|
+
return Number.isFinite(value) ? value : void 0;
|
|
2431
|
+
}
|
|
2432
|
+
/** 1 = DELTA, 2 = CUMULATIVE; the string enum spelling is handled too. */
|
|
2433
|
+
function readTemporality(aggregation) {
|
|
2434
|
+
const raw = aggregation.aggregationTemporality;
|
|
2435
|
+
if (raw === 2 || raw === "AGGREGATION_TEMPORALITY_CUMULATIVE") return "cumulative";
|
|
2436
|
+
if (raw === 1 || raw === "AGGREGATION_TEMPORALITY_DELTA") return "delta";
|
|
2437
|
+
}
|
|
2438
|
+
|
|
2439
|
+
//#endregion
|
|
2440
|
+
//#region src/server/server.ts
|
|
2441
|
+
/** How often the store is pruned past its caps. */
|
|
2442
|
+
const DEFAULT_RETENTION_INTERVAL_MS = 3e4;
|
|
2443
|
+
/**
|
|
2444
|
+
* Traces read when aggregating errors.
|
|
2445
|
+
*
|
|
2446
|
+
* Deliberately large: an error group's count is only right if every occurrence
|
|
2447
|
+
* in the window is seen, and a page-sized read would under-report the common
|
|
2448
|
+
* failures most — the ones with occurrences past the page boundary.
|
|
2449
|
+
*/
|
|
2450
|
+
var DevtoolsServer = class {
|
|
2451
|
+
wss;
|
|
2452
|
+
wsPath;
|
|
2453
|
+
loopbackOnly;
|
|
2454
|
+
clients = /* @__PURE__ */ new Set();
|
|
2455
|
+
httpServer;
|
|
2456
|
+
traces = [];
|
|
2457
|
+
logs = [];
|
|
2458
|
+
agentSessions = /* @__PURE__ */ new Map();
|
|
2459
|
+
errorAggregator = new ErrorAggregator();
|
|
2460
|
+
limits;
|
|
2461
|
+
verbose;
|
|
2462
|
+
_port;
|
|
2463
|
+
onData;
|
|
2464
|
+
/**
|
|
2465
|
+
* Durable store. The in-memory `traces`/`logs` arrays above remain the live
|
|
2466
|
+
* tail — what a freshly-connected client is handed and what streams over WS —
|
|
2467
|
+
* while the store answers queries and outlives the process. Both are written
|
|
2468
|
+
* on every ingest; neither is derived from the other.
|
|
2469
|
+
*/
|
|
2470
|
+
store;
|
|
2471
|
+
retentionTimer = null;
|
|
2472
|
+
constructor(options = {}) {
|
|
2473
|
+
this.limits = resolveTelemetryLimits(options);
|
|
2474
|
+
this.verbose = options.verbose ?? false;
|
|
2475
|
+
this._port = options.port ?? 4318;
|
|
2476
|
+
this.onData = options.onData;
|
|
2477
|
+
this.store = new DevtoolsStore({
|
|
2478
|
+
path: options.dbPath,
|
|
2479
|
+
maxTraces: options.maxTraces,
|
|
2480
|
+
maxLogs: options.maxLogs,
|
|
2481
|
+
maxBytes: options.maxDbBytes
|
|
2482
|
+
});
|
|
2483
|
+
this.startRetentionLoop(options.retentionIntervalMs);
|
|
2484
|
+
this.httpServer = options.server ?? (0, node_http.createServer)();
|
|
2485
|
+
const loopbackOnly = options.host == null || hostHeaderIsLoopback(options.host);
|
|
2486
|
+
this.wsPath = options.path ?? "/ws";
|
|
2487
|
+
this.loopbackOnly = loopbackOnly;
|
|
2488
|
+
this.wss = new ws.WebSocketServer({
|
|
2489
|
+
noServer: true,
|
|
2490
|
+
perMessageDeflate: { threshold: 1024 }
|
|
2491
|
+
});
|
|
2492
|
+
this.attachWebSocket(this.httpServer);
|
|
2493
|
+
this.wss.on("error", (err) => {
|
|
2494
|
+
if (this.httpServer.listening) throw err;
|
|
2495
|
+
});
|
|
2496
|
+
this.wss.on("connection", (ws$1) => {
|
|
2497
|
+
this.clients.add(ws$1);
|
|
2498
|
+
this.log(`Client connected (${this.clients.size} total)`);
|
|
2499
|
+
const data = this.getCurrentData();
|
|
2500
|
+
if (data.traces.length > 0 || data.logs.length > 0 || data.errors.length > 0 || (data.agents?.length ?? 0) > 0) ws$1.send(JSON.stringify(data));
|
|
2501
|
+
ws$1.on("close", () => {
|
|
2502
|
+
this.clients.delete(ws$1);
|
|
2503
|
+
this.log(`Client disconnected (${this.clients.size} total)`);
|
|
2504
|
+
});
|
|
2505
|
+
});
|
|
2506
|
+
if (!options.server) this.httpServer.listen(this._port, () => {
|
|
2507
|
+
const addr = this.httpServer.address();
|
|
2508
|
+
if (addr && typeof addr === "object") this._port = addr.port;
|
|
2509
|
+
this.log(`WebSocket server listening on port ${this._port}`);
|
|
2510
|
+
});
|
|
2511
|
+
}
|
|
2512
|
+
get port() {
|
|
2513
|
+
const addr = this.httpServer.address();
|
|
2514
|
+
if (addr && typeof addr === "object") return addr.port;
|
|
2515
|
+
return this._port;
|
|
2516
|
+
}
|
|
2517
|
+
get clientCount() {
|
|
2518
|
+
return this.clients.size;
|
|
2519
|
+
}
|
|
2520
|
+
addTrace(trace) {
|
|
2521
|
+
const existing = this.traces.find((t) => t.traceId === trace.traceId);
|
|
2522
|
+
const merged = existing ?? trace;
|
|
2523
|
+
let newSpans = trace.spans;
|
|
2524
|
+
if (existing) {
|
|
2525
|
+
const existingSpanIds = new Set(existing.spans.map((s) => s.spanId));
|
|
2526
|
+
newSpans = [];
|
|
2527
|
+
for (const span of trace.spans) if (!existingSpanIds.has(span.spanId)) {
|
|
2528
|
+
existing.spans.push(span);
|
|
2529
|
+
newSpans.push(span);
|
|
2530
|
+
}
|
|
2531
|
+
existing.startTime = Math.min(existing.startTime, trace.startTime);
|
|
2532
|
+
existing.endTime = Math.max(existing.endTime, trace.endTime);
|
|
2533
|
+
existing.duration = existing.endTime - existing.startTime;
|
|
2534
|
+
if (trace.status === "ERROR") existing.status = "ERROR";
|
|
2535
|
+
existing.spans.sort((a, b) => a.startTime - b.startTime);
|
|
2536
|
+
const { rootSpan, partial } = require_trace_root.pickRoot(existing.spans);
|
|
2537
|
+
existing.rootSpan = rootSpan;
|
|
2538
|
+
if (partial) existing.partial = true;
|
|
2539
|
+
else {
|
|
2540
|
+
delete existing.partial;
|
|
2541
|
+
const rootService = rootSpan.attributes?.["service.name"];
|
|
2542
|
+
if (typeof rootService === "string" && rootService.length > 0) existing.service = rootService;
|
|
2543
|
+
}
|
|
2544
|
+
} else this.traces = appendWithLimit(this.traces, trace, this.limits.maxTraceCount);
|
|
2545
|
+
if (newSpans.length > 0) this.errorAggregator.addErrorsFromTrace({
|
|
2546
|
+
...trace,
|
|
2547
|
+
spans: newSpans
|
|
2548
|
+
});
|
|
2549
|
+
this.store.ingestTraces([merged]);
|
|
2550
|
+
this.broadcast({
|
|
2551
|
+
traces: [merged],
|
|
2552
|
+
logs: [],
|
|
2553
|
+
errors: this.errorAggregator.getErrorGroups()
|
|
2554
|
+
});
|
|
2555
|
+
}
|
|
2556
|
+
addTraces(traces) {
|
|
2557
|
+
for (const trace of traces) this.addTrace(trace);
|
|
2558
|
+
}
|
|
2559
|
+
addLog(log) {
|
|
2560
|
+
this.logs = appendWithLimit(this.logs, log, this.limits.maxLogCount);
|
|
2561
|
+
this.store.ingestLogs([log]);
|
|
2562
|
+
this.broadcast({
|
|
2563
|
+
traces: [],
|
|
2564
|
+
logs: [log],
|
|
2565
|
+
errors: this.errorAggregator.getErrorGroups()
|
|
2566
|
+
});
|
|
2567
|
+
}
|
|
2568
|
+
addLogs(logs) {
|
|
2569
|
+
this.logs = appendManyWithLimit(this.logs, logs, this.limits.maxLogCount);
|
|
2570
|
+
this.store.ingestLogs(logs);
|
|
2571
|
+
this.broadcast({
|
|
2572
|
+
traces: [],
|
|
2573
|
+
logs,
|
|
2574
|
+
errors: this.errorAggregator.getErrorGroups()
|
|
2575
|
+
});
|
|
2576
|
+
}
|
|
2577
|
+
/** Ingest one decoded OTLP request, regardless of its transport. */
|
|
2578
|
+
/**
|
|
2579
|
+
* Serve the live tail on another HTTP listener.
|
|
2580
|
+
*
|
|
2581
|
+
* A loopback bind produces two listeners, one per IP family, because
|
|
2582
|
+
* `localhost` resolves to `::1` on macOS and `127.0.0.1` elsewhere. The HTTP
|
|
2583
|
+
* routes were attached to both from the start and the WebSocket was not, so
|
|
2584
|
+
* the widget connected over one address and silently failed over the other:
|
|
2585
|
+
* telemetry visible, live tail dead, and nothing in the UI saying why.
|
|
2586
|
+
*
|
|
2587
|
+
* Both listeners hand their upgrades to the same `WebSocketServer`, so there
|
|
2588
|
+
* is still one client set and one broadcast.
|
|
2589
|
+
*/
|
|
2590
|
+
attachWebSocket(server) {
|
|
2591
|
+
server.on("upgrade", (req, socket, head) => {
|
|
2592
|
+
if ((req.url ?? "/").split("?")[0] !== this.wsPath) {
|
|
2593
|
+
socket.write("HTTP/1.1 400 Bad Request\r\n\r\n");
|
|
2594
|
+
socket.destroy();
|
|
2595
|
+
return;
|
|
2596
|
+
}
|
|
2597
|
+
if (!allowSensitiveRequest({
|
|
2598
|
+
origin: req.headers.origin,
|
|
2599
|
+
host: req.headers.host
|
|
2600
|
+
}, this.loopbackOnly)) {
|
|
2601
|
+
socket.write("HTTP/1.1 403 Forbidden\r\n\r\n");
|
|
2602
|
+
socket.destroy();
|
|
2603
|
+
return;
|
|
2604
|
+
}
|
|
2605
|
+
this.wss.handleUpgrade(req, socket, head, (ws$2) => {
|
|
2606
|
+
this.wss.emit("connection", ws$2, req);
|
|
2607
|
+
});
|
|
2608
|
+
});
|
|
2609
|
+
}
|
|
2610
|
+
ingestOtlp(signal, payload) {
|
|
2611
|
+
if (signal === "traces") {
|
|
2612
|
+
const traces = parseOtlpTraces(payload);
|
|
2613
|
+
this.addTraces(traces);
|
|
2614
|
+
return traces.length;
|
|
2615
|
+
}
|
|
2616
|
+
if (signal === "logs") {
|
|
2617
|
+
const logs = parseOtlpLogs(payload);
|
|
2618
|
+
this.addLogs(logs);
|
|
2619
|
+
this.ingestAgentEvents(parseOtlpAgentEvents(payload));
|
|
2620
|
+
return logs.length;
|
|
2621
|
+
}
|
|
2622
|
+
this.ingestAgentMetrics(parseOtlpMetrics(payload));
|
|
2623
|
+
this.ingestMetricStreams(parseOtlpMetricStreams(payload));
|
|
2624
|
+
return countOtlpMetrics(payload);
|
|
2625
|
+
}
|
|
2626
|
+
/** Fold decoded agent log events into sessions and broadcast the full set. */
|
|
2627
|
+
ingestAgentEvents(records) {
|
|
2628
|
+
if (records.length === 0) return;
|
|
2629
|
+
(0, autotel_agents.ingestAgentEvents)(this.agentSessions, records);
|
|
2630
|
+
this.broadcastAgents();
|
|
2631
|
+
}
|
|
2632
|
+
/**
|
|
2633
|
+
* Store decoded metric streams for the Metrics tab.
|
|
2634
|
+
*
|
|
2635
|
+
* Separate from `ingestAgentMetrics`: that folds the same OTLP batch into
|
|
2636
|
+
* coding-agent sessions through a counter-shaped model, while this keeps the
|
|
2637
|
+
* full data points — buckets, quantiles, exemplars — that charts need.
|
|
2638
|
+
*/
|
|
2639
|
+
ingestMetricStreams(streams) {
|
|
2640
|
+
if (streams.length === 0) return;
|
|
2641
|
+
this.store.ingestMetrics(streams);
|
|
2642
|
+
}
|
|
2643
|
+
/** Metric catalogue: every metric name held, with its kind and series count. */
|
|
2644
|
+
listMetricNames() {
|
|
2645
|
+
return this.store.listMetricNames();
|
|
2646
|
+
}
|
|
2647
|
+
queryMetricCatalog(query) {
|
|
2648
|
+
return this.store.queryMetricCatalog(query);
|
|
2649
|
+
}
|
|
2650
|
+
/** The series for one metric, with their points. */
|
|
2651
|
+
queryMetricSeries(args) {
|
|
2652
|
+
return this.store.queryMetricSeries(args);
|
|
2653
|
+
}
|
|
2654
|
+
/** Fold decoded agent metric records into sessions and broadcast the full set. */
|
|
2655
|
+
ingestAgentMetrics(records) {
|
|
2656
|
+
if (records.length === 0) return;
|
|
2657
|
+
(0, autotel_agents.ingestAgentMetrics)(this.agentSessions, records);
|
|
2658
|
+
this.broadcastAgents();
|
|
2659
|
+
}
|
|
2660
|
+
broadcastAgents() {
|
|
2661
|
+
this.broadcast({
|
|
2662
|
+
traces: [],
|
|
2663
|
+
logs: [],
|
|
2664
|
+
errors: this.errorAggregator.getErrorGroups(),
|
|
2665
|
+
agents: [...this.agentSessions.values()]
|
|
2666
|
+
});
|
|
2667
|
+
}
|
|
2668
|
+
/**
|
|
2669
|
+
* Run a query against the durable store.
|
|
2670
|
+
*
|
|
2671
|
+
* Distinct from `getCurrentData()`, which returns the live tail: this reaches
|
|
2672
|
+
* the whole retained history, which is normally far larger than the tail and
|
|
2673
|
+
* is the only way to see anything from before the process restarted.
|
|
2674
|
+
*/
|
|
2675
|
+
queryTraces(args) {
|
|
2676
|
+
return this.store.queryTraces(args);
|
|
2677
|
+
}
|
|
2678
|
+
/** Route and span-name counts, for the instrumentation coverage join. */
|
|
2679
|
+
observedSpans() {
|
|
2680
|
+
return this.store.observedSpans();
|
|
2681
|
+
}
|
|
2682
|
+
/** One row per matching span, as the population for a cohort comparison. */
|
|
2683
|
+
cohortRows(args) {
|
|
2684
|
+
return this.store.cohortRows(args);
|
|
2685
|
+
}
|
|
2686
|
+
/**
|
|
2687
|
+
* Aggregate errors from the store for a window and query.
|
|
2688
|
+
*
|
|
2689
|
+
* A fresh aggregator over whatever the store returns, rather than a second
|
|
2690
|
+
* implementation: the grouping, fingerprinting and sampling rules are the
|
|
2691
|
+
* same ones the live path uses, so the two cannot describe the same failure
|
|
2692
|
+
* differently.
|
|
2693
|
+
*
|
|
2694
|
+
* The live `errorAggregator` stays as it is — it backs the WS full-state
|
|
2695
|
+
* broadcast, which has no window and needs none.
|
|
2696
|
+
*/
|
|
2697
|
+
queryErrors(args) {
|
|
2698
|
+
const aggregator = new ErrorAggregator();
|
|
2699
|
+
const seenCursors = /* @__PURE__ */ new Set();
|
|
2700
|
+
let cursor = args.cursor;
|
|
2701
|
+
do {
|
|
2702
|
+
const result = this.store.queryTraces({
|
|
2703
|
+
...args,
|
|
2704
|
+
cursor
|
|
2705
|
+
});
|
|
2706
|
+
if (result.errors) return {
|
|
2707
|
+
errors: [],
|
|
2708
|
+
errors_parse: result.errors
|
|
2709
|
+
};
|
|
2710
|
+
for (const trace of result.traces) aggregator.addErrorsFromTrace(trace);
|
|
2711
|
+
cursor = result.nextCursor ?? void 0;
|
|
2712
|
+
if (cursor && seenCursors.has(cursor)) break;
|
|
2713
|
+
if (cursor) seenCursors.add(cursor);
|
|
2714
|
+
} while (cursor);
|
|
2715
|
+
return { errors: aggregator.getErrorGroups() };
|
|
2716
|
+
}
|
|
2717
|
+
/** Run a log query against the durable store. */
|
|
2718
|
+
queryLogs(args) {
|
|
2719
|
+
return this.store.queryLogs(args);
|
|
2720
|
+
}
|
|
2721
|
+
listQueryFields(signal) {
|
|
2722
|
+
return this.store.listQueryFields(signal);
|
|
2723
|
+
}
|
|
2724
|
+
searchAttributes(signal, value, limit) {
|
|
2725
|
+
return this.store.searchAttributes(signal, value, limit);
|
|
2726
|
+
}
|
|
2727
|
+
getStoreStats() {
|
|
2728
|
+
return this.store.getStats();
|
|
2729
|
+
}
|
|
2730
|
+
describeTrace(traceId) {
|
|
2731
|
+
return this.store.describeTrace(traceId);
|
|
2732
|
+
}
|
|
2733
|
+
findSlowestTraces(limit) {
|
|
2734
|
+
return this.store.findSlowest(limit);
|
|
2735
|
+
}
|
|
2736
|
+
/** Prune the store to its retention cap. Safe to call on a timer. */
|
|
2737
|
+
enforceRetention() {
|
|
2738
|
+
this.store.enforceRetention();
|
|
2739
|
+
}
|
|
2740
|
+
/**
|
|
2741
|
+
* Prune periodically for the life of the server.
|
|
2742
|
+
*
|
|
2743
|
+
* `unref` matters: without it this timer alone keeps the Node process alive,
|
|
2744
|
+
* so a CLI that has finished its work would hang instead of exiting. A
|
|
2745
|
+
* failure is logged rather than thrown — an interval callback that throws
|
|
2746
|
+
* takes the process down, and a missed prune is not worth that.
|
|
2747
|
+
*/
|
|
2748
|
+
startRetentionLoop(intervalMs = DEFAULT_RETENTION_INTERVAL_MS) {
|
|
2749
|
+
if (intervalMs <= 0) return;
|
|
2750
|
+
this.retentionTimer = setInterval(() => {
|
|
2751
|
+
try {
|
|
2752
|
+
this.store.enforceRetention();
|
|
2753
|
+
} catch (error) {
|
|
2754
|
+
this.log(`retention failed: ${String(error)}`);
|
|
2755
|
+
}
|
|
2756
|
+
}, intervalMs);
|
|
2757
|
+
this.retentionTimer.unref?.();
|
|
2758
|
+
}
|
|
2759
|
+
getCurrentData() {
|
|
2760
|
+
return {
|
|
2761
|
+
traces: this.traces,
|
|
2762
|
+
logs: this.logs,
|
|
2763
|
+
errors: this.errorAggregator.getErrorGroups(),
|
|
2764
|
+
agents: [...this.agentSessions.values()]
|
|
2765
|
+
};
|
|
2766
|
+
}
|
|
2767
|
+
clearData() {
|
|
2768
|
+
this.traces = [];
|
|
2769
|
+
this.logs = [];
|
|
2770
|
+
this.agentSessions.clear();
|
|
2771
|
+
this.errorAggregator.clear();
|
|
2772
|
+
this.store.clear();
|
|
2773
|
+
}
|
|
2774
|
+
clearSignal(signal) {
|
|
2775
|
+
this.store.clearSignal(signal);
|
|
2776
|
+
if (signal === "traces") {
|
|
2777
|
+
this.traces = [];
|
|
2778
|
+
this.errorAggregator.clear();
|
|
2779
|
+
} else if (signal === "logs") this.logs = [];
|
|
2780
|
+
}
|
|
2781
|
+
deleteMetric(name) {
|
|
2782
|
+
return this.store.deleteMetric(name);
|
|
2783
|
+
}
|
|
2784
|
+
deleteTraces(traceIds) {
|
|
2785
|
+
const ids = new Set(traceIds);
|
|
2786
|
+
this.traces = this.traces.filter((trace) => !ids.has(trace.traceId));
|
|
2787
|
+
const deleted = this.store.deleteTraces(traceIds);
|
|
2788
|
+
this.errorAggregator.clear();
|
|
2789
|
+
let cursor;
|
|
2790
|
+
do {
|
|
2791
|
+
const page = this.store.queryTraces({
|
|
2792
|
+
query: "",
|
|
2793
|
+
limit: 1e3,
|
|
2794
|
+
cursor
|
|
2795
|
+
});
|
|
2796
|
+
for (const trace of page.traces) this.errorAggregator.addErrorsFromTrace(trace);
|
|
2797
|
+
cursor = page.nextCursor ?? void 0;
|
|
2798
|
+
} while (cursor);
|
|
2799
|
+
return deleted;
|
|
2800
|
+
}
|
|
2801
|
+
broadcast(data) {
|
|
2802
|
+
const msg = JSON.stringify(data.traces?.length ? {
|
|
2803
|
+
...data,
|
|
2804
|
+
traces: require_wire.encodeTraces(data.traces)
|
|
2805
|
+
} : data);
|
|
2806
|
+
for (const client of this.clients) if (client.readyState === ws.WebSocket.OPEN) client.send(msg);
|
|
2807
|
+
if (this.onData) try {
|
|
2808
|
+
this.onData(data);
|
|
2809
|
+
} catch {}
|
|
2810
|
+
}
|
|
2811
|
+
log(message) {
|
|
2812
|
+
if (this.verbose) console.log(`[autotel-devtools] ${message}`);
|
|
2813
|
+
}
|
|
2814
|
+
async close() {
|
|
2815
|
+
if (this.retentionTimer) {
|
|
2816
|
+
clearInterval(this.retentionTimer);
|
|
2817
|
+
this.retentionTimer = null;
|
|
2818
|
+
}
|
|
2819
|
+
for (const client of this.clients) client.close();
|
|
2820
|
+
this.clients.clear();
|
|
2821
|
+
this.wss.close();
|
|
2822
|
+
await new Promise((resolve) => this.httpServer.close(() => resolve()));
|
|
2823
|
+
this.store.close();
|
|
2824
|
+
}
|
|
2825
|
+
};
|
|
2826
|
+
|
|
2827
|
+
//#endregion
|
|
2828
|
+
//#region src/server/otlp-proto.ts
|
|
2829
|
+
const COMMON_PROTO = `
|
|
2830
|
+
syntax = "proto3";
|
|
2831
|
+
package opentelemetry.proto.common.v1;
|
|
2832
|
+
|
|
2833
|
+
message AnyValue {
|
|
2834
|
+
oneof value {
|
|
2835
|
+
string string_value = 1;
|
|
2836
|
+
bool bool_value = 2;
|
|
2837
|
+
int64 int_value = 3;
|
|
2838
|
+
double double_value = 4;
|
|
2839
|
+
ArrayValue array_value = 5;
|
|
2840
|
+
KeyValueList kvlist_value = 6;
|
|
2841
|
+
bytes bytes_value = 7;
|
|
2842
|
+
}
|
|
2843
|
+
}
|
|
2844
|
+
message ArrayValue { repeated AnyValue values = 1; }
|
|
2845
|
+
message KeyValueList { repeated KeyValue values = 1; }
|
|
2846
|
+
message KeyValue {
|
|
2847
|
+
string key = 1;
|
|
2848
|
+
AnyValue value = 2;
|
|
2849
|
+
}
|
|
2850
|
+
message InstrumentationScope {
|
|
2851
|
+
string name = 1;
|
|
2852
|
+
string version = 2;
|
|
2853
|
+
repeated KeyValue attributes = 3;
|
|
2854
|
+
uint32 dropped_attributes_count = 4;
|
|
2855
|
+
}
|
|
2856
|
+
`;
|
|
2857
|
+
const RESOURCE_PROTO = `
|
|
2858
|
+
syntax = "proto3";
|
|
2859
|
+
package opentelemetry.proto.resource.v1;
|
|
2860
|
+
|
|
2861
|
+
message Resource {
|
|
2862
|
+
repeated opentelemetry.proto.common.v1.KeyValue attributes = 1;
|
|
2863
|
+
uint32 dropped_attributes_count = 2;
|
|
2864
|
+
}
|
|
2865
|
+
`;
|
|
2866
|
+
const TRACE_PROTO = `
|
|
2867
|
+
syntax = "proto3";
|
|
2868
|
+
package opentelemetry.proto.trace.v1;
|
|
2869
|
+
|
|
2870
|
+
message ResourceSpans {
|
|
2871
|
+
opentelemetry.proto.resource.v1.Resource resource = 1;
|
|
2872
|
+
repeated ScopeSpans scope_spans = 2;
|
|
2873
|
+
string schema_url = 3;
|
|
2874
|
+
}
|
|
2875
|
+
message ScopeSpans {
|
|
2876
|
+
opentelemetry.proto.common.v1.InstrumentationScope scope = 1;
|
|
2877
|
+
repeated Span spans = 2;
|
|
2878
|
+
string schema_url = 3;
|
|
2879
|
+
}
|
|
2880
|
+
message Span {
|
|
2881
|
+
bytes trace_id = 1;
|
|
2882
|
+
bytes span_id = 2;
|
|
2883
|
+
string trace_state = 3;
|
|
2884
|
+
bytes parent_span_id = 4;
|
|
2885
|
+
fixed32 flags = 16;
|
|
2886
|
+
string name = 5;
|
|
2887
|
+
SpanKind kind = 6;
|
|
2888
|
+
fixed64 start_time_unix_nano = 7;
|
|
2889
|
+
fixed64 end_time_unix_nano = 8;
|
|
2890
|
+
repeated opentelemetry.proto.common.v1.KeyValue attributes = 9;
|
|
2891
|
+
uint32 dropped_attributes_count = 10;
|
|
2892
|
+
repeated Event events = 11;
|
|
2893
|
+
uint32 dropped_events_count = 12;
|
|
2894
|
+
repeated Link links = 13;
|
|
2895
|
+
uint32 dropped_links_count = 14;
|
|
2896
|
+
Status status = 15;
|
|
2897
|
+
|
|
2898
|
+
enum SpanKind {
|
|
2899
|
+
SPAN_KIND_UNSPECIFIED = 0;
|
|
2900
|
+
SPAN_KIND_INTERNAL = 1;
|
|
2901
|
+
SPAN_KIND_SERVER = 2;
|
|
2902
|
+
SPAN_KIND_CLIENT = 3;
|
|
2903
|
+
SPAN_KIND_PRODUCER = 4;
|
|
2904
|
+
SPAN_KIND_CONSUMER = 5;
|
|
2905
|
+
}
|
|
2906
|
+
message Event {
|
|
2907
|
+
fixed64 time_unix_nano = 1;
|
|
2908
|
+
string name = 2;
|
|
2909
|
+
repeated opentelemetry.proto.common.v1.KeyValue attributes = 3;
|
|
2910
|
+
uint32 dropped_attributes_count = 4;
|
|
2911
|
+
}
|
|
2912
|
+
message Link {
|
|
2913
|
+
bytes trace_id = 1;
|
|
2914
|
+
bytes span_id = 2;
|
|
2915
|
+
string trace_state = 3;
|
|
2916
|
+
repeated opentelemetry.proto.common.v1.KeyValue attributes = 4;
|
|
2917
|
+
uint32 dropped_attributes_count = 5;
|
|
2918
|
+
fixed32 flags = 6;
|
|
2919
|
+
}
|
|
2920
|
+
}
|
|
2921
|
+
message Status {
|
|
2922
|
+
reserved 1;
|
|
2923
|
+
string message = 2;
|
|
2924
|
+
StatusCode code = 3;
|
|
2925
|
+
|
|
2926
|
+
enum StatusCode {
|
|
2927
|
+
STATUS_CODE_UNSET = 0;
|
|
2928
|
+
STATUS_CODE_OK = 1;
|
|
2929
|
+
STATUS_CODE_ERROR = 2;
|
|
2930
|
+
}
|
|
2931
|
+
}
|
|
2932
|
+
message ExportTraceServiceRequest {
|
|
2933
|
+
repeated ResourceSpans resource_spans = 1;
|
|
2934
|
+
}
|
|
2935
|
+
`;
|
|
2936
|
+
const LOGS_PROTO = `
|
|
2937
|
+
syntax = "proto3";
|
|
2938
|
+
package opentelemetry.proto.logs.v1;
|
|
2939
|
+
|
|
2940
|
+
enum SeverityNumber {
|
|
2941
|
+
SEVERITY_NUMBER_UNSPECIFIED = 0;
|
|
2942
|
+
SEVERITY_NUMBER_TRACE = 1;
|
|
2943
|
+
SEVERITY_NUMBER_TRACE2 = 2;
|
|
2944
|
+
SEVERITY_NUMBER_TRACE3 = 3;
|
|
2945
|
+
SEVERITY_NUMBER_TRACE4 = 4;
|
|
2946
|
+
SEVERITY_NUMBER_DEBUG = 5;
|
|
2947
|
+
SEVERITY_NUMBER_DEBUG2 = 6;
|
|
2948
|
+
SEVERITY_NUMBER_DEBUG3 = 7;
|
|
2949
|
+
SEVERITY_NUMBER_DEBUG4 = 8;
|
|
2950
|
+
SEVERITY_NUMBER_INFO = 9;
|
|
2951
|
+
SEVERITY_NUMBER_INFO2 = 10;
|
|
2952
|
+
SEVERITY_NUMBER_INFO3 = 11;
|
|
2953
|
+
SEVERITY_NUMBER_INFO4 = 12;
|
|
2954
|
+
SEVERITY_NUMBER_WARN = 13;
|
|
2955
|
+
SEVERITY_NUMBER_WARN2 = 14;
|
|
2956
|
+
SEVERITY_NUMBER_WARN3 = 15;
|
|
2957
|
+
SEVERITY_NUMBER_WARN4 = 16;
|
|
2958
|
+
SEVERITY_NUMBER_ERROR = 17;
|
|
2959
|
+
SEVERITY_NUMBER_ERROR2 = 18;
|
|
2960
|
+
SEVERITY_NUMBER_ERROR3 = 19;
|
|
2961
|
+
SEVERITY_NUMBER_ERROR4 = 20;
|
|
2962
|
+
SEVERITY_NUMBER_FATAL = 21;
|
|
2963
|
+
SEVERITY_NUMBER_FATAL2 = 22;
|
|
2964
|
+
SEVERITY_NUMBER_FATAL3 = 23;
|
|
2965
|
+
SEVERITY_NUMBER_FATAL4 = 24;
|
|
2966
|
+
}
|
|
2967
|
+
message ResourceLogs {
|
|
2968
|
+
opentelemetry.proto.resource.v1.Resource resource = 1;
|
|
2969
|
+
repeated ScopeLogs scope_logs = 2;
|
|
2970
|
+
string schema_url = 3;
|
|
2971
|
+
}
|
|
2972
|
+
message ScopeLogs {
|
|
2973
|
+
opentelemetry.proto.common.v1.InstrumentationScope scope = 1;
|
|
2974
|
+
repeated LogRecord log_records = 2;
|
|
2975
|
+
string schema_url = 3;
|
|
2976
|
+
}
|
|
2977
|
+
message LogRecord {
|
|
2978
|
+
reserved 4;
|
|
2979
|
+
fixed64 time_unix_nano = 1;
|
|
2980
|
+
fixed64 observed_time_unix_nano = 11;
|
|
2981
|
+
SeverityNumber severity_number = 2;
|
|
2982
|
+
string severity_text = 3;
|
|
2983
|
+
opentelemetry.proto.common.v1.AnyValue body = 5;
|
|
2984
|
+
repeated opentelemetry.proto.common.v1.KeyValue attributes = 6;
|
|
2985
|
+
uint32 dropped_attributes_count = 7;
|
|
2986
|
+
fixed32 flags = 8;
|
|
2987
|
+
bytes trace_id = 9;
|
|
2988
|
+
bytes span_id = 10;
|
|
2989
|
+
}
|
|
2990
|
+
message ExportLogsServiceRequest {
|
|
2991
|
+
repeated ResourceLogs resource_logs = 1;
|
|
2992
|
+
}
|
|
2993
|
+
`;
|
|
2994
|
+
const METRICS_PROTO = `
|
|
2995
|
+
syntax = "proto3";
|
|
2996
|
+
package opentelemetry.proto.metrics.v1;
|
|
2997
|
+
|
|
2998
|
+
enum AggregationTemporality {
|
|
2999
|
+
AGGREGATION_TEMPORALITY_UNSPECIFIED = 0;
|
|
3000
|
+
AGGREGATION_TEMPORALITY_DELTA = 1;
|
|
3001
|
+
AGGREGATION_TEMPORALITY_CUMULATIVE = 2;
|
|
3002
|
+
}
|
|
3003
|
+
message ResourceMetrics {
|
|
3004
|
+
opentelemetry.proto.resource.v1.Resource resource = 1;
|
|
3005
|
+
repeated ScopeMetrics scope_metrics = 2;
|
|
3006
|
+
string schema_url = 3;
|
|
3007
|
+
}
|
|
3008
|
+
message ScopeMetrics {
|
|
3009
|
+
opentelemetry.proto.common.v1.InstrumentationScope scope = 1;
|
|
3010
|
+
repeated Metric metrics = 2;
|
|
3011
|
+
string schema_url = 3;
|
|
3012
|
+
}
|
|
3013
|
+
message Metric {
|
|
3014
|
+
string name = 1;
|
|
3015
|
+
string description = 2;
|
|
3016
|
+
string unit = 3;
|
|
3017
|
+
oneof data {
|
|
3018
|
+
Gauge gauge = 5;
|
|
3019
|
+
Sum sum = 7;
|
|
3020
|
+
Histogram histogram = 9;
|
|
3021
|
+
ExponentialHistogram exponential_histogram = 10;
|
|
3022
|
+
Summary summary = 11;
|
|
3023
|
+
}
|
|
3024
|
+
}
|
|
3025
|
+
message Gauge {
|
|
3026
|
+
repeated NumberDataPoint data_points = 1;
|
|
3027
|
+
}
|
|
3028
|
+
message Sum {
|
|
3029
|
+
repeated NumberDataPoint data_points = 1;
|
|
3030
|
+
AggregationTemporality aggregation_temporality = 2;
|
|
3031
|
+
bool is_monotonic = 3;
|
|
3032
|
+
}
|
|
3033
|
+
message Histogram {
|
|
3034
|
+
repeated HistogramDataPoint data_points = 1;
|
|
3035
|
+
AggregationTemporality aggregation_temporality = 2;
|
|
3036
|
+
}
|
|
3037
|
+
message ExponentialHistogram {
|
|
3038
|
+
repeated ExponentialHistogramDataPoint data_points = 1;
|
|
3039
|
+
AggregationTemporality aggregation_temporality = 2;
|
|
3040
|
+
}
|
|
3041
|
+
message Summary {
|
|
3042
|
+
repeated SummaryDataPoint data_points = 1;
|
|
3043
|
+
}
|
|
3044
|
+
message NumberDataPoint {
|
|
3045
|
+
repeated opentelemetry.proto.common.v1.KeyValue attributes = 7;
|
|
3046
|
+
fixed64 start_time_unix_nano = 2;
|
|
3047
|
+
fixed64 time_unix_nano = 3;
|
|
3048
|
+
oneof value {
|
|
3049
|
+
double as_double = 4;
|
|
3050
|
+
sfixed64 as_int = 6;
|
|
3051
|
+
}
|
|
3052
|
+
repeated Exemplar exemplars = 5;
|
|
3053
|
+
uint32 flags = 8;
|
|
3054
|
+
}
|
|
3055
|
+
message HistogramDataPoint {
|
|
3056
|
+
repeated opentelemetry.proto.common.v1.KeyValue attributes = 9;
|
|
3057
|
+
fixed64 start_time_unix_nano = 2;
|
|
3058
|
+
fixed64 time_unix_nano = 3;
|
|
3059
|
+
uint64 count = 4;
|
|
3060
|
+
double sum = 5;
|
|
3061
|
+
repeated uint64 bucket_counts = 6;
|
|
3062
|
+
repeated double explicit_bounds = 7;
|
|
3063
|
+
repeated Exemplar exemplars = 8;
|
|
3064
|
+
uint32 flags = 10;
|
|
3065
|
+
double min = 11;
|
|
3066
|
+
double max = 12;
|
|
3067
|
+
}
|
|
3068
|
+
message ExponentialHistogramDataPoint {
|
|
3069
|
+
repeated opentelemetry.proto.common.v1.KeyValue attributes = 1;
|
|
3070
|
+
fixed64 start_time_unix_nano = 2;
|
|
3071
|
+
fixed64 time_unix_nano = 3;
|
|
3072
|
+
uint64 count = 4;
|
|
3073
|
+
double sum = 5;
|
|
3074
|
+
sint32 scale = 6;
|
|
3075
|
+
uint64 zero_count = 7;
|
|
3076
|
+
Buckets positive = 8;
|
|
3077
|
+
Buckets negative = 9;
|
|
3078
|
+
uint32 flags = 10;
|
|
3079
|
+
repeated Exemplar exemplars = 11;
|
|
3080
|
+
double min = 12;
|
|
3081
|
+
double max = 13;
|
|
3082
|
+
double zero_threshold = 14;
|
|
3083
|
+
|
|
3084
|
+
message Buckets {
|
|
3085
|
+
sint32 offset = 1;
|
|
3086
|
+
repeated uint64 bucket_counts = 2;
|
|
3087
|
+
}
|
|
3088
|
+
}
|
|
3089
|
+
message SummaryDataPoint {
|
|
3090
|
+
repeated opentelemetry.proto.common.v1.KeyValue attributes = 7;
|
|
3091
|
+
fixed64 start_time_unix_nano = 2;
|
|
3092
|
+
fixed64 time_unix_nano = 3;
|
|
3093
|
+
uint64 count = 4;
|
|
3094
|
+
double sum = 5;
|
|
3095
|
+
repeated ValueAtQuantile quantile_values = 6;
|
|
3096
|
+
uint32 flags = 8;
|
|
3097
|
+
|
|
3098
|
+
message ValueAtQuantile {
|
|
3099
|
+
double quantile = 1;
|
|
3100
|
+
double value = 2;
|
|
3101
|
+
}
|
|
3102
|
+
}
|
|
3103
|
+
message Exemplar {
|
|
3104
|
+
repeated opentelemetry.proto.common.v1.KeyValue filtered_attributes = 7;
|
|
3105
|
+
fixed64 time_unix_nano = 2;
|
|
3106
|
+
oneof value {
|
|
3107
|
+
double as_double = 3;
|
|
3108
|
+
sfixed64 as_int = 6;
|
|
3109
|
+
}
|
|
3110
|
+
bytes span_id = 4;
|
|
3111
|
+
bytes trace_id = 5;
|
|
3112
|
+
}
|
|
3113
|
+
message ExportMetricsServiceRequest {
|
|
3114
|
+
repeated ResourceMetrics resource_metrics = 1;
|
|
3115
|
+
}
|
|
3116
|
+
`;
|
|
3117
|
+
const TO_OBJECT_OPTIONS = {
|
|
3118
|
+
longs: String,
|
|
3119
|
+
bytes: String,
|
|
3120
|
+
defaults: false
|
|
3121
|
+
};
|
|
3122
|
+
let cachedRoot = null;
|
|
3123
|
+
function getRoot() {
|
|
3124
|
+
if (cachedRoot) return cachedRoot;
|
|
3125
|
+
const root = new protobufjs.default.Root();
|
|
3126
|
+
for (const source of [
|
|
3127
|
+
COMMON_PROTO,
|
|
3128
|
+
RESOURCE_PROTO,
|
|
3129
|
+
TRACE_PROTO,
|
|
3130
|
+
LOGS_PROTO,
|
|
3131
|
+
METRICS_PROTO
|
|
3132
|
+
]) protobufjs.default.parse(source, root, { keepCase: false });
|
|
3133
|
+
root.resolveAll();
|
|
3134
|
+
cachedRoot = root;
|
|
3135
|
+
return root;
|
|
3136
|
+
}
|
|
3137
|
+
function decodeRequest(typeName, body) {
|
|
3138
|
+
const messageType = getRoot().lookupType(typeName);
|
|
3139
|
+
const message = messageType.decode(body);
|
|
3140
|
+
return messageType.toObject(message, TO_OBJECT_OPTIONS);
|
|
3141
|
+
}
|
|
3142
|
+
/** Decode an OTLP/protobuf `ExportTraceServiceRequest` into the OTLP/JSON object shape. */
|
|
3143
|
+
function decodeOtlpTraceRequest(body) {
|
|
3144
|
+
return decodeRequest("opentelemetry.proto.trace.v1.ExportTraceServiceRequest", body);
|
|
3145
|
+
}
|
|
3146
|
+
/** Decode an OTLP/protobuf `ExportLogsServiceRequest` into the OTLP/JSON object shape. */
|
|
3147
|
+
function decodeOtlpLogsRequest(body) {
|
|
3148
|
+
return decodeRequest("opentelemetry.proto.logs.v1.ExportLogsServiceRequest", body);
|
|
3149
|
+
}
|
|
3150
|
+
/** Decode an OTLP/protobuf `ExportMetricsServiceRequest` into the OTLP/JSON object shape. */
|
|
3151
|
+
function decodeOtlpMetricsRequest(body) {
|
|
3152
|
+
return decodeRequest("opentelemetry.proto.metrics.v1.ExportMetricsServiceRequest", body);
|
|
3153
|
+
}
|
|
3154
|
+
|
|
3155
|
+
//#endregion
|
|
3156
|
+
//#region src/server/identity.ts
|
|
3157
|
+
/** Value of the `x-autotel-devtools` response header and the /healthz `service` field. */
|
|
3158
|
+
const DEVTOOLS_IDENTITY = "autotel-devtools";
|
|
3159
|
+
/**
|
|
3160
|
+
* Probe `host:port` over HTTP and classify what is listening. Used when our
|
|
3161
|
+
* requested port is busy: it lets us tell "a stale autotel-devtools is still
|
|
3162
|
+
* up" (benign) apart from "a foreign collector owns this port" — the latter is
|
|
3163
|
+
* the silent footgun where apps keep exporting OTLP to the busy port and reach
|
|
3164
|
+
* the wrong process, so the devtools UI stays empty and the app sees errors.
|
|
3165
|
+
*/
|
|
3166
|
+
async function probePortHolder(host, port, timeoutMs = 500) {
|
|
3167
|
+
const authority = host.includes(":") ? `[${host}]` : host;
|
|
3168
|
+
const controller = new AbortController();
|
|
3169
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
3170
|
+
try {
|
|
3171
|
+
const res = await fetch(`http://${authority}:${port}/healthz`, { signal: controller.signal });
|
|
3172
|
+
if (res.headers.get("x-autotel-devtools")) return "autotel-devtools";
|
|
3173
|
+
try {
|
|
3174
|
+
const body = await res.json();
|
|
3175
|
+
if (body && body.service === "autotel-devtools") return "autotel-devtools";
|
|
3176
|
+
} catch {}
|
|
3177
|
+
return "foreign";
|
|
3178
|
+
} catch {
|
|
3179
|
+
return "none";
|
|
3180
|
+
} finally {
|
|
3181
|
+
clearTimeout(timer);
|
|
3182
|
+
}
|
|
3183
|
+
}
|
|
3184
|
+
|
|
3185
|
+
//#endregion
|
|
3186
|
+
//#region src/server/source-file.ts
|
|
3187
|
+
/** Refuse to slurp something huge just because a span named it. */
|
|
3188
|
+
const MAX_BYTES = 2e6;
|
|
3189
|
+
const DISABLED = /* @__PURE__ */ new Set([
|
|
3190
|
+
"false",
|
|
3191
|
+
"0",
|
|
3192
|
+
"off",
|
|
3193
|
+
"no",
|
|
3194
|
+
""
|
|
3195
|
+
]);
|
|
3196
|
+
/**
|
|
3197
|
+
* Decide what `GET /source` may read, from `AUTOTEL_DEVTOOLS_SOURCE_ROOT`.
|
|
3198
|
+
*
|
|
3199
|
+
* Defaults **on**, at the working directory: devtools is a local tool whose
|
|
3200
|
+
* whole point is showing you your own code, and requiring a flag for that would
|
|
3201
|
+
* mean nobody ever sees the feature. The blast radius stays small because two
|
|
3202
|
+
* other things still hold — the receiver is bound to loopback, and nothing
|
|
3203
|
+
* outside this directory is reachable. Set the variable to `false` to turn it
|
|
3204
|
+
* off outright.
|
|
3205
|
+
*
|
|
3206
|
+
* A non-loopback bind (`--host 0.0.0.0`) removes the first of those, and the
|
|
3207
|
+
* Origin guard does not replace it: a request with no `Origin` at all — any
|
|
3208
|
+
* `curl` on the network — passes. The root holds whatever else lives in the
|
|
3209
|
+
* project, `.env` included, so the default flips to **off** there. An explicit
|
|
3210
|
+
* root is still honoured: exposing it on purpose is the caller's call.
|
|
3211
|
+
*/
|
|
3212
|
+
function resolveSourceRoot(configured, cwd, loopbackOnly = true) {
|
|
3213
|
+
if (configured === void 0) return loopbackOnly ? cwd : void 0;
|
|
3214
|
+
if (DISABLED.has(configured.trim().toLowerCase())) return void 0;
|
|
3215
|
+
return configured;
|
|
3216
|
+
}
|
|
3217
|
+
/**
|
|
3218
|
+
* Resolve `requested` against `root`, or return `null` if it escapes.
|
|
3219
|
+
*
|
|
3220
|
+
* Containment is judged on **real** paths so a symlink inside the root that
|
|
3221
|
+
* points outside it is rejected — lexical `..` stripping alone cannot see that.
|
|
3222
|
+
* The value returned is the *lexical* resolution, because the real one differs
|
|
3223
|
+
* from the caller's path whenever an ancestor is a symlink (on macOS both
|
|
3224
|
+
* `/tmp` and `/var` are), and a caller comparing paths should not have to know.
|
|
3225
|
+
*/
|
|
3226
|
+
function resolveWithinRoot(root, requested) {
|
|
3227
|
+
const lexicalRoot = node_path.default.resolve(root);
|
|
3228
|
+
const realRoot = safeRealpath(lexicalRoot);
|
|
3229
|
+
if (realRoot === null) return null;
|
|
3230
|
+
const lexicalTarget = node_path.default.resolve(lexicalRoot, requested);
|
|
3231
|
+
const realTarget = safeRealpath(lexicalTarget);
|
|
3232
|
+
if (realTarget === null) return null;
|
|
3233
|
+
if (!isInside(realRoot, realTarget)) return null;
|
|
3234
|
+
return lexicalTarget;
|
|
3235
|
+
}
|
|
3236
|
+
/** True when `target` is `root` itself or sits beneath it. */
|
|
3237
|
+
function isInside(root, target) {
|
|
3238
|
+
if (target === root) return true;
|
|
3239
|
+
return target.startsWith(root.endsWith(node_path.default.sep) ? root : root + node_path.default.sep);
|
|
3240
|
+
}
|
|
3241
|
+
function safeRealpath(p) {
|
|
3242
|
+
try {
|
|
3243
|
+
return (0, node_fs.realpathSync)(p);
|
|
3244
|
+
} catch {
|
|
3245
|
+
return null;
|
|
3246
|
+
}
|
|
3247
|
+
}
|
|
3248
|
+
/**
|
|
3249
|
+
* Read `context` lines either side of `line` from a file inside `root`.
|
|
3250
|
+
* Returns `null` when the path escapes the root, is not a readable file, or is
|
|
3251
|
+
* too large — the caller cannot distinguish those, which is the point.
|
|
3252
|
+
*/
|
|
3253
|
+
function readSourceWindow(root, requested, line, context) {
|
|
3254
|
+
const resolved = resolveWithinRoot(root, requested);
|
|
3255
|
+
if (resolved === null) return null;
|
|
3256
|
+
let text;
|
|
3257
|
+
try {
|
|
3258
|
+
const stat = (0, node_fs.statSync)(resolved);
|
|
3259
|
+
if (!stat.isFile() || stat.size > MAX_BYTES) return null;
|
|
3260
|
+
text = (0, node_fs.readFileSync)(resolved, "utf8");
|
|
3261
|
+
} catch {
|
|
3262
|
+
return null;
|
|
3263
|
+
}
|
|
3264
|
+
const all = text.split("\n");
|
|
3265
|
+
if (all.at(-1) === "") all.pop();
|
|
3266
|
+
const startLine = Math.max(1, line - context);
|
|
3267
|
+
const endLine = Math.min(all.length, line + context);
|
|
3268
|
+
if (startLine > all.length) return null;
|
|
3269
|
+
return {
|
|
3270
|
+
file: node_path.default.relative(node_path.default.resolve(root), resolved),
|
|
3271
|
+
line,
|
|
3272
|
+
startLine,
|
|
3273
|
+
lines: all.slice(startLine - 1, endLine)
|
|
3274
|
+
};
|
|
3275
|
+
}
|
|
3276
|
+
|
|
3277
|
+
//#endregion
|
|
3278
|
+
//#region src/server/coverage/coverage.ts
|
|
3279
|
+
/**
|
|
3280
|
+
* The names a span could plausibly carry for this route.
|
|
3281
|
+
*
|
|
3282
|
+
* `http.route` is the semconv attribute a framework integration sets. The span
|
|
3283
|
+
* *name* is the other route in, and for an HTTP handler the convention is
|
|
3284
|
+
* `"{method} {route}"`, which is what `trace()` and every framework middleware
|
|
3285
|
+
* produce. A non-HTTP entry point has no method, so its own name is the only
|
|
3286
|
+
* thing to match.
|
|
3287
|
+
*/
|
|
3288
|
+
function candidateNames(route) {
|
|
3289
|
+
if (!route.method) return [route.path];
|
|
3290
|
+
return [`${route.method.toUpperCase()} ${route.path}`];
|
|
3291
|
+
}
|
|
3292
|
+
function joinCoverage(routes, observed) {
|
|
3293
|
+
const entries = routes.map((route) => {
|
|
3294
|
+
const byRoute = route.method ? 0 : observed.routeCounts[route.path] ?? 0;
|
|
3295
|
+
const byRouteAny = observed.routeCounts[route.path] ?? 0;
|
|
3296
|
+
const byName = candidateNames(route).reduce((total, name) => total + (observed.spanNameCounts[name] ?? 0), 0);
|
|
3297
|
+
const namedAnyMethod = Object.keys(observed.spanNameCounts).some((name) => name.endsWith(` ${route.path}`));
|
|
3298
|
+
const spanCount = byName > 0 ? byName : namedAnyMethod ? 0 : byRoute || byRouteAny;
|
|
3299
|
+
return {
|
|
3300
|
+
...route,
|
|
3301
|
+
seen: spanCount > 0,
|
|
3302
|
+
spanCount
|
|
3303
|
+
};
|
|
3304
|
+
});
|
|
3305
|
+
entries.sort((left, right) => Number(left.seen) - Number(right.seen));
|
|
3306
|
+
return {
|
|
3307
|
+
entries,
|
|
3308
|
+
seenCount: entries.filter((entry) => entry.seen).length,
|
|
3309
|
+
total: entries.length
|
|
3310
|
+
};
|
|
3311
|
+
}
|
|
3312
|
+
|
|
3313
|
+
//#endregion
|
|
3314
|
+
//#region src/server/http.ts
|
|
3315
|
+
function sendOtlpError(res, req, e) {
|
|
3316
|
+
sendJson(res, 400, {
|
|
3317
|
+
error: "Invalid OTLP payload",
|
|
3318
|
+
message: e instanceof Error ? e.message : String(e),
|
|
3319
|
+
contentType: req.headers["content-type"] ?? null
|
|
3320
|
+
});
|
|
3321
|
+
}
|
|
3322
|
+
const PROTOBUF_DECODERS = {
|
|
3323
|
+
traces: decodeOtlpTraceRequest,
|
|
3324
|
+
logs: decodeOtlpLogsRequest,
|
|
3325
|
+
metrics: decodeOtlpMetricsRequest
|
|
3326
|
+
};
|
|
3327
|
+
async function readOtlpPayload(req, signal) {
|
|
3328
|
+
if (isProtobufContentType(req.headers["content-type"])) return PROTOBUF_DECODERS[signal](await readRawBody(req));
|
|
3329
|
+
return readJsonBody(req);
|
|
3330
|
+
}
|
|
3331
|
+
function findPackageRoot() {
|
|
3332
|
+
let dir = (0, node_path.dirname)((0, node_url.fileURLToPath)(require("url").pathToFileURL(__filename).href));
|
|
3333
|
+
for (let i = 0; i < 5; i++) {
|
|
3334
|
+
if ((0, node_fs.existsSync)((0, node_path.resolve)(dir, "package.json"))) return dir;
|
|
3335
|
+
dir = (0, node_path.dirname)(dir);
|
|
3336
|
+
}
|
|
3337
|
+
return dir;
|
|
3338
|
+
}
|
|
3339
|
+
const DEVTOOLS_FAVICON_SVG = "<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 64 64\"><rect width=\"64\" height=\"64\" rx=\"14\" fill=\"#0f172a\"/><text x=\"32\" y=\"41\" text-anchor=\"middle\" font-size=\"32\">🛰️</text></svg>";
|
|
3340
|
+
/**
|
|
3341
|
+
* The title is user-supplied (`--title` / `AUTOTEL_DEVTOOLS_TITLE`) and lands
|
|
3342
|
+
* inside `<title>`, where an unescaped `<` would close the element early.
|
|
3343
|
+
*/
|
|
3344
|
+
function escapeHtml(value) {
|
|
3345
|
+
return value.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll("\"", """).replaceAll("'", "'");
|
|
3346
|
+
}
|
|
3347
|
+
function renderFullpageHtml(title = "autotel-devtools") {
|
|
3348
|
+
return `<!DOCTYPE html><html><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"><title>${escapeHtml(title)}</title><link rel="icon" href="/favicon.svg" type="image/svg+xml"><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>`;
|
|
3349
|
+
}
|
|
3350
|
+
let cachedVersion = null;
|
|
3351
|
+
function getVersion() {
|
|
3352
|
+
if (cachedVersion !== null) return cachedVersion;
|
|
3353
|
+
let version = "unknown";
|
|
3354
|
+
try {
|
|
3355
|
+
const pkg = JSON.parse((0, node_fs.readFileSync)((0, node_path.resolve)(findPackageRoot(), "package.json"), "utf8"));
|
|
3356
|
+
if (typeof pkg.version === "string") version = pkg.version;
|
|
3357
|
+
} catch {}
|
|
3358
|
+
cachedVersion = version;
|
|
3359
|
+
return version;
|
|
3360
|
+
}
|
|
3361
|
+
const widgetJsCache = /* @__PURE__ */ new Map();
|
|
3362
|
+
/**
|
|
3363
|
+
* The browser bundle for one surface.
|
|
3364
|
+
*
|
|
3365
|
+
* Two are built: `fullpage.global.js` carries every view, while
|
|
3366
|
+
* `widget.global.js` is the reduced set for embedding in someone else's page.
|
|
3367
|
+
* Serving the right one is what makes the split worth anything — handing the
|
|
3368
|
+
* full bundle to an embedder would ship them the views the split exists to
|
|
3369
|
+
* spare them.
|
|
3370
|
+
*
|
|
3371
|
+
* The full-page bundle falls back to the widget one when it is missing, so a
|
|
3372
|
+
* partially-built checkout still serves a working UI rather than a comment.
|
|
3373
|
+
*/
|
|
3374
|
+
function getWidgetJs(surface) {
|
|
3375
|
+
const cached = widgetJsCache.get(surface);
|
|
3376
|
+
if (cached) return cached;
|
|
3377
|
+
const pkgRoot = findPackageRoot();
|
|
3378
|
+
const candidates = (surface === "fullpage" ? ["fullpage.global.js", "widget.global.js"] : ["widget.global.js"]).flatMap((name) => [(0, node_path.resolve)(pkgRoot, "dist", name), (0, node_path.resolve)(pkgRoot, name)]);
|
|
3379
|
+
let contents = null;
|
|
3380
|
+
for (const candidate of candidates) try {
|
|
3381
|
+
contents = (0, node_fs.readFileSync)(candidate, "utf8");
|
|
3382
|
+
break;
|
|
3383
|
+
} catch {}
|
|
3384
|
+
const resolved = contents ?? "// widget bundle not found - run pnpm build first";
|
|
3385
|
+
widgetJsCache.set(surface, resolved);
|
|
3386
|
+
return resolved;
|
|
3387
|
+
}
|
|
3388
|
+
function attachDevtoolsRoutes(httpServer, devtools, options = {}) {
|
|
3389
|
+
const loopbackOnly = options.loopbackOnly ?? true;
|
|
3390
|
+
const sourceRoot = options.sourceRoot;
|
|
3391
|
+
const fullpageHtml = renderFullpageHtml(options.title);
|
|
3392
|
+
httpServer.on("request", async (req, res) => {
|
|
3393
|
+
if (req.headers.upgrade?.toLowerCase() === "websocket") return;
|
|
3394
|
+
res.setHeader("Access-Control-Allow-Origin", "*");
|
|
3395
|
+
res.setHeader("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS");
|
|
3396
|
+
res.setHeader("Access-Control-Allow-Headers", "Content-Type");
|
|
3397
|
+
res.setHeader("x-autotel-devtools", getVersion());
|
|
3398
|
+
res.setHeader("Access-Control-Expose-Headers", "x-autotel-devtools");
|
|
3399
|
+
if (req.method === "OPTIONS") {
|
|
3400
|
+
res.writeHead(204);
|
|
3401
|
+
res.end();
|
|
3402
|
+
return;
|
|
3403
|
+
}
|
|
3404
|
+
const url = req.url || "/";
|
|
3405
|
+
if (req.method === "GET" && url === "/") {
|
|
3406
|
+
res.writeHead(200, {
|
|
3407
|
+
"Content-Type": "text/html; charset=utf-8",
|
|
3408
|
+
"Content-Length": Buffer.byteLength(fullpageHtml)
|
|
3409
|
+
});
|
|
3410
|
+
res.end(fullpageHtml);
|
|
3411
|
+
return;
|
|
3412
|
+
}
|
|
3413
|
+
if (req.method === "GET" && url.startsWith("/widget.js")) {
|
|
3414
|
+
const js = getWidgetJs(url.includes("mode=fullpage") ? "fullpage" : "widget");
|
|
3415
|
+
res.writeHead(200, {
|
|
3416
|
+
"Content-Type": "application/javascript; charset=utf-8",
|
|
3417
|
+
"Content-Length": Buffer.byteLength(js)
|
|
3418
|
+
});
|
|
3419
|
+
res.end(js);
|
|
3420
|
+
return;
|
|
3421
|
+
}
|
|
3422
|
+
if (req.method === "GET" && (url === "/favicon.svg" || url === "/favicon.ico")) {
|
|
3423
|
+
res.writeHead(200, {
|
|
3424
|
+
"Content-Type": "image/svg+xml; charset=utf-8",
|
|
3425
|
+
"Cache-Control": "public, max-age=86400",
|
|
3426
|
+
"Content-Length": Buffer.byteLength(DEVTOOLS_FAVICON_SVG)
|
|
3427
|
+
});
|
|
3428
|
+
res.end(DEVTOOLS_FAVICON_SVG);
|
|
3429
|
+
return;
|
|
3430
|
+
}
|
|
3431
|
+
if (req.method === "GET" && url.split("?")[0] === "/api/query/attributes") {
|
|
3432
|
+
if (!allowSensitiveRequest(req.headers, loopbackOnly)) {
|
|
3433
|
+
sendJson(res, 403, { error: "Forbidden" });
|
|
3434
|
+
return;
|
|
3435
|
+
}
|
|
3436
|
+
const params = new URL(url, "http://localhost").searchParams;
|
|
3437
|
+
const signal = params.get("signal") === "logs" ? "logs" : "traces";
|
|
3438
|
+
sendJson(res, 200, { attributes: devtools.searchAttributes(signal, params.get("value") ?? "") });
|
|
3439
|
+
return;
|
|
3440
|
+
}
|
|
3441
|
+
if (req.method === "GET" && url.split("?")[0] === "/source") {
|
|
3442
|
+
if (!sourceRoot) {
|
|
3443
|
+
sendJson(res, 404, { error: "Not found" });
|
|
3444
|
+
return;
|
|
3445
|
+
}
|
|
3446
|
+
if (!allowSensitiveRequest(req.headers, loopbackOnly)) {
|
|
3447
|
+
sendJson(res, 403, { error: "Forbidden" });
|
|
3448
|
+
return;
|
|
3449
|
+
}
|
|
3450
|
+
const query = new URL(url, "http://localhost").searchParams;
|
|
3451
|
+
const file = query.get("file");
|
|
3452
|
+
const line = Number(query.get("line"));
|
|
3453
|
+
const context = Math.min(Math.max(Number(query.get("context") ?? 5) || 0, 0), 50);
|
|
3454
|
+
if (!file || !Number.isInteger(line) || line < 1) {
|
|
3455
|
+
sendJson(res, 400, { error: "file and a positive integer line are required" });
|
|
3456
|
+
return;
|
|
3457
|
+
}
|
|
3458
|
+
const window = readSourceWindow(sourceRoot, file, line, context);
|
|
3459
|
+
if (window === null) {
|
|
3460
|
+
sendJson(res, 404, { error: "Not found" });
|
|
3461
|
+
return;
|
|
3462
|
+
}
|
|
3463
|
+
sendJson(res, 200, { ...window });
|
|
3464
|
+
return;
|
|
3465
|
+
}
|
|
3466
|
+
if (req.method === "GET" && url === "/healthz") {
|
|
3467
|
+
sendJson(res, 200, {
|
|
3468
|
+
ok: true,
|
|
3469
|
+
service: DEVTOOLS_IDENTITY,
|
|
3470
|
+
version: getVersion(),
|
|
3471
|
+
clients: devtools.clientCount
|
|
3472
|
+
});
|
|
3473
|
+
return;
|
|
3474
|
+
}
|
|
3475
|
+
if (req.method === "GET" && url === "/v1/traces") {
|
|
3476
|
+
if (!allowSensitiveRequest(req.headers, loopbackOnly)) {
|
|
3477
|
+
sendJson(res, 403, { error: "Forbidden" });
|
|
3478
|
+
return;
|
|
3479
|
+
}
|
|
3480
|
+
const data = devtools.getCurrentData();
|
|
3481
|
+
sendJson(res, 200, {
|
|
3482
|
+
traces: data.traces,
|
|
3483
|
+
count: data.traces.length
|
|
3484
|
+
});
|
|
3485
|
+
return;
|
|
3486
|
+
}
|
|
3487
|
+
if (req.method === "DELETE" && url === "/v1/traces") {
|
|
3488
|
+
if (!allowSensitiveRequest(req.headers, loopbackOnly)) {
|
|
3489
|
+
sendJson(res, 403, { error: "Forbidden" });
|
|
3490
|
+
return;
|
|
3491
|
+
}
|
|
3492
|
+
devtools.clearData();
|
|
3493
|
+
sendJson(res, 200, { cleared: true });
|
|
3494
|
+
return;
|
|
3495
|
+
}
|
|
3496
|
+
if (req.method === "DELETE" && (url === "/v1/logs" || url === "/v1/metrics")) {
|
|
3497
|
+
if (!allowSensitiveRequest(req.headers, loopbackOnly)) {
|
|
3498
|
+
sendJson(res, 403, { error: "Forbidden" });
|
|
3499
|
+
return;
|
|
3500
|
+
}
|
|
3501
|
+
const signal = url.endsWith("/logs") ? "logs" : "metrics";
|
|
3502
|
+
devtools.clearSignal(signal);
|
|
3503
|
+
sendJson(res, 200, {
|
|
3504
|
+
cleared: true,
|
|
3505
|
+
signal
|
|
3506
|
+
});
|
|
3507
|
+
return;
|
|
3508
|
+
}
|
|
3509
|
+
if (req.method === "DELETE" && url === "/api/traces") {
|
|
3510
|
+
if (!allowSensitiveRequest(req.headers, loopbackOnly)) {
|
|
3511
|
+
sendJson(res, 403, { error: "Forbidden" });
|
|
3512
|
+
return;
|
|
3513
|
+
}
|
|
3514
|
+
const body = await readJsonBody(req);
|
|
3515
|
+
const traceIds = Array.isArray(body.traceIds) ? body.traceIds.filter((id) => typeof id === "string") : [];
|
|
3516
|
+
sendJson(res, 200, { deleted: devtools.deleteTraces(traceIds) });
|
|
3517
|
+
return;
|
|
3518
|
+
}
|
|
3519
|
+
if (req.method === "POST" && url === "/v1/traces") {
|
|
3520
|
+
try {
|
|
3521
|
+
const payload = await readOtlpPayload(req, "traces");
|
|
3522
|
+
sendJson(res, 200, { acceptedTraces: devtools.ingestOtlp("traces", payload) });
|
|
3523
|
+
} catch (e) {
|
|
3524
|
+
sendOtlpError(res, req, e);
|
|
3525
|
+
}
|
|
3526
|
+
return;
|
|
3527
|
+
}
|
|
3528
|
+
if (req.method === "POST" && url === "/v1/logs") {
|
|
3529
|
+
try {
|
|
3530
|
+
const payload = await readOtlpPayload(req, "logs");
|
|
3531
|
+
sendJson(res, 200, { acceptedLogs: devtools.ingestOtlp("logs", payload) });
|
|
3532
|
+
} catch (e) {
|
|
3533
|
+
sendOtlpError(res, req, e);
|
|
3534
|
+
}
|
|
3535
|
+
return;
|
|
3536
|
+
}
|
|
3537
|
+
if (req.method === "POST" && url === "/v1/metrics") {
|
|
3538
|
+
try {
|
|
3539
|
+
const payload = await readOtlpPayload(req, "metrics");
|
|
3540
|
+
sendJson(res, 200, { acceptedMetrics: devtools.ingestOtlp("metrics", payload) });
|
|
3541
|
+
} catch (e) {
|
|
3542
|
+
sendOtlpError(res, req, e);
|
|
3543
|
+
}
|
|
3544
|
+
return;
|
|
3545
|
+
}
|
|
3546
|
+
if (req.method === "GET" && (url === "/api/query/traces/fields" || url === "/api/query/logs/fields")) {
|
|
3547
|
+
if (!allowSensitiveRequest(req.headers, loopbackOnly)) {
|
|
3548
|
+
sendJson(res, 403, { error: "Forbidden" });
|
|
3549
|
+
return;
|
|
3550
|
+
}
|
|
3551
|
+
const signal = url.includes("/logs/") ? "logs" : "traces";
|
|
3552
|
+
sendJson(res, 200, { fields: devtools.listQueryFields(signal) });
|
|
3553
|
+
return;
|
|
3554
|
+
}
|
|
3555
|
+
if (req.method === "POST" && url === "/api/query/traces") {
|
|
3556
|
+
if (!allowSensitiveRequest(req.headers, loopbackOnly)) {
|
|
3557
|
+
sendJson(res, 403, { error: "Forbidden" });
|
|
3558
|
+
return;
|
|
3559
|
+
}
|
|
3560
|
+
try {
|
|
3561
|
+
const body = await readJsonBody(req);
|
|
3562
|
+
const result = devtools.queryTraces({
|
|
3563
|
+
query: body.query ?? "",
|
|
3564
|
+
window: body.window,
|
|
3565
|
+
limit: body.limit,
|
|
3566
|
+
cursor: body.cursor
|
|
3567
|
+
});
|
|
3568
|
+
if (result.errors) {
|
|
3569
|
+
sendJson(res, 400, { errors: result.errors });
|
|
3570
|
+
return;
|
|
3571
|
+
}
|
|
3572
|
+
sendJson(res, 200, {
|
|
3573
|
+
traces: result.traces,
|
|
3574
|
+
nextCursor: result.nextCursor
|
|
3575
|
+
});
|
|
3576
|
+
} catch (e) {
|
|
3577
|
+
sendJson(res, 400, {
|
|
3578
|
+
error: "Invalid query request",
|
|
3579
|
+
message: e instanceof Error ? e.message : String(e)
|
|
3580
|
+
});
|
|
3581
|
+
}
|
|
3582
|
+
return;
|
|
3583
|
+
}
|
|
3584
|
+
if (req.method === "GET" && url === "/api/coverage") {
|
|
3585
|
+
if (!allowSensitiveRequest(req.headers, loopbackOnly)) {
|
|
3586
|
+
sendJson(res, 403, { error: "Forbidden" });
|
|
3587
|
+
return;
|
|
3588
|
+
}
|
|
3589
|
+
if (!sourceRoot) {
|
|
3590
|
+
sendJson(res, 404, {
|
|
3591
|
+
error: "Coverage unavailable",
|
|
3592
|
+
message: "No source root configured, so `autotel.map.json` cannot be located."
|
|
3593
|
+
});
|
|
3594
|
+
return;
|
|
3595
|
+
}
|
|
3596
|
+
const mapPath = (0, node_path.resolve)(sourceRoot, "autotel.map.json");
|
|
3597
|
+
if (!(0, node_fs.existsSync)(mapPath)) {
|
|
3598
|
+
sendJson(res, 404, {
|
|
3599
|
+
error: "No instrumentation map",
|
|
3600
|
+
message: "Run `npx autotel map` to record this project's entry points, then reload."
|
|
3601
|
+
});
|
|
3602
|
+
return;
|
|
3603
|
+
}
|
|
3604
|
+
try {
|
|
3605
|
+
sendJson(res, 200, joinCoverage(JSON.parse((0, node_fs.readFileSync)(mapPath, "utf8")).routes ?? [], devtools.observedSpans()));
|
|
3606
|
+
} catch (e) {
|
|
3607
|
+
sendJson(res, 400, {
|
|
3608
|
+
error: "Unreadable instrumentation map",
|
|
3609
|
+
message: e instanceof Error ? e.message : String(e)
|
|
3610
|
+
});
|
|
3611
|
+
}
|
|
3612
|
+
return;
|
|
3613
|
+
}
|
|
3614
|
+
if (req.method === "POST" && url === "/api/analysis/compare") {
|
|
3615
|
+
if (!allowSensitiveRequest(req.headers, loopbackOnly)) {
|
|
3616
|
+
sendJson(res, 403, { error: "Forbidden" });
|
|
3617
|
+
return;
|
|
3618
|
+
}
|
|
3619
|
+
let compareCohorts;
|
|
3620
|
+
try {
|
|
3621
|
+
({compareCohorts} = await import("autotel/analysis"));
|
|
3622
|
+
} catch {
|
|
3623
|
+
sendJson(res, 501, {
|
|
3624
|
+
error: "Comparison unavailable",
|
|
3625
|
+
message: "Install `autotel` alongside autotel-devtools to compare cohorts."
|
|
3626
|
+
});
|
|
3627
|
+
return;
|
|
3628
|
+
}
|
|
3629
|
+
try {
|
|
3630
|
+
const body = await readJsonBody(req);
|
|
3631
|
+
const outlier = devtools.cohortRows({
|
|
3632
|
+
query: body.outlier?.query ?? "",
|
|
3633
|
+
window: body.outlier?.window
|
|
3634
|
+
});
|
|
3635
|
+
const baseline = devtools.cohortRows({
|
|
3636
|
+
query: body.baseline?.query ?? "",
|
|
3637
|
+
window: body.baseline?.window
|
|
3638
|
+
});
|
|
3639
|
+
sendJson(res, 200, {
|
|
3640
|
+
differences: compareCohorts({
|
|
3641
|
+
outlier,
|
|
3642
|
+
baseline,
|
|
3643
|
+
ignoreFields: body.ignoreFields,
|
|
3644
|
+
limit: body.limit
|
|
3645
|
+
}),
|
|
3646
|
+
outlierCount: outlier.length,
|
|
3647
|
+
baselineCount: baseline.length
|
|
3648
|
+
});
|
|
3649
|
+
} catch (e) {
|
|
3650
|
+
sendJson(res, 400, {
|
|
3651
|
+
error: "Invalid comparison request",
|
|
3652
|
+
message: e instanceof Error ? e.message : String(e)
|
|
3653
|
+
});
|
|
3654
|
+
}
|
|
3655
|
+
return;
|
|
3656
|
+
}
|
|
3657
|
+
if (req.method === "POST" && url === "/api/query/logs") {
|
|
3658
|
+
if (!allowSensitiveRequest(req.headers, loopbackOnly)) {
|
|
3659
|
+
sendJson(res, 403, { error: "Forbidden" });
|
|
3660
|
+
return;
|
|
3661
|
+
}
|
|
3662
|
+
try {
|
|
3663
|
+
const body = await readJsonBody(req);
|
|
3664
|
+
const result = devtools.queryLogs({
|
|
3665
|
+
query: body.query ?? "",
|
|
3666
|
+
window: body.window,
|
|
3667
|
+
limit: body.limit,
|
|
3668
|
+
cursor: body.cursor
|
|
3669
|
+
});
|
|
3670
|
+
if (result.errors) {
|
|
3671
|
+
sendJson(res, 400, { errors: result.errors });
|
|
3672
|
+
return;
|
|
3673
|
+
}
|
|
3674
|
+
sendJson(res, 200, {
|
|
3675
|
+
logs: result.logs,
|
|
3676
|
+
nextCursor: result.nextCursor
|
|
3677
|
+
});
|
|
3678
|
+
} catch (e) {
|
|
3679
|
+
sendJson(res, 400, {
|
|
3680
|
+
error: "Invalid query request",
|
|
3681
|
+
message: e instanceof Error ? e.message : String(e)
|
|
3682
|
+
});
|
|
3683
|
+
}
|
|
3684
|
+
return;
|
|
3685
|
+
}
|
|
3686
|
+
if (req.method === "POST" && url === "/api/query/errors") {
|
|
3687
|
+
if (!allowSensitiveRequest(req.headers, loopbackOnly)) {
|
|
3688
|
+
sendJson(res, 403, { error: "Forbidden" });
|
|
3689
|
+
return;
|
|
3690
|
+
}
|
|
3691
|
+
try {
|
|
3692
|
+
const body = await readJsonBody(req);
|
|
3693
|
+
const result = devtools.queryErrors({
|
|
3694
|
+
query: body.query ?? "",
|
|
3695
|
+
window: body.window,
|
|
3696
|
+
limit: body.limit
|
|
3697
|
+
});
|
|
3698
|
+
if (result.errors_parse) {
|
|
3699
|
+
sendJson(res, 400, { errors: result.errors_parse });
|
|
3700
|
+
return;
|
|
3701
|
+
}
|
|
3702
|
+
sendJson(res, 200, { errors: result.errors });
|
|
3703
|
+
} catch (e) {
|
|
3704
|
+
sendJson(res, 400, {
|
|
3705
|
+
error: "Invalid query request",
|
|
3706
|
+
message: e instanceof Error ? e.message : String(e)
|
|
3707
|
+
});
|
|
3708
|
+
}
|
|
3709
|
+
return;
|
|
3710
|
+
}
|
|
3711
|
+
if (req.method === "GET" && url === "/api/metrics") {
|
|
3712
|
+
if (!allowSensitiveRequest(req.headers, loopbackOnly)) {
|
|
3713
|
+
sendJson(res, 403, { error: "Forbidden" });
|
|
3714
|
+
return;
|
|
3715
|
+
}
|
|
3716
|
+
sendJson(res, 200, { metrics: devtools.listMetricNames() });
|
|
3717
|
+
return;
|
|
3718
|
+
}
|
|
3719
|
+
if (req.method === "GET" && url.split("?")[0] === "/api/metrics") {
|
|
3720
|
+
if (!allowSensitiveRequest(req.headers, loopbackOnly)) {
|
|
3721
|
+
sendJson(res, 403, { error: "Forbidden" });
|
|
3722
|
+
return;
|
|
3723
|
+
}
|
|
3724
|
+
const query = new URL(url, "http://localhost").searchParams.get("q") ?? "";
|
|
3725
|
+
const result = devtools.queryMetricCatalog(query);
|
|
3726
|
+
if (result.errors) sendJson(res, 400, { errors: result.errors });
|
|
3727
|
+
else sendJson(res, 200, { metrics: result.metrics });
|
|
3728
|
+
return;
|
|
3729
|
+
}
|
|
3730
|
+
if (req.method === "DELETE" && url.split("?")[0] === "/api/metrics") {
|
|
3731
|
+
if (!allowSensitiveRequest(req.headers, loopbackOnly)) {
|
|
3732
|
+
sendJson(res, 403, { error: "Forbidden" });
|
|
3733
|
+
return;
|
|
3734
|
+
}
|
|
3735
|
+
const name = new URL(url, "http://localhost").searchParams.get("name");
|
|
3736
|
+
if (!name) {
|
|
3737
|
+
sendJson(res, 400, { error: "A metric name is required" });
|
|
3738
|
+
return;
|
|
3739
|
+
}
|
|
3740
|
+
sendJson(res, 200, { deletedSeries: devtools.deleteMetric(name) });
|
|
3741
|
+
return;
|
|
3742
|
+
}
|
|
3743
|
+
if (req.method === "GET" && url === "/api/stats") {
|
|
3744
|
+
if (!allowSensitiveRequest(req.headers, loopbackOnly)) {
|
|
3745
|
+
sendJson(res, 403, { error: "Forbidden" });
|
|
3746
|
+
return;
|
|
3747
|
+
}
|
|
3748
|
+
sendJson(res, 200, devtools.getStoreStats());
|
|
3749
|
+
return;
|
|
3750
|
+
}
|
|
3751
|
+
if (req.method === "GET" && url.split("?")[0] === "/api/traces/slowest") {
|
|
3752
|
+
if (!allowSensitiveRequest(req.headers, loopbackOnly)) {
|
|
3753
|
+
sendJson(res, 403, { error: "Forbidden" });
|
|
3754
|
+
return;
|
|
3755
|
+
}
|
|
3756
|
+
const limit = Number(new URL(url, "http://localhost").searchParams.get("limit") ?? 10);
|
|
3757
|
+
sendJson(res, 200, { traces: devtools.findSlowestTraces(limit) });
|
|
3758
|
+
return;
|
|
3759
|
+
}
|
|
3760
|
+
const summaryMatch = req.method === "GET" && url.match(/^\/api\/traces\/([^/?]+)\/summary$/);
|
|
3761
|
+
if (summaryMatch) {
|
|
3762
|
+
if (!allowSensitiveRequest(req.headers, loopbackOnly)) {
|
|
3763
|
+
sendJson(res, 403, { error: "Forbidden" });
|
|
3764
|
+
return;
|
|
3765
|
+
}
|
|
3766
|
+
const summary = devtools.describeTrace(decodeURIComponent(summaryMatch[1]));
|
|
3767
|
+
if (!summary) sendJson(res, 404, { error: "Trace not found" });
|
|
3768
|
+
else sendJson(res, 200, summary);
|
|
3769
|
+
return;
|
|
3770
|
+
}
|
|
3771
|
+
if (req.method === "POST" && url === "/api/query/metrics") {
|
|
3772
|
+
if (!allowSensitiveRequest(req.headers, loopbackOnly)) {
|
|
3773
|
+
sendJson(res, 403, { error: "Forbidden" });
|
|
3774
|
+
return;
|
|
3775
|
+
}
|
|
3776
|
+
try {
|
|
3777
|
+
const body = await readJsonBody(req);
|
|
3778
|
+
if (!body.name) {
|
|
3779
|
+
sendJson(res, 400, { error: "A metric name is required" });
|
|
3780
|
+
return;
|
|
3781
|
+
}
|
|
3782
|
+
sendJson(res, 200, { series: devtools.queryMetricSeries({
|
|
3783
|
+
name: body.name,
|
|
3784
|
+
window: body.window,
|
|
3785
|
+
maxPoints: body.maxPoints
|
|
3786
|
+
}) });
|
|
3787
|
+
} catch (e) {
|
|
3788
|
+
sendJson(res, 400, {
|
|
3789
|
+
error: "Invalid metrics query",
|
|
3790
|
+
message: e instanceof Error ? e.message : String(e)
|
|
3791
|
+
});
|
|
3792
|
+
}
|
|
3793
|
+
return;
|
|
3794
|
+
}
|
|
3795
|
+
sendJson(res, 404, { error: "Not found" });
|
|
3796
|
+
});
|
|
3797
|
+
}
|
|
3798
|
+
function createDevtoolsHttpServer(devtools, _options = {}) {
|
|
3799
|
+
const server = (0, node_http.createServer)();
|
|
3800
|
+
attachDevtoolsRoutes(server, devtools);
|
|
3801
|
+
return server;
|
|
3802
|
+
}
|
|
3803
|
+
|
|
3804
|
+
//#endregion
|
|
3805
|
+
Object.defineProperty(exports, 'DEVTOOLS_IDENTITY', {
|
|
3806
|
+
enumerable: true,
|
|
3807
|
+
get: function () {
|
|
3808
|
+
return DEVTOOLS_IDENTITY;
|
|
3809
|
+
}
|
|
3810
|
+
});
|
|
3811
|
+
Object.defineProperty(exports, 'DevtoolsServer', {
|
|
3812
|
+
enumerable: true,
|
|
3813
|
+
get: function () {
|
|
3814
|
+
return DevtoolsServer;
|
|
3815
|
+
}
|
|
3816
|
+
});
|
|
3817
|
+
Object.defineProperty(exports, 'DevtoolsStore', {
|
|
3818
|
+
enumerable: true,
|
|
3819
|
+
get: function () {
|
|
3820
|
+
return DevtoolsStore;
|
|
3821
|
+
}
|
|
3822
|
+
});
|
|
3823
|
+
Object.defineProperty(exports, 'ErrorAggregator', {
|
|
3824
|
+
enumerable: true,
|
|
3825
|
+
get: function () {
|
|
3826
|
+
return ErrorAggregator;
|
|
3827
|
+
}
|
|
3828
|
+
});
|
|
3829
|
+
Object.defineProperty(exports, 'SPAN_SCHEMA', {
|
|
3830
|
+
enumerable: true,
|
|
3831
|
+
get: function () {
|
|
3832
|
+
return SPAN_SCHEMA;
|
|
3833
|
+
}
|
|
3834
|
+
});
|
|
3835
|
+
Object.defineProperty(exports, 'allowSensitiveRequest', {
|
|
3836
|
+
enumerable: true,
|
|
3837
|
+
get: function () {
|
|
3838
|
+
return allowSensitiveRequest;
|
|
3839
|
+
}
|
|
3840
|
+
});
|
|
3841
|
+
Object.defineProperty(exports, 'appendManyWithLimit', {
|
|
3842
|
+
enumerable: true,
|
|
3843
|
+
get: function () {
|
|
3844
|
+
return appendManyWithLimit;
|
|
3845
|
+
}
|
|
3846
|
+
});
|
|
3847
|
+
Object.defineProperty(exports, 'appendWithLimit', {
|
|
3848
|
+
enumerable: true,
|
|
3849
|
+
get: function () {
|
|
3850
|
+
return appendWithLimit;
|
|
3851
|
+
}
|
|
3852
|
+
});
|
|
3853
|
+
Object.defineProperty(exports, 'applyTelemetryLimits', {
|
|
3854
|
+
enumerable: true,
|
|
3855
|
+
get: function () {
|
|
3856
|
+
return applyTelemetryLimits;
|
|
3857
|
+
}
|
|
3858
|
+
});
|
|
3859
|
+
Object.defineProperty(exports, 'attachDevtoolsRoutes', {
|
|
3860
|
+
enumerable: true,
|
|
3861
|
+
get: function () {
|
|
3862
|
+
return attachDevtoolsRoutes;
|
|
3863
|
+
}
|
|
3864
|
+
});
|
|
3865
|
+
Object.defineProperty(exports, 'createDevtoolsHttpServer', {
|
|
3866
|
+
enumerable: true,
|
|
3867
|
+
get: function () {
|
|
3868
|
+
return createDevtoolsHttpServer;
|
|
3869
|
+
}
|
|
3870
|
+
});
|
|
3871
|
+
Object.defineProperty(exports, 'decodeOtlpLogsRequest', {
|
|
3872
|
+
enumerable: true,
|
|
3873
|
+
get: function () {
|
|
3874
|
+
return decodeOtlpLogsRequest;
|
|
3875
|
+
}
|
|
3876
|
+
});
|
|
3877
|
+
Object.defineProperty(exports, 'decodeOtlpMetricsRequest', {
|
|
3878
|
+
enumerable: true,
|
|
3879
|
+
get: function () {
|
|
3880
|
+
return decodeOtlpMetricsRequest;
|
|
3881
|
+
}
|
|
3882
|
+
});
|
|
3883
|
+
Object.defineProperty(exports, 'decodeOtlpTraceRequest', {
|
|
3884
|
+
enumerable: true,
|
|
3885
|
+
get: function () {
|
|
3886
|
+
return decodeOtlpTraceRequest;
|
|
3887
|
+
}
|
|
3888
|
+
});
|
|
3889
|
+
Object.defineProperty(exports, 'hostHeaderIsLoopback', {
|
|
3890
|
+
enumerable: true,
|
|
3891
|
+
get: function () {
|
|
3892
|
+
return hostHeaderIsLoopback;
|
|
3893
|
+
}
|
|
3894
|
+
});
|
|
3895
|
+
Object.defineProperty(exports, 'isLoopbackHostname', {
|
|
3896
|
+
enumerable: true,
|
|
3897
|
+
get: function () {
|
|
3898
|
+
return isLoopbackHostname;
|
|
3899
|
+
}
|
|
3900
|
+
});
|
|
3901
|
+
Object.defineProperty(exports, 'isProtobufContentType', {
|
|
3902
|
+
enumerable: true,
|
|
3903
|
+
get: function () {
|
|
3904
|
+
return isProtobufContentType;
|
|
3905
|
+
}
|
|
3906
|
+
});
|
|
3907
|
+
Object.defineProperty(exports, 'originIsLoopback', {
|
|
3908
|
+
enumerable: true,
|
|
3909
|
+
get: function () {
|
|
3910
|
+
return originIsLoopback;
|
|
3911
|
+
}
|
|
3912
|
+
});
|
|
3913
|
+
Object.defineProperty(exports, 'parseOtlpLogs', {
|
|
3914
|
+
enumerable: true,
|
|
3915
|
+
get: function () {
|
|
3916
|
+
return parseOtlpLogs;
|
|
3917
|
+
}
|
|
3918
|
+
});
|
|
3919
|
+
Object.defineProperty(exports, 'parseOtlpTraces', {
|
|
3920
|
+
enumerable: true,
|
|
3921
|
+
get: function () {
|
|
3922
|
+
return parseOtlpTraces;
|
|
3923
|
+
}
|
|
3924
|
+
});
|
|
3925
|
+
Object.defineProperty(exports, 'probePortHolder', {
|
|
3926
|
+
enumerable: true,
|
|
3927
|
+
get: function () {
|
|
3928
|
+
return probePortHolder;
|
|
3929
|
+
}
|
|
3930
|
+
});
|
|
3931
|
+
Object.defineProperty(exports, 'resolveSourceRoot', {
|
|
3932
|
+
enumerable: true,
|
|
3933
|
+
get: function () {
|
|
3934
|
+
return resolveSourceRoot;
|
|
3935
|
+
}
|
|
3936
|
+
});
|
|
3937
|
+
Object.defineProperty(exports, 'resolveTelemetryLimits', {
|
|
3938
|
+
enumerable: true,
|
|
3939
|
+
get: function () {
|
|
3940
|
+
return resolveTelemetryLimits;
|
|
3941
|
+
}
|
|
3942
|
+
});
|