@hue-run/sdk 0.1.4 → 0.1.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/transport.js CHANGED
@@ -5,7 +5,32 @@ import { LogsExporterMetricsHelper, ProtobufLogsSerializer, ProtobufTraceSeriali
5
5
  import { BatchSpanProcessor, } from "@opentelemetry/sdk-trace";
6
6
  import { BatchLogRecordProcessor, } from "@opentelemetry/sdk-logs";
7
7
  import { MAX_BODY_BYTES, validateOptions } from "./config.js";
8
+ import { estimateRecordBytes } from "./safety.js";
9
+ import { snapshotLog, snapshotSpan } from "./snapshot.js";
8
10
  import { redactLog, redactSpan } from "./privacy.js";
11
+ function recordData(record, signal) {
12
+ if (signal === "traces") {
13
+ const span = record;
14
+ return {
15
+ name: span.name,
16
+ attributes: span.attributes,
17
+ events: span.events,
18
+ links: span.links,
19
+ status: span.status,
20
+ resource: span.resource.attributes,
21
+ scope: span.instrumentationScope,
22
+ };
23
+ }
24
+ const log = record;
25
+ return {
26
+ body: log.body,
27
+ attributes: log.attributes,
28
+ eventName: log.eventName,
29
+ severityText: log.severityText,
30
+ resource: log.resource.attributes,
31
+ scope: log.instrumentationScope,
32
+ };
33
+ }
9
34
  export class HueExportError extends Error {
10
35
  issues;
11
36
  report;
@@ -28,8 +53,13 @@ export class HueTransport {
28
53
  accepted = { traces: 0, logs: 0 };
29
54
  rejected = { traces: 0, logs: 0 };
30
55
  failed = { traces: 0, logs: 0 };
31
- spans = new Set();
32
- logs = new Set();
56
+ spans = new Map();
57
+ logs = new Map();
58
+ pendingBytes = 0;
59
+ dropped = { traces: 0, logs: 0 };
60
+ instrumentationFailures = 0;
61
+ diagnosticPending = false;
62
+ lastDiagnosticAt = -Infinity;
33
63
  traceExporter;
34
64
  logExporter;
35
65
  closed = false;
@@ -40,6 +70,11 @@ export class HueTransport {
40
70
  Object.defineProperty(this, "options", { enumerable: false });
41
71
  this.traceExporter = new ReportingExporter(this, "traces", ProtobufTraceSerializer, TraceExporterMetricsHelper, (span, cache) => redactSpan(span, this.options, cache));
42
72
  this.logExporter = new ReportingExporter(this, "logs", ProtobufLogsSerializer, LogsExporterMetricsHelper, (log, cache) => redactLog(log, this.options, cache));
73
+ if (this.options.enabled === false) {
74
+ this.spanProcessor = { onStart() { }, onEnd() { }, async forceFlush() { }, async shutdown() { } };
75
+ this.logRecordProcessor = { onEmit() { }, async forceFlush() { }, async shutdown() { } };
76
+ return;
77
+ }
43
78
  const batching = {
44
79
  maxQueueSize: 2048,
45
80
  maxExportBatchSize: 128,
@@ -49,22 +84,49 @@ export class HueTransport {
49
84
  const spans = new BatchSpanProcessor({ exporter: this.traceExporter, ...batching });
50
85
  const logs = new BatchLogRecordProcessor({ exporter: this.logExporter, ...batching });
51
86
  this.spanProcessor = {
52
- onStart: (span, parent) => spans.onStart(span, parent),
87
+ onStart: (span, parent) => {
88
+ try {
89
+ spans.onStart(span, parent);
90
+ }
91
+ catch {
92
+ this.instrumentationFailure();
93
+ }
94
+ },
53
95
  onEnd: (span) => {
54
- if (!(span.spanContext().traceFlags & 1))
55
- return;
56
- if (!this.enqueue("traces", span))
57
- return;
58
- spans.onEnd(span);
96
+ let admitted;
97
+ try {
98
+ if (!(span.spanContext().traceFlags & 1))
99
+ return;
100
+ const queued = this.enqueue("traces", span);
101
+ if (!queued)
102
+ return;
103
+ admitted = queued;
104
+ spans.onEnd(admitted);
105
+ }
106
+ catch {
107
+ if (admitted)
108
+ this.finish("traces", [admitted]);
109
+ this.issue("traces", "invalid", 1, "Telemetry processor could not accept a record");
110
+ }
59
111
  },
60
112
  forceFlush: () => spans.forceFlush(),
61
113
  shutdown: () => spans.shutdown(),
62
114
  };
63
115
  this.logRecordProcessor = {
64
116
  onEmit: (log) => {
65
- if (!this.enqueue("logs", log))
66
- return;
67
- logs.onEmit(log);
117
+ let admitted;
118
+ try {
119
+ const queued = this.enqueue("logs", log);
120
+ if (!queued)
121
+ return;
122
+ admitted = queued;
123
+ logs.onEmit(queued);
124
+ }
125
+ catch {
126
+ if (admitted)
127
+ this.finish("logs", [admitted]);
128
+ this.issue("logs", "invalid", 1, "Telemetry processor could not accept a record");
129
+ }
68
130
  },
69
131
  forceFlush: () => logs.forceFlush(),
70
132
  shutdown: () => logs.shutdown(),
@@ -76,26 +138,40 @@ export class HueTransport {
76
138
  this.issue(signal, "dropped", 1, this.closed
77
139
  ? "Telemetry emitted after transport shutdown"
78
140
  : "Telemetry queue reached 2048 records");
79
- return false;
141
+ return undefined;
142
+ }
143
+ try {
144
+ const remaining = this.options.maxQueueBytes - this.pendingBytes;
145
+ const snapshot = signal === "traces"
146
+ ? snapshotSpan(record, remaining)
147
+ : snapshotLog(record, remaining);
148
+ this.pendingBytes += snapshot.bytes;
149
+ if (signal === "traces")
150
+ this.spans.set(snapshot.record, snapshot.bytes);
151
+ else
152
+ this.logs.set(snapshot.record, snapshot.bytes);
153
+ if (snapshot.unresolvedResource)
154
+ this.issue(signal, "warning", 0, "Unresolved resource attributes omitted from the telemetry snapshot");
155
+ return snapshot.record;
156
+ }
157
+ catch {
158
+ this.issue(signal, "dropped", 1, "Telemetry snapshot exceeded its byte or complexity budget or contained unsupported data");
159
+ return undefined;
80
160
  }
81
- if (signal === "traces")
82
- this.spans.add(record);
83
- else
84
- this.logs.add(record);
85
- return true;
86
161
  }
87
162
  finish(signal, records) {
88
163
  for (const record of records) {
89
- if (signal === "traces")
90
- this.spans.delete(record);
91
- else
92
- this.logs.delete(record);
164
+ const pending = signal === "traces" ? this.spans : this.logs;
165
+ this.pendingBytes -= pending.get(record) ?? 0;
166
+ pending.delete(record);
93
167
  }
94
168
  }
95
169
  acceptedRecords(signal, count) {
96
170
  this.accepted[signal] += count;
97
171
  }
98
172
  issue(signal, kind, count, message, status) {
173
+ if (kind === "dropped")
174
+ this.dropped[signal] += count;
99
175
  if (kind === "rejected")
100
176
  this.rejected[signal] += count;
101
177
  else if (kind !== "warning")
@@ -113,13 +189,26 @@ export class HueTransport {
113
189
  this.issues.push(issue);
114
190
  if (this.issues.length > 128)
115
191
  this.issues.shift();
116
- try {
117
- this.options.onExportIssue?.({ ...issue });
118
- }
119
- catch {
120
- /* Diagnostics callbacks cannot interrupt the customer's application. */
192
+ // One diagnostic task at a time, at most once a second. No unbounded promise
193
+ // queue if a user callback never settles; all issues remain in counts/history.
194
+ if (this.options.onExportIssue &&
195
+ !this.diagnosticPending &&
196
+ Date.now() - this.lastDiagnosticAt >= 1000) {
197
+ this.diagnosticPending = true;
198
+ this.lastDiagnosticAt = Date.now();
199
+ void Promise.resolve()
200
+ .then(() => this.options.onExportIssue?.({ ...issue }))
201
+ .then(() => {
202
+ this.diagnosticPending = false;
203
+ }, () => {
204
+ this.diagnosticPending = false;
205
+ });
121
206
  }
122
207
  }
208
+ instrumentationFailure(signal = "traces") {
209
+ this.instrumentationFailures++;
210
+ this.issue(signal, "invalid", 0, "Telemetry capture or instrumentation failed; application execution was preserved");
211
+ }
123
212
  getReport() {
124
213
  return {
125
214
  acceptedSpans: this.accepted.traces,
@@ -130,6 +219,10 @@ export class HueTransport {
130
219
  failedLogs: this.failed.logs,
131
220
  pendingSpans: this.spans.size,
132
221
  pendingLogs: this.logs.size,
222
+ droppedSpans: this.dropped.traces,
223
+ droppedLogs: this.dropped.logs,
224
+ pendingBytes: this.pendingBytes,
225
+ instrumentationFailures: this.instrumentationFailures,
133
226
  };
134
227
  }
135
228
  getIssues() {
@@ -208,17 +301,40 @@ class ReportingExporter {
208
301
  .finally(() => {
209
302
  this.transport.finish(this.signal, records);
210
303
  this.pending.delete(work);
211
- });
304
+ })
305
+ .catch(() => { });
212
306
  this.pending.add(work);
213
307
  }
214
308
  async exportRecords(records) {
215
309
  const accepted = [];
216
310
  const cache = new WeakMap();
217
311
  let failed = false;
312
+ let redactedBytes = 0;
313
+ const resourceDeadline = Date.now() + this.transport.options.timeoutMillis;
218
314
  for (const record of records) {
219
315
  try {
220
- await record.resource.waitForAsyncAttributes?.();
221
- accepted.push(this.redact(record, cache));
316
+ const ready = record.resource.waitForAsyncAttributes?.();
317
+ if (ready) {
318
+ let timer;
319
+ try {
320
+ await Promise.race([
321
+ ready,
322
+ new Promise((_resolve, reject) => {
323
+ timer = setTimeout(() => reject(new Error("Resource deadline exceeded")), Math.max(1, resourceDeadline - Date.now()));
324
+ }),
325
+ ]);
326
+ }
327
+ finally {
328
+ clearTimeout(timer);
329
+ }
330
+ }
331
+ const redacted = this.redact(record, cache);
332
+ const bytes = 512 +
333
+ estimateRecordBytes(recordData(redacted, this.signal), this.transport.options.maxQueueBytes - redactedBytes);
334
+ if (redactedBytes + bytes > this.transport.options.maxQueueBytes)
335
+ throw new RangeError("Redacted batch exceeds byte budget");
336
+ redactedBytes += bytes;
337
+ accepted.push(redacted);
222
338
  }
223
339
  catch {
224
340
  failed = true;
@@ -227,6 +343,15 @@ class ReportingExporter {
227
343
  }
228
344
  let batch = [];
229
345
  for (const record of accepted) {
346
+ let recordBytes;
347
+ try {
348
+ recordBytes = this.serializer.serializeRequest([record])?.byteLength ?? Infinity;
349
+ }
350
+ catch {
351
+ failed = true;
352
+ this.transport.issue(this.signal, "invalid", 1, "Telemetry record could not be serialized");
353
+ continue;
354
+ }
230
355
  const candidate = [...batch, record];
231
356
  // Leave room for gzip headers/blocks when otherwise incompressible data is near the wire cap.
232
357
  if ((this.serializer.serializeRequest(candidate)?.byteLength ?? 0) <= MAX_BODY_BYTES - 1024) {
@@ -236,7 +361,7 @@ class ReportingExporter {
236
361
  if (batch.length && !(await this.send(batch)))
237
362
  failed = true;
238
363
  batch = [];
239
- if ((this.serializer.serializeRequest([record])?.byteLength ?? 0) > MAX_BODY_BYTES - 1024) {
364
+ if (recordBytes > MAX_BODY_BYTES - 1024) {
240
365
  failed = true;
241
366
  this.transport.issue(this.signal, "invalid", 1, "Telemetry record exceeds the 1 MiB request limit");
242
367
  }
@@ -252,9 +377,17 @@ class ReportingExporter {
252
377
  const options = this.transport.options;
253
378
  let rejected = 0;
254
379
  let validResponse = true;
380
+ let receivedResponse = false;
381
+ let expired = false;
382
+ const agents = new Set();
383
+ const deadline = Date.now() + options.timeoutMillis;
384
+ let timer;
255
385
  const serializer = {
256
386
  serializeRequest: (data) => this.serializer.serializeRequest(data),
257
387
  deserializeResponse: (bytes) => {
388
+ if (expired)
389
+ return {};
390
+ receivedResponse = true;
258
391
  try {
259
392
  const response = this.serializer.deserializeResponse(bytes);
260
393
  const partial = response.partialSuccess;
@@ -283,11 +416,33 @@ class ReportingExporter {
283
416
  timeoutMillis: options.timeoutMillis,
284
417
  concurrencyLimit: 1,
285
418
  compression: CompressionAlgorithm.GZIP,
419
+ httpAgentOptions: async (protocol) => {
420
+ if (expired || Date.now() >= deadline)
421
+ throw new Error("Hue export deadline exceeded");
422
+ const { Agent } = await import(protocol === "https:" ? "node:https" : "node:http");
423
+ const agent = new Agent({ keepAlive: false });
424
+ agents.add(agent);
425
+ if (expired)
426
+ agent.destroy();
427
+ return agent;
428
+ },
286
429
  }, this.signal === "traces" ? "TRACES" : "LOGS", `v1/${this.signal}`, { "Content-Type": "application/x-protobuf" }), serializer, this.signal === "traces" ? "otlp_http_span_exporter" : "otlp_http_log_exporter", this.metrics, undefined);
287
430
  const exporter = new OTLPExporterBase(delegate);
288
431
  try {
289
- const result = await new Promise((resolve) => exporter.export(records, resolve));
432
+ const result = await new Promise((resolve) => {
433
+ timer = setTimeout(() => {
434
+ expired = true;
435
+ for (const agent of agents)
436
+ agent.destroy();
437
+ resolve({ code: ExportResultCode.FAILED });
438
+ }, Math.max(1, deadline - Date.now()));
439
+ exporter.export(records, resolve);
440
+ });
290
441
  if (result.code === ExportResultCode.SUCCESS) {
442
+ if (!receivedResponse) {
443
+ this.transport.issue(this.signal, "failed", records.length, "Hue response ended without a complete OTLP acknowledgement; acceptance is uncertain");
444
+ return false;
445
+ }
291
446
  if (validResponse)
292
447
  this.transport.acceptedRecords(this.signal, records.length - rejected);
293
448
  return validResponse;
@@ -305,7 +460,12 @@ class ReportingExporter {
305
460
  return false;
306
461
  }
307
462
  finally {
308
- await exporter.shutdown();
463
+ clearTimeout(timer);
464
+ for (const agent of agents)
465
+ agent.destroy();
466
+ // Delegate cleanup cannot extend the hard request wait. Sockets are closed
467
+ // and the cleanup promise is always observed, even after a caller timeout.
468
+ void exporter.shutdown().catch(() => { });
309
469
  }
310
470
  }
311
471
  async forceFlush() {
package/dist/types.d.ts CHANGED
@@ -4,17 +4,26 @@ export type JsonValue = null | boolean | number | string | JsonValue[] | {
4
4
  [key: string]: JsonValue;
5
5
  };
6
6
  export type Signal = "traces" | "logs";
7
- export interface HueOptions {
8
- apiKey: string;
9
- serviceName: string;
7
+ interface SharedHueOptions {
10
8
  captureContent: boolean;
11
9
  baseUrl?: string;
12
10
  serviceVersion?: string;
13
11
  /** Runs on string values before Hue export, including custom attribute values. */
14
12
  redact?: (value: string, path: string) => string;
15
- onExportIssue?: (issue: ExportIssue) => void;
13
+ onExportIssue?: (issue: ExportIssue) => void | Promise<void>;
16
14
  timeoutMillis?: number;
15
+ /** Aggregate estimated retained telemetry bytes across both signals, including in-flight work. Default 8 MiB. */
16
+ maxQueueBytes?: number;
17
17
  }
18
+ export type HueOptions = SharedHueOptions & ({
19
+ enabled?: true;
20
+ apiKey: string;
21
+ serviceName: string;
22
+ } | {
23
+ enabled: false;
24
+ apiKey?: string;
25
+ serviceName?: string;
26
+ });
18
27
  export interface ExportIssue {
19
28
  sequence: number;
20
29
  signal: Signal;
@@ -32,6 +41,19 @@ export interface ExportReport {
32
41
  failedLogs: number;
33
42
  pendingSpans: number;
34
43
  pendingLogs: number;
44
+ droppedSpans: number;
45
+ droppedLogs: number;
46
+ pendingBytes: number;
47
+ instrumentationFailures: number;
48
+ }
49
+ export interface SafeLifecycleOptions {
50
+ /** Caller wait budget, 1–60000 ms. Default 1000. Does not cancel borrowed providers. */
51
+ timeoutMillis?: number;
52
+ }
53
+ export interface SafeLifecycleResult {
54
+ ok: boolean;
55
+ timedOut: boolean;
56
+ report: ExportReport;
35
57
  }
36
58
  export interface SpanOptions {
37
59
  kind?: SpanKind;
@@ -82,3 +104,4 @@ export interface TraceVerification {
82
104
  verified: boolean;
83
105
  receipt: TraceReceipt | null;
84
106
  }
107
+ export {};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hue-run/sdk",
3
- "version": "0.1.4",
3
+ "version": "0.1.5",
4
4
  "private": false,
5
5
  "license": "MIT",
6
6
  "publishConfig": {
@@ -68,7 +68,7 @@
68
68
  "peerDependencies": {
69
69
  "@ai-sdk/otel": "^1.0.99",
70
70
  "@opentelemetry/api": "^1.9.1",
71
- "ai": "^7.0.99"
71
+ "ai": "^6.0.0 || ^7.0.99"
72
72
  },
73
73
  "peerDependenciesMeta": {
74
74
  "@ai-sdk/otel": {