@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/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
- const histogramBuckets = [0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10];
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(settings: ObservabilitySettings, options: ObservabilityOptions): Observability {
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 metrics = new MetricsRegistry();
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: (url: string, body: unknown, headers: Record<string, string>) => Promise<void>;
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(private readonly settings: ObservabilitySettings, private readonly options: ObservabilityOptions) {
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(level: "debug" | "info" | "warn" | "error", message: string, attributes: Attributes = {}): void {
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 errorAttributes = input.error ? errorToAttributes(input.error) : {};
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: input.error,
201
+ ...(sanitizedError ? { error: sanitizedError } : {}),
116
202
  });
117
203
  },
118
204
  };
119
205
  }
120
206
 
121
- recordHttpRequest(input: { method: string; route: string; status: number; durationSeconds: number }): void {
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
- method: input.method,
127
- route: input.route,
128
- status: String(input.status),
129
- component: this.options.component,
130
- };
131
- this.metrics.increment("opengeni_http_requests_total", labels);
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
- recordWorkerActivity(input: { activity: string; status: string; durationSeconds: number }): void {
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
- activity: input.activity,
145
- status: input.status,
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
- prometheusMetrics(): string {
156
- return this.metrics.toPrometheus({
157
- service: this.settings.serviceName,
158
- environment: this.settings.environment,
159
- component: this.options.component,
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?: unknown;
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
- resource: {
179
- attributes: otlpAttributes(this.resourceAttributes),
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
- spans: [{
187
- traceId: span.traceId,
188
- spanId: span.spanId,
189
- name: span.name,
190
- kind: 1,
191
- startTimeUnixNano: millisToNanos(span.startMs),
192
- endTimeUnixNano: millisToNanos(span.endMs),
193
- attributes: otlpAttributes(span.attributes),
194
- status: span.error ? { code: 2, message: errorMessage(span.error) } : { code: 1 },
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((error) => {
200
- this.warn("OTLP span export failed", { error: errorMessage(error), endpoint });
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(observability: Observability, event: StartupDependencyRetryEvent): void {
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
- class MetricsRegistry {
225
- private readonly counters = new Map<string, number>();
226
- private readonly histograms = new Map<string, { buckets: number[]; counts: number[]; sum: number; count: number; labels: Record<string, string> }>();
227
-
228
- increment(name: string, labels: Record<string, string>, amount = 1): void {
229
- const key = metricKey(name, labels);
230
- this.counters.set(key, (this.counters.get(key) ?? 0) + amount);
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 metricKey(name: string, labels: Record<string, string>): string {
286
- return JSON.stringify({ name, labels: Object.fromEntries(Object.entries(labels).sort(([a], [b]) => a.localeCompare(b))) });
519
+ function buildVersion(): string {
520
+ return process.env.OPENGENI_VERSION ?? process.env.npm_package_version ?? "dev";
287
521
  }
288
522
 
289
- function parseMetricKey(key: string): { name: string; labels: Record<string, string> } {
290
- return JSON.parse(key) as { name: string; labels: Record<string, string> };
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
- function formatLabels(labels: Record<string, string>): string {
294
- const entries = Object.entries(labels).filter(([, value]) => value.length > 0);
295
- if (entries.length === 0) {
296
- return "";
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 escapeMetricLabel(value: string): string {
302
- return value.replace(/\\/g, "\\\\").replace(/"/g, "\\\"").replace(/\n/g, "\\n");
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 cleanAttributes(attributes: Attributes): Record<string, string | number | boolean | null> {
306
- return Object.fromEntries(Object.entries(attributes).filter(([, value]) => value !== undefined)) as Record<string, string | number | boolean | null>;
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: unknown): Attributes {
563
+ function errorToAttributes(error: SanitizedSpanError): Attributes {
310
564
  return {
311
- "error.type": error instanceof Error ? error.name : "Error",
312
- "error.message": errorMessage(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(attributes: Attributes): Array<{ key: string; value: Record<string, string | number | boolean> }> {
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(value: string | number | boolean | null): Record<string, string | number | boolean> {
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.split(",").map((pair): [string, string] => {
351
- const separator = pair.indexOf("=");
352
- if (separator === -1) {
353
- return [pair.trim(), ""];
354
- }
355
- return [pair.slice(0, separator).trim(), pair.slice(separator + 1).trim()];
356
- }).filter(([key]) => key.length > 0);
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(url: string, body: unknown, headers: Record<string, string>): Promise<void> {
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: {