@opengeni/observability 0.2.1 → 0.4.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/LICENSE +190 -0
- package/dist/index.d.ts +67 -14
- package/dist/index.js +288 -134
- package/dist/index.js.map +1 -1
- package/package.json +17 -15
- package/src/index.ts +422 -151
package/src/index.ts
CHANGED
|
@@ -1,9 +1,13 @@
|
|
|
1
|
+
import { collectDefaultMetrics, Counter, Gauge, Histogram, Registry } from "prom-client";
|
|
2
|
+
import { SandboxBackend } from "@opengeni/contracts";
|
|
3
|
+
|
|
1
4
|
export type AttributeValue = string | number | boolean | null | undefined;
|
|
2
5
|
export type Attributes = Record<string, AttributeValue>;
|
|
3
6
|
|
|
4
7
|
export type ObservabilitySettings = {
|
|
5
8
|
serviceName: string;
|
|
6
9
|
environment: string;
|
|
10
|
+
deploymentRevision?: string | undefined;
|
|
7
11
|
observabilityStructuredLogs: boolean;
|
|
8
12
|
observabilityMetricsEnabled: boolean;
|
|
9
13
|
observabilityOtlpEndpoint?: string | undefined;
|
|
@@ -22,19 +26,72 @@ export type Span = {
|
|
|
22
26
|
end: (input?: { attributes?: Attributes; error?: unknown }) => void;
|
|
23
27
|
};
|
|
24
28
|
|
|
25
|
-
|
|
29
|
+
export type MetricLabels = Record<string, AttributeValue>;
|
|
30
|
+
|
|
31
|
+
const httpHistogramBuckets = [0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10];
|
|
32
|
+
const durationHistogramBuckets = [
|
|
33
|
+
0.01, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10, 30, 60, 120, 300, 900, 1800, 3600,
|
|
34
|
+
];
|
|
35
|
+
|
|
36
|
+
const SANDBOX_OPERATION_BACKENDS = new Set<string>([...SandboxBackend.options, "unprovisioned"]);
|
|
37
|
+
|
|
38
|
+
const SANDBOX_OPERATION_NAMES = new Set([
|
|
39
|
+
"desktopInput",
|
|
40
|
+
"screenshot",
|
|
41
|
+
"exec",
|
|
42
|
+
"execCommand",
|
|
43
|
+
"writeStdin",
|
|
44
|
+
"cancelExecCommand",
|
|
45
|
+
"readFile",
|
|
46
|
+
"writeFile",
|
|
47
|
+
"listDir",
|
|
48
|
+
"pathExists",
|
|
49
|
+
"viewImage",
|
|
50
|
+
"materializeEntry",
|
|
51
|
+
"editor.createFile",
|
|
52
|
+
"editor.updateFile",
|
|
53
|
+
"editor.deleteFile",
|
|
54
|
+
"resolveExposedPort",
|
|
55
|
+
"serializeSessionState",
|
|
56
|
+
]);
|
|
57
|
+
|
|
58
|
+
export type SandboxOperationMetricObservation = {
|
|
59
|
+
backend: string;
|
|
60
|
+
op: string;
|
|
61
|
+
outcome: "ok" | "failed";
|
|
62
|
+
durationMs: number;
|
|
63
|
+
};
|
|
26
64
|
|
|
27
|
-
export function createObservability(
|
|
65
|
+
export function createObservability(
|
|
66
|
+
settings: ObservabilitySettings,
|
|
67
|
+
options: ObservabilityOptions,
|
|
68
|
+
): Observability {
|
|
28
69
|
return new Observability(settings, options);
|
|
29
70
|
}
|
|
30
71
|
|
|
72
|
+
type MetricRegistration = {
|
|
73
|
+
kind: "counter" | "gauge" | "histogram";
|
|
74
|
+
labelNames: string[];
|
|
75
|
+
};
|
|
76
|
+
|
|
31
77
|
export class Observability {
|
|
32
|
-
private readonly
|
|
78
|
+
private readonly registry = new Registry();
|
|
79
|
+
private readonly counters = new Map<string, Counter<string>>();
|
|
80
|
+
private readonly gauges = new Map<string, Gauge<string>>();
|
|
81
|
+
private readonly histograms = new Map<string, Histogram<string>>();
|
|
82
|
+
private readonly registrations = new Map<string, MetricRegistration>();
|
|
33
83
|
private readonly now: () => number;
|
|
34
|
-
private readonly exporter: (
|
|
84
|
+
private readonly exporter: (
|
|
85
|
+
url: string,
|
|
86
|
+
body: unknown,
|
|
87
|
+
headers: Record<string, string>,
|
|
88
|
+
) => Promise<void>;
|
|
35
89
|
private readonly resourceAttributes: Attributes;
|
|
36
90
|
|
|
37
|
-
constructor(
|
|
91
|
+
constructor(
|
|
92
|
+
private readonly settings: ObservabilitySettings,
|
|
93
|
+
private readonly options: ObservabilityOptions,
|
|
94
|
+
) {
|
|
38
95
|
this.now = options.now ?? Date.now;
|
|
39
96
|
this.exporter = options.exporter ?? defaultExporter;
|
|
40
97
|
this.resourceAttributes = {
|
|
@@ -42,6 +99,27 @@ export class Observability {
|
|
|
42
99
|
"deployment.environment": settings.environment,
|
|
43
100
|
"opengeni.component": options.component,
|
|
44
101
|
};
|
|
102
|
+
this.registry.setDefaultLabels({
|
|
103
|
+
service: settings.serviceName,
|
|
104
|
+
environment: settings.environment,
|
|
105
|
+
component: options.component,
|
|
106
|
+
});
|
|
107
|
+
if (settings.observabilityMetricsEnabled) {
|
|
108
|
+
collectDefaultMetrics({ register: this.registry, prefix: "opengeni_" });
|
|
109
|
+
this.setGauge({
|
|
110
|
+
name: "opengeni_build_info",
|
|
111
|
+
help: "OpenGeni build information.",
|
|
112
|
+
labels: {
|
|
113
|
+
version: buildVersion(),
|
|
114
|
+
revision: settings.deploymentRevision ?? "dev",
|
|
115
|
+
},
|
|
116
|
+
value: 1,
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
debug(message: string, attributes: Attributes = {}): void {
|
|
122
|
+
this.log("debug", message, attributes);
|
|
45
123
|
}
|
|
46
124
|
|
|
47
125
|
info(message: string, attributes: Attributes = {}): void {
|
|
@@ -56,7 +134,11 @@ export class Observability {
|
|
|
56
134
|
this.log("error", message, attributes);
|
|
57
135
|
}
|
|
58
136
|
|
|
59
|
-
log(
|
|
137
|
+
log(
|
|
138
|
+
level: "debug" | "info" | "warn" | "error",
|
|
139
|
+
message: string,
|
|
140
|
+
attributes: Attributes = {},
|
|
141
|
+
): void {
|
|
60
142
|
if (!this.settings.observabilityStructuredLogs) {
|
|
61
143
|
const line = attributes.error ? `${message}: ${String(attributes.error)}` : message;
|
|
62
144
|
if (level === "warn") {
|
|
@@ -100,7 +182,11 @@ export class Observability {
|
|
|
100
182
|
return;
|
|
101
183
|
}
|
|
102
184
|
ended = true;
|
|
103
|
-
const
|
|
185
|
+
const sanitizedError =
|
|
186
|
+
input.error !== undefined && input.error !== null
|
|
187
|
+
? sanitizeSpanError(input.error)
|
|
188
|
+
: undefined;
|
|
189
|
+
const errorAttributes = sanitizedError ? errorToAttributes(sanitizedError) : {};
|
|
104
190
|
this.exportSpan({
|
|
105
191
|
traceId,
|
|
106
192
|
spanId,
|
|
@@ -112,52 +198,195 @@ export class Observability {
|
|
|
112
198
|
...input.attributes,
|
|
113
199
|
...errorAttributes,
|
|
114
200
|
},
|
|
115
|
-
error:
|
|
201
|
+
...(sanitizedError ? { error: sanitizedError } : {}),
|
|
116
202
|
});
|
|
117
203
|
},
|
|
118
204
|
};
|
|
119
205
|
}
|
|
120
206
|
|
|
121
|
-
recordHttpRequest(input: {
|
|
207
|
+
recordHttpRequest(input: {
|
|
208
|
+
method: string;
|
|
209
|
+
route: string;
|
|
210
|
+
status: number;
|
|
211
|
+
durationSeconds: number;
|
|
212
|
+
}): void {
|
|
213
|
+
this.incrementCounter({
|
|
214
|
+
name: "opengeni_http_requests_total",
|
|
215
|
+
help: "Total HTTP requests handled by OpenGeni.",
|
|
216
|
+
labels: {
|
|
217
|
+
method: input.method,
|
|
218
|
+
route: input.route,
|
|
219
|
+
status: String(input.status),
|
|
220
|
+
component: this.options.component,
|
|
221
|
+
},
|
|
222
|
+
});
|
|
223
|
+
this.observeHistogram({
|
|
224
|
+
name: "opengeni_http_request_duration_seconds",
|
|
225
|
+
help: "HTTP request duration in seconds.",
|
|
226
|
+
buckets: httpHistogramBuckets,
|
|
227
|
+
value: input.durationSeconds,
|
|
228
|
+
labels: {
|
|
229
|
+
method: input.method,
|
|
230
|
+
route: input.route,
|
|
231
|
+
component: this.options.component,
|
|
232
|
+
},
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
recordWorkerActivity(input: { activity: string; status: string; durationSeconds: number }): void {
|
|
237
|
+
this.incrementCounter({
|
|
238
|
+
name: "opengeni_worker_activity_runs_total",
|
|
239
|
+
help: "Total worker activity executions.",
|
|
240
|
+
labels: {
|
|
241
|
+
activity: input.activity,
|
|
242
|
+
status: input.status,
|
|
243
|
+
component: this.options.component,
|
|
244
|
+
},
|
|
245
|
+
});
|
|
246
|
+
this.observeHistogram({
|
|
247
|
+
name: "opengeni_worker_activity_duration_seconds",
|
|
248
|
+
help: "Worker activity duration in seconds.",
|
|
249
|
+
buckets: durationHistogramBuckets,
|
|
250
|
+
value: input.durationSeconds,
|
|
251
|
+
labels: {
|
|
252
|
+
activity: input.activity,
|
|
253
|
+
component: this.options.component,
|
|
254
|
+
},
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
incrementCounter(input: {
|
|
259
|
+
name: string;
|
|
260
|
+
help?: string;
|
|
261
|
+
labels?: MetricLabels;
|
|
262
|
+
amount?: number;
|
|
263
|
+
}): void {
|
|
122
264
|
if (!this.settings.observabilityMetricsEnabled) {
|
|
123
265
|
return;
|
|
124
266
|
}
|
|
125
|
-
const labels =
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
this.metrics.observe("opengeni_http_request_duration_seconds", histogramBuckets, input.durationSeconds, {
|
|
133
|
-
method: input.method,
|
|
134
|
-
route: input.route,
|
|
135
|
-
component: this.options.component,
|
|
136
|
-
});
|
|
267
|
+
const labels = normalizeLabels(input.labels);
|
|
268
|
+
const counter = this.counter(
|
|
269
|
+
input.name,
|
|
270
|
+
input.help ?? `${input.name} counter.`,
|
|
271
|
+
Object.keys(labels),
|
|
272
|
+
);
|
|
273
|
+
counter.inc(labels as never, input.amount ?? 1);
|
|
137
274
|
}
|
|
138
275
|
|
|
139
|
-
|
|
276
|
+
setGauge(input: { name: string; help?: string; labels?: MetricLabels; value: number }): void {
|
|
140
277
|
if (!this.settings.observabilityMetricsEnabled) {
|
|
141
278
|
return;
|
|
142
279
|
}
|
|
143
|
-
const labels =
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
component: this.options.component,
|
|
147
|
-
};
|
|
148
|
-
this.metrics.increment("opengeni_worker_activity_runs_total", labels);
|
|
149
|
-
this.metrics.observe("opengeni_worker_activity_duration_seconds", histogramBuckets, input.durationSeconds, {
|
|
150
|
-
activity: input.activity,
|
|
151
|
-
component: this.options.component,
|
|
152
|
-
});
|
|
280
|
+
const labels = normalizeLabels(input.labels);
|
|
281
|
+
const gauge = this.gauge(input.name, input.help ?? `${input.name} gauge.`, Object.keys(labels));
|
|
282
|
+
gauge.set(labels as never, input.value);
|
|
153
283
|
}
|
|
154
284
|
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
285
|
+
incrementGauge(input: {
|
|
286
|
+
name: string;
|
|
287
|
+
help?: string;
|
|
288
|
+
labels?: MetricLabels;
|
|
289
|
+
amount?: number;
|
|
290
|
+
}): void {
|
|
291
|
+
if (!this.settings.observabilityMetricsEnabled) {
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
294
|
+
const labels = normalizeLabels(input.labels);
|
|
295
|
+
const gauge = this.gauge(input.name, input.help ?? `${input.name} gauge.`, Object.keys(labels));
|
|
296
|
+
gauge.inc(labels as never, input.amount ?? 1);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
observeHistogram(input: {
|
|
300
|
+
name: string;
|
|
301
|
+
help?: string;
|
|
302
|
+
labels?: MetricLabels;
|
|
303
|
+
value: number;
|
|
304
|
+
buckets?: number[];
|
|
305
|
+
}): void {
|
|
306
|
+
if (!this.settings.observabilityMetricsEnabled) {
|
|
307
|
+
return;
|
|
308
|
+
}
|
|
309
|
+
const labels = normalizeLabels(input.labels);
|
|
310
|
+
const histogram = this.histogram(
|
|
311
|
+
input.name,
|
|
312
|
+
input.help ?? `${input.name} histogram.`,
|
|
313
|
+
Object.keys(labels),
|
|
314
|
+
input.buckets ?? durationHistogramBuckets,
|
|
315
|
+
);
|
|
316
|
+
histogram.observe(labels as never, input.value);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
async prometheusMetrics(): Promise<string> {
|
|
320
|
+
if (!this.settings.observabilityMetricsEnabled) {
|
|
321
|
+
return "";
|
|
322
|
+
}
|
|
323
|
+
return await this.registry.metrics();
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
private counter(name: string, help: string, labelNames: string[]): Counter<string> {
|
|
327
|
+
const existing = this.counters.get(name);
|
|
328
|
+
if (existing) {
|
|
329
|
+
this.assertRegistration(name, "counter", labelNames);
|
|
330
|
+
return existing;
|
|
331
|
+
}
|
|
332
|
+
this.register(name, "counter", labelNames);
|
|
333
|
+
const metric = new Counter({ name, help, labelNames, registers: [this.registry] });
|
|
334
|
+
this.counters.set(name, metric);
|
|
335
|
+
return metric;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
private gauge(name: string, help: string, labelNames: string[]): Gauge<string> {
|
|
339
|
+
const existing = this.gauges.get(name);
|
|
340
|
+
if (existing) {
|
|
341
|
+
this.assertRegistration(name, "gauge", labelNames);
|
|
342
|
+
return existing;
|
|
343
|
+
}
|
|
344
|
+
this.register(name, "gauge", labelNames);
|
|
345
|
+
const metric = new Gauge({ name, help, labelNames, registers: [this.registry] });
|
|
346
|
+
this.gauges.set(name, metric);
|
|
347
|
+
return metric;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
private histogram(
|
|
351
|
+
name: string,
|
|
352
|
+
help: string,
|
|
353
|
+
labelNames: string[],
|
|
354
|
+
buckets: number[],
|
|
355
|
+
): Histogram<string> {
|
|
356
|
+
const existing = this.histograms.get(name);
|
|
357
|
+
if (existing) {
|
|
358
|
+
this.assertRegistration(name, "histogram", labelNames);
|
|
359
|
+
return existing;
|
|
360
|
+
}
|
|
361
|
+
this.register(name, "histogram", labelNames);
|
|
362
|
+
const metric = new Histogram({ name, help, labelNames, buckets, registers: [this.registry] });
|
|
363
|
+
this.histograms.set(name, metric);
|
|
364
|
+
return metric;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
private register(name: string, kind: MetricRegistration["kind"], labelNames: string[]): void {
|
|
368
|
+
const sorted = [...labelNames].sort();
|
|
369
|
+
this.registrations.set(name, { kind, labelNames: sorted });
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
private assertRegistration(
|
|
373
|
+
name: string,
|
|
374
|
+
kind: MetricRegistration["kind"],
|
|
375
|
+
labelNames: string[],
|
|
376
|
+
): void {
|
|
377
|
+
const registration = this.registrations.get(name);
|
|
378
|
+
const sorted = [...labelNames].sort();
|
|
379
|
+
if (
|
|
380
|
+
!registration ||
|
|
381
|
+
registration.kind !== kind ||
|
|
382
|
+
registration.labelNames.length !== sorted.length ||
|
|
383
|
+
registration.labelNames.some((label, index) => label !== sorted[index])
|
|
384
|
+
) {
|
|
385
|
+
throw new Error(
|
|
386
|
+
`Metric ${name} was already registered as ${registration?.kind ?? "unknown"} ` +
|
|
387
|
+
`with labels [${registration?.labelNames.join(",") ?? ""}], not ${kind} [${sorted.join(",")}]`,
|
|
388
|
+
);
|
|
389
|
+
}
|
|
161
390
|
}
|
|
162
391
|
|
|
163
392
|
private exportSpan(span: {
|
|
@@ -167,41 +396,95 @@ export class Observability {
|
|
|
167
396
|
startMs: number;
|
|
168
397
|
endMs: number;
|
|
169
398
|
attributes: Attributes;
|
|
170
|
-
error?:
|
|
399
|
+
error?: SanitizedSpanError;
|
|
171
400
|
}): void {
|
|
172
401
|
if (!this.settings.observabilityOtlpEndpoint) {
|
|
173
402
|
return;
|
|
174
403
|
}
|
|
175
404
|
const endpoint = `${this.settings.observabilityOtlpEndpoint.replace(/\/$/, "")}/v1/traces`;
|
|
176
405
|
const body = {
|
|
177
|
-
resourceSpans: [
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
scopeSpans: [{
|
|
182
|
-
scope: {
|
|
183
|
-
name: "@opengeni/observability",
|
|
184
|
-
version: "0.1.0",
|
|
406
|
+
resourceSpans: [
|
|
407
|
+
{
|
|
408
|
+
resource: {
|
|
409
|
+
attributes: otlpAttributes(this.resourceAttributes),
|
|
185
410
|
},
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
411
|
+
scopeSpans: [
|
|
412
|
+
{
|
|
413
|
+
scope: {
|
|
414
|
+
name: "@opengeni/observability",
|
|
415
|
+
version: "0.1.0",
|
|
416
|
+
},
|
|
417
|
+
spans: [
|
|
418
|
+
{
|
|
419
|
+
traceId: span.traceId,
|
|
420
|
+
spanId: span.spanId,
|
|
421
|
+
name: span.name,
|
|
422
|
+
kind: 1,
|
|
423
|
+
startTimeUnixNano: millisToNanos(span.startMs),
|
|
424
|
+
endTimeUnixNano: millisToNanos(span.endMs),
|
|
425
|
+
attributes: otlpAttributes(span.attributes),
|
|
426
|
+
status: span.error ? { code: 2, message: span.error.statusMessage } : { code: 1 },
|
|
427
|
+
},
|
|
428
|
+
],
|
|
429
|
+
},
|
|
430
|
+
],
|
|
431
|
+
},
|
|
432
|
+
],
|
|
198
433
|
};
|
|
199
|
-
void this.exporter(endpoint, body, parseHeaders(this.settings.observabilityOtlpHeaders)).catch(
|
|
200
|
-
|
|
201
|
-
|
|
434
|
+
void this.exporter(endpoint, body, parseHeaders(this.settings.observabilityOtlpHeaders)).catch(
|
|
435
|
+
(error) => {
|
|
436
|
+
this.warn("OTLP span export failed", { error: errorMessage(error), endpoint });
|
|
437
|
+
},
|
|
438
|
+
);
|
|
202
439
|
}
|
|
203
440
|
}
|
|
204
441
|
|
|
442
|
+
/**
|
|
443
|
+
* Convert routed sandbox-provider observations into one bounded Prometheus
|
|
444
|
+
* contract shared by API-direct and worker-turn execution. Provider/session
|
|
445
|
+
* identifiers, commands, paths, and other request data can never become labels:
|
|
446
|
+
* a future backend or operation is deliberately collapsed to `unknown` until
|
|
447
|
+
* this allowlist is reviewed.
|
|
448
|
+
*
|
|
449
|
+
* The returned callback is fail-safe. Metrics must never change the provider
|
|
450
|
+
* operation's result; a registration/exporter error is counted best-effort and
|
|
451
|
+
* then swallowed at this telemetry boundary.
|
|
452
|
+
*/
|
|
453
|
+
export function sandboxOperationMetricObserver(
|
|
454
|
+
observability: Observability,
|
|
455
|
+
): (observation: SandboxOperationMetricObservation) => void {
|
|
456
|
+
return (observation) => {
|
|
457
|
+
const backend = SANDBOX_OPERATION_BACKENDS.has(observation.backend)
|
|
458
|
+
? observation.backend
|
|
459
|
+
: "unknown";
|
|
460
|
+
const op = SANDBOX_OPERATION_NAMES.has(observation.op) ? observation.op : "unknown";
|
|
461
|
+
try {
|
|
462
|
+
observability.incrementCounter({
|
|
463
|
+
name: "opengeni_sandbox_operations_total",
|
|
464
|
+
help: "Physical routed sandbox provider operations by backend, operation, and outcome.",
|
|
465
|
+
labels: { backend, op, outcome: observation.outcome },
|
|
466
|
+
});
|
|
467
|
+
observability.observeHistogram({
|
|
468
|
+
name: "opengeni_sandbox_operation_duration_seconds",
|
|
469
|
+
help: "Physical routed sandbox provider-operation duration in seconds.",
|
|
470
|
+
labels: { backend, op },
|
|
471
|
+
value: Math.max(0, observation.durationMs) / 1_000,
|
|
472
|
+
});
|
|
473
|
+
} catch {
|
|
474
|
+
try {
|
|
475
|
+
observability.incrementCounter({
|
|
476
|
+
name: "opengeni_observability_observer_errors_total",
|
|
477
|
+
help: "Observability observer failures isolated from product execution.",
|
|
478
|
+
labels: { observer: "sandbox_operation" },
|
|
479
|
+
});
|
|
480
|
+
} catch {
|
|
481
|
+
// The metrics registry itself is unhealthy. Product execution remains
|
|
482
|
+
// authoritative and must not inherit an observability failure.
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
};
|
|
486
|
+
}
|
|
487
|
+
|
|
205
488
|
export type StartupDependencyRetryEvent = {
|
|
206
489
|
label: string;
|
|
207
490
|
attempt: number;
|
|
@@ -210,7 +493,10 @@ export type StartupDependencyRetryEvent = {
|
|
|
210
493
|
error: unknown;
|
|
211
494
|
};
|
|
212
495
|
|
|
213
|
-
export function logStartupDependencyRetry(
|
|
496
|
+
export function logStartupDependencyRetry(
|
|
497
|
+
observability: Observability,
|
|
498
|
+
event: StartupDependencyRetryEvent,
|
|
499
|
+
): void {
|
|
214
500
|
const message = event.error instanceof Error ? event.error.message : String(event.error);
|
|
215
501
|
observability.warn("Startup dependency connection failed; retrying", {
|
|
216
502
|
dependency: event.label,
|
|
@@ -221,95 +507,63 @@ export function logStartupDependencyRetry(observability: Observability, event: S
|
|
|
221
507
|
});
|
|
222
508
|
}
|
|
223
509
|
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
}
|
|
232
|
-
|
|
233
|
-
observe(name: string, buckets: number[], value: number, labels: Record<string, string>): void {
|
|
234
|
-
const key = metricKey(name, labels);
|
|
235
|
-
const histogram = this.histograms.get(key) ?? {
|
|
236
|
-
buckets,
|
|
237
|
-
counts: buckets.map(() => 0),
|
|
238
|
-
sum: 0,
|
|
239
|
-
count: 0,
|
|
240
|
-
labels,
|
|
241
|
-
};
|
|
242
|
-
histogram.sum += value;
|
|
243
|
-
histogram.count += 1;
|
|
244
|
-
for (let index = 0; index < buckets.length; index += 1) {
|
|
245
|
-
if (value <= buckets[index]!) {
|
|
246
|
-
histogram.counts[index] = (histogram.counts[index] ?? 0) + 1;
|
|
247
|
-
}
|
|
248
|
-
}
|
|
249
|
-
this.histograms.set(key, histogram);
|
|
250
|
-
}
|
|
251
|
-
|
|
252
|
-
toPrometheus(resourceLabels: Record<string, string>): string {
|
|
253
|
-
const lines = [
|
|
254
|
-
"# HELP opengeni_http_requests_total Total HTTP requests handled by the OpenGeni API.",
|
|
255
|
-
"# TYPE opengeni_http_requests_total counter",
|
|
256
|
-
];
|
|
257
|
-
for (const [key, value] of this.counters) {
|
|
258
|
-
const { name, labels } = parseMetricKey(key);
|
|
259
|
-
if (name.endsWith("_total")) {
|
|
260
|
-
lines.push(`${name}${formatLabels({ ...resourceLabels, ...labels })} ${value}`);
|
|
261
|
-
}
|
|
262
|
-
}
|
|
263
|
-
lines.push(
|
|
264
|
-
"# HELP opengeni_http_request_duration_seconds HTTP request duration in seconds.",
|
|
265
|
-
"# TYPE opengeni_http_request_duration_seconds histogram",
|
|
266
|
-
"# HELP opengeni_worker_activity_runs_total Total worker activity executions.",
|
|
267
|
-
"# TYPE opengeni_worker_activity_runs_total counter",
|
|
268
|
-
"# HELP opengeni_worker_activity_duration_seconds Worker activity duration in seconds.",
|
|
269
|
-
"# TYPE opengeni_worker_activity_duration_seconds histogram",
|
|
270
|
-
);
|
|
271
|
-
for (const [key, histogram] of this.histograms) {
|
|
272
|
-
const { name, labels } = parseMetricKey(key);
|
|
273
|
-
const baseLabels = { ...resourceLabels, ...labels };
|
|
274
|
-
for (let index = 0; index < histogram.buckets.length; index += 1) {
|
|
275
|
-
lines.push(`${name}_bucket${formatLabels({ ...baseLabels, le: String(histogram.buckets[index]) })} ${histogram.counts[index]}`);
|
|
276
|
-
}
|
|
277
|
-
lines.push(`${name}_bucket${formatLabels({ ...baseLabels, le: "+Inf" })} ${histogram.count}`);
|
|
278
|
-
lines.push(`${name}_sum${formatLabels(baseLabels)} ${histogram.sum}`);
|
|
279
|
-
lines.push(`${name}_count${formatLabels(baseLabels)} ${histogram.count}`);
|
|
280
|
-
}
|
|
281
|
-
return `${lines.join("\n")}\n`;
|
|
282
|
-
}
|
|
510
|
+
function normalizeLabels(labels: MetricLabels = {}): Record<string, string> {
|
|
511
|
+
return Object.fromEntries(
|
|
512
|
+
Object.entries(labels)
|
|
513
|
+
.filter(([, value]) => value !== undefined && value !== null)
|
|
514
|
+
.map(([key, value]): [string, string] => [key, String(value)])
|
|
515
|
+
.sort((left, right) => left[0].localeCompare(right[0])),
|
|
516
|
+
);
|
|
283
517
|
}
|
|
284
518
|
|
|
285
|
-
function
|
|
286
|
-
return
|
|
519
|
+
function buildVersion(): string {
|
|
520
|
+
return process.env.OPENGENI_VERSION ?? process.env.npm_package_version ?? "dev";
|
|
287
521
|
}
|
|
288
522
|
|
|
289
|
-
function
|
|
290
|
-
return
|
|
523
|
+
function cleanAttributes(attributes: Attributes): Record<string, string | number | boolean | null> {
|
|
524
|
+
return Object.fromEntries(
|
|
525
|
+
Object.entries(attributes).filter(([, value]) => value !== undefined),
|
|
526
|
+
) as Record<string, string | number | boolean | null>;
|
|
291
527
|
}
|
|
292
528
|
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
return `{${entries.map(([key, value]) => `${key}="${escapeMetricLabel(value)}"`).join(",")}}`;
|
|
299
|
-
}
|
|
529
|
+
type SanitizedSpanError = {
|
|
530
|
+
type: string;
|
|
531
|
+
statusCode?: number;
|
|
532
|
+
statusMessage: string;
|
|
533
|
+
};
|
|
300
534
|
|
|
301
|
-
function
|
|
302
|
-
|
|
535
|
+
function sanitizeSpanError(error: unknown): SanitizedSpanError {
|
|
536
|
+
const type =
|
|
537
|
+
error instanceof Error && /^[A-Za-z][A-Za-z0-9_.-]{0,63}$/.test(error.name)
|
|
538
|
+
? error.name
|
|
539
|
+
: "Error";
|
|
540
|
+
const statusCode = errorStatusCode(error);
|
|
541
|
+
return {
|
|
542
|
+
type,
|
|
543
|
+
...(statusCode === undefined ? {} : { statusCode }),
|
|
544
|
+
statusMessage: statusCode === undefined ? "operation failed" : `HTTP ${statusCode}`,
|
|
545
|
+
};
|
|
303
546
|
}
|
|
304
547
|
|
|
305
|
-
function
|
|
306
|
-
|
|
548
|
+
function errorStatusCode(error: unknown): number | undefined {
|
|
549
|
+
if (typeof error !== "object" || error === null) return undefined;
|
|
550
|
+
const value = (error as { status?: unknown; statusCode?: unknown }).status;
|
|
551
|
+
const statusCode =
|
|
552
|
+
Number.isInteger(value) && typeof value === "number"
|
|
553
|
+
? value
|
|
554
|
+
: (error as { statusCode?: unknown }).statusCode;
|
|
555
|
+
return Number.isInteger(statusCode) &&
|
|
556
|
+
typeof statusCode === "number" &&
|
|
557
|
+
statusCode >= 100 &&
|
|
558
|
+
statusCode <= 599
|
|
559
|
+
? statusCode
|
|
560
|
+
: undefined;
|
|
307
561
|
}
|
|
308
562
|
|
|
309
|
-
function errorToAttributes(error:
|
|
563
|
+
function errorToAttributes(error: SanitizedSpanError): Attributes {
|
|
310
564
|
return {
|
|
311
|
-
"error.type": error
|
|
312
|
-
"error.
|
|
565
|
+
"error.type": error.type,
|
|
566
|
+
...(error.statusCode === undefined ? {} : { "error.status_code": error.statusCode }),
|
|
313
567
|
};
|
|
314
568
|
}
|
|
315
569
|
|
|
@@ -317,21 +571,31 @@ function errorMessage(error: unknown): string {
|
|
|
317
571
|
return error instanceof Error ? error.message : String(error);
|
|
318
572
|
}
|
|
319
573
|
|
|
320
|
-
function otlpAttributes(
|
|
574
|
+
function otlpAttributes(
|
|
575
|
+
attributes: Attributes,
|
|
576
|
+
): Array<{ key: string; value: Record<string, string | number | boolean> }> {
|
|
321
577
|
return Object.entries(cleanAttributes(attributes)).map(([key, value]) => ({
|
|
322
578
|
key,
|
|
323
579
|
value: otlpValue(value),
|
|
324
580
|
}));
|
|
325
581
|
}
|
|
326
582
|
|
|
327
|
-
function otlpValue(
|
|
583
|
+
function otlpValue(
|
|
584
|
+
value: string | number | boolean | null,
|
|
585
|
+
): Record<string, string | number | boolean> {
|
|
328
586
|
if (typeof value === "number") {
|
|
329
587
|
return Number.isInteger(value) ? { intValue: value } : { doubleValue: value };
|
|
330
588
|
}
|
|
331
589
|
if (typeof value === "boolean") {
|
|
332
590
|
return { boolValue: value };
|
|
333
591
|
}
|
|
334
|
-
return { stringValue: value === null ? "" : value };
|
|
592
|
+
return { stringValue: value === null ? "" : boundedOtlpString(value) };
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
function boundedOtlpString(value: string): string {
|
|
596
|
+
const bytes = new TextEncoder().encode(value);
|
|
597
|
+
if (bytes.byteLength <= 512) return value;
|
|
598
|
+
return `${new TextDecoder().decode(bytes.slice(0, 509))}…`;
|
|
335
599
|
}
|
|
336
600
|
|
|
337
601
|
function millisToNanos(ms: number): string {
|
|
@@ -347,17 +611,24 @@ export function parseHeaders(value: string): Record<string, string> {
|
|
|
347
611
|
if (!value.trim()) {
|
|
348
612
|
return {};
|
|
349
613
|
}
|
|
350
|
-
const entries: Array<[string, string]> = value
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
614
|
+
const entries: Array<[string, string]> = value
|
|
615
|
+
.split(",")
|
|
616
|
+
.map((pair): [string, string] => {
|
|
617
|
+
const separator = pair.indexOf("=");
|
|
618
|
+
if (separator === -1) {
|
|
619
|
+
return [pair.trim(), ""];
|
|
620
|
+
}
|
|
621
|
+
return [pair.slice(0, separator).trim(), pair.slice(separator + 1).trim()];
|
|
622
|
+
})
|
|
623
|
+
.filter(([key]) => key.length > 0);
|
|
357
624
|
return Object.fromEntries(entries);
|
|
358
625
|
}
|
|
359
626
|
|
|
360
|
-
async function defaultExporter(
|
|
627
|
+
async function defaultExporter(
|
|
628
|
+
url: string,
|
|
629
|
+
body: unknown,
|
|
630
|
+
headers: Record<string, string>,
|
|
631
|
+
): Promise<void> {
|
|
361
632
|
const response = await fetch(url, {
|
|
362
633
|
method: "POST",
|
|
363
634
|
headers: {
|