@hue-run/sdk 0.1.4 → 0.2.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.
Files changed (58) hide show
  1. package/ENVIRONMENTS.md +182 -0
  2. package/EVALUATIONS.md +12 -0
  3. package/README.md +204 -21
  4. package/dist/ai-sdk.d.ts +9 -1
  5. package/dist/ai-sdk.js +37 -2
  6. package/dist/client.d.ts +130 -5
  7. package/dist/client.js +518 -110
  8. package/dist/config.d.ts +11 -2
  9. package/dist/config.js +50 -4
  10. package/dist/environment/client.d.ts +73 -0
  11. package/dist/environment/client.js +209 -0
  12. package/dist/environment/tools.d.ts +30 -0
  13. package/dist/environment/tools.js +24 -0
  14. package/dist/environment/types.d.ts +429 -0
  15. package/dist/environment/types.js +1 -0
  16. package/dist/environment.d.ts +5 -0
  17. package/dist/environment.js +2 -0
  18. package/dist/evals/attempt.d.ts +454 -0
  19. package/dist/evals/attempt.js +687 -0
  20. package/dist/evals/client.d.ts +99 -5
  21. package/dist/evals/client.js +136 -7
  22. package/dist/evals/environment-evidence.d.ts +6 -0
  23. package/dist/evals/environment-evidence.js +123 -0
  24. package/dist/evals/environment-json.d.ts +3 -0
  25. package/dist/evals/environment-json.js +76 -0
  26. package/dist/evals/json.d.ts +9 -1
  27. package/dist/evals/json.js +14 -6
  28. package/dist/evals/runner.d.ts +61 -2
  29. package/dist/evals/runner.js +71 -9
  30. package/dist/evals/scorer-publication.d.ts +2 -0
  31. package/dist/evals/scorer-publication.js +84 -0
  32. package/dist/evals/scorers.d.ts +11 -0
  33. package/dist/evals/scorers.js +56 -5
  34. package/dist/evals/simulation.d.ts +184 -0
  35. package/dist/evals/simulation.js +603 -0
  36. package/dist/evals/types.d.ts +304 -0
  37. package/dist/evals.d.ts +5 -1
  38. package/dist/evals.js +3 -1
  39. package/dist/experimental-telemetry.d.ts +8 -0
  40. package/dist/experimental-telemetry.js +13 -0
  41. package/dist/index.d.ts +4 -1
  42. package/dist/index.js +3 -1
  43. package/dist/managed.d.ts +51 -1
  44. package/dist/managed.js +11 -1
  45. package/dist/privacy.d.ts +2 -0
  46. package/dist/privacy.js +54 -21
  47. package/dist/receipt.d.ts +12 -1
  48. package/dist/receipt.js +10 -1
  49. package/dist/safety.d.ts +7 -0
  50. package/dist/safety.js +179 -0
  51. package/dist/snapshot.d.ts +12 -0
  52. package/dist/snapshot.js +200 -0
  53. package/dist/transport.d.ts +46 -8
  54. package/dist/transport.js +266 -48
  55. package/dist/types.d.ts +167 -8
  56. package/dist/version.d.ts +2 -0
  57. package/dist/version.js +3 -0
  58. package/package.json +51 -15
package/dist/client.js CHANGED
@@ -1,16 +1,26 @@
1
1
  import { AsyncLocalStorage } from "node:async_hooks";
2
- import { context, SpanKind, SpanStatusCode, trace, } from "@opentelemetry/api";
2
+ import { context, isSpanContextValid, ROOT_CONTEXT, SpanKind, SpanStatusCode, trace, } from "@opentelemetry/api";
3
+ import { defaultTextMapGetter, defaultTextMapSetter } from "@opentelemetry/api";
3
4
  import { SeverityNumber } from "@opentelemetry/api-logs";
5
+ import { W3CTraceContextPropagator } from "@opentelemetry/core";
4
6
  import { LoggerProvider } from "@opentelemetry/sdk-logs";
5
7
  import { TracerProvider } from "@opentelemetry/sdk-trace";
6
- import { resourceFromAttributes } from "@opentelemetry/resources";
7
- import { MAX_CONTENT_BYTES } from "./config.js";
8
+ import { defaultResource, resourceFromAttributes } from "@opentelemetry/resources";
9
+ import { encodeContent, noopSpan, safeSpan } from "./safety.js";
8
10
  import { createHueTransport, HueExportError } from "./transport.js";
9
11
  import { verifyTrace } from "./receipt.js";
12
+ import { sdkVersion } from "./version.js";
13
+ /**
14
+ * Thrown by {@link HueClient.checkConnection} and {@link HueClient.verifyTrace} when Hue cannot be
15
+ * reached, rejects the project key or answers unexpectedly. The message is fixed and safe to log;
16
+ * the underlying network, timeout or parsing error, when there is one, is available as `cause`.
17
+ */
10
18
  export class HueConnectionError extends Error {
11
19
  status;
12
- constructor(message, status) {
13
- super(message);
20
+ constructor(message,
21
+ /** HTTP status when Hue answered; absent for network, timeout and parsing failures. */
22
+ status, options) {
23
+ super(message, options);
14
24
  this.status = status;
15
25
  this.name = "HueConnectionError";
16
26
  }
@@ -26,24 +36,72 @@ function identifier(value) {
26
36
  throw new TypeError("Session/user identifiers must contain 1–4096 valid characters");
27
37
  return value;
28
38
  }
39
+ /** A usable metadata label: a non-blank string of at most 256 characters. */
40
+ function isLabel(value) {
41
+ return typeof value === "string" && value.trim() !== "" && value.length <= 256;
42
+ }
43
+ /**
44
+ * Runs `work` exactly once with `active` as OpenTelemetry's current context, so instrumentations
45
+ * that use the global API parent under the Hue span. Without a registered context manager
46
+ * `context.with` only calls `work`; a failing manager is counted and cannot skip or rerun `work`.
47
+ */
48
+ function runInContext(active, work, failed) {
49
+ const slot = {};
50
+ const invoke = () => {
51
+ try {
52
+ return { value: work() };
53
+ }
54
+ catch (error) {
55
+ return { error };
56
+ }
57
+ };
58
+ try {
59
+ context.with(active, () => {
60
+ slot.outcome = invoke();
61
+ });
62
+ }
63
+ catch {
64
+ failed();
65
+ }
66
+ const settled = slot.outcome ?? invoke();
67
+ if ("error" in settled)
68
+ throw settled.error;
69
+ return settled.value;
70
+ }
71
+ /** Error class name for `error.type` and `exception.type`; never the message or stack. */
72
+ function errorType(error) {
73
+ return error instanceof Error && error.name ? String(error.name) : "Error";
74
+ }
29
75
  /** Local async context preserves nesting without registering or replacing global OTel providers. */
30
76
  class ContextualTracer {
31
77
  source;
32
78
  storage;
33
- constructor(source, storage) {
79
+ enabled;
80
+ failed;
81
+ constructor(source, storage, enabled, failed) {
34
82
  this.source = source;
35
83
  this.storage = storage;
84
+ this.enabled = enabled;
85
+ this.failed = failed;
36
86
  }
37
87
  startSpan(name, options = {}, parent) {
38
- const active = this.storage.getStore();
39
- return this.source.startSpan(name, {
40
- ...options,
41
- attributes: {
42
- ...options.attributes,
43
- ...(active?.sessionId ? { "gen_ai.conversation.id": active.sessionId } : {}),
44
- ...(active?.userId ? { "user.id": active.userId } : {}),
45
- },
46
- }, parent ?? active?.context ?? context.active());
88
+ if (!this.enabled())
89
+ return noopSpan();
90
+ try {
91
+ const active = this.storage.getStore();
92
+ return safeSpan(this.source.startSpan(name, {
93
+ ...options,
94
+ attributes: {
95
+ ...options.attributes,
96
+ ...(active?.sessionId ? { "gen_ai.conversation.id": active.sessionId } : {}),
97
+ ...(active?.userId ? { "user.id": active.userId } : {}),
98
+ },
99
+ }, parent ?? active?.context ?? context.active()), this.failed);
100
+ }
101
+ catch {
102
+ this.failed();
103
+ return noopSpan();
104
+ }
47
105
  }
48
106
  startActiveSpan(name, optionsOrFn, contextOrFn, fn) {
49
107
  const options = typeof optionsOrFn === "function" ? {} : optionsOrFn;
@@ -54,17 +112,48 @@ class ContextualTracer {
54
112
  : fn;
55
113
  if (!callback)
56
114
  throw new TypeError("A span callback is required");
57
- const parent = typeof contextOrFn === "object"
58
- ? contextOrFn
59
- : (this.storage.getStore()?.context ?? context.active());
115
+ let parent = ROOT_CONTEXT;
116
+ try {
117
+ parent =
118
+ typeof contextOrFn === "object"
119
+ ? contextOrFn
120
+ : (this.storage.getStore()?.context ?? context.active());
121
+ }
122
+ catch {
123
+ this.failed();
124
+ }
60
125
  const span = this.startSpan(name, options, parent);
61
- return this.storage.run({ ...this.storage.getStore(), context: trace.setSpan(parent, span) }, () => callback(span));
126
+ // A disabled client creates no span and leaves the application's active context untouched.
127
+ const created = isSpanContextValid(span.spanContext());
128
+ let active = parent;
129
+ if (created)
130
+ try {
131
+ active = trace.setSpan(parent, span);
132
+ }
133
+ catch {
134
+ this.failed();
135
+ active = trace.setSpan(ROOT_CONTEXT, span);
136
+ }
137
+ return this.storage.run({ ...this.storage.getStore(), context: active }, () => created
138
+ ? runInContext(active, () => callback(span), this.failed)
139
+ : callback(span));
62
140
  }
63
141
  }
142
+ const propagator = new W3CTraceContextPropagator();
143
+ /**
144
+ * Hue tracing client. Helpers create spans through a private tracer and local async context, never
145
+ * through global OpenTelemetry registration, and they fail open: capture problems are counted in
146
+ * the export report while application code runs and returns unchanged.
147
+ */
64
148
  export class HueClient {
149
+ /** Export pipeline: counters, issue history and the processors that feed Hue. */
65
150
  transport;
151
+ /** Hue's tracer for other instrumentations; spans parent under `withSpan` and inherit identifiers. */
66
152
  tracer;
153
+ /** Whether helpers record content, as configured. */
67
154
  captureContent;
155
+ /** False for `enabled: false` clients and for `createHueSafe` fallbacks; helpers then only run callbacks. */
156
+ enabled;
68
157
  logger;
69
158
  storage = new AsyncLocalStorage();
70
159
  tracerProvider;
@@ -73,135 +162,348 @@ export class HueClient {
73
162
  closed = false;
74
163
  shutdownPromise;
75
164
  flushPromise;
165
+ safeFlushPromise;
166
+ safeShutdownPromise;
76
167
  constructor(options) {
77
168
  if ("transport" in options) {
78
169
  this.transport = options.transport;
79
170
  this.tracerProvider = options.tracerProvider;
80
171
  this.loggerProvider = options.loggerProvider;
172
+ if (this.transport.options.resourceAttributes !== undefined)
173
+ this.transport.issue("traces", "warning", 0, "resourceAttributes are ignored in attach mode; configure the resource on the application's providers");
81
174
  }
82
175
  else {
83
176
  this.transport = createHueTransport(options);
84
- const resource = resourceFromAttributes({
85
- "service.name": options.serviceName,
177
+ // The OpenTelemetry default resource supplies the required telemetry.sdk.* attributes and
178
+ // Hue's service identity is merged on top of any caller-supplied resourceAttributes, so
179
+ // serviceName and serviceVersion take precedence over same-named keys. Nothing here reads
180
+ // environment variables.
181
+ const resource = defaultResource().merge(resourceFromAttributes({
182
+ ...options.resourceAttributes,
183
+ "service.name": this.transport.options.serviceName,
86
184
  ...(options.serviceVersion ? { "service.version": options.serviceVersion } : {}),
87
- });
185
+ }));
88
186
  const tracer = new TracerProvider({
89
187
  resource,
90
- spanProcessors: [this.transport.spanProcessor],
188
+ spanProcessors: this.transport.options.enabled === false ? [] : [this.transport.spanProcessor],
91
189
  });
92
190
  const logger = new LoggerProvider({
93
191
  resource,
94
- processors: [this.transport.logRecordProcessor],
192
+ processors: this.transport.options.enabled === false ? [] : [this.transport.logRecordProcessor],
95
193
  });
96
194
  this.ownedProviders = { tracer, logger };
97
195
  this.tracerProvider = tracer;
98
196
  this.loggerProvider = logger;
99
197
  }
100
198
  this.captureContent = this.transport.options.captureContent;
101
- this.tracer = new ContextualTracer(this.tracerProvider.getTracer("@hue-run/sdk", "0.1.4"), this.storage);
102
- this.logger = this.loggerProvider.getLogger("@hue-run/sdk", "0.1.4");
199
+ this.enabled = this.transport.options.enabled !== false;
200
+ this.tracer = new ContextualTracer(this.tracerProvider.getTracer("@hue-run/sdk", sdkVersion), this.storage, () => this.enabled && !this.closed, () => this.transport.instrumentationFailure());
201
+ this.logger = this.loggerProvider.getLogger("@hue-run/sdk", sdkVersion);
103
202
  }
203
+ /**
204
+ * Verifies that Hue stored a trace by ID, optionally waiting for expected span IDs and normalized
205
+ * fields within the budget. Read-only; it does not flush or inspect content.
206
+ *
207
+ * @throws HueConnectionError when the client is disabled.
208
+ * @throws HueTraceVerificationError for authentication, unsupported endpoint, transport or invalid response failures.
209
+ * @throws TypeError for an invalid trace ID or options.
210
+ */
104
211
  verifyTrace(traceId, options = {}) {
212
+ if (!this.enabled)
213
+ return Promise.reject(new HueConnectionError("Hue telemetry is disabled"));
105
214
  return verifyTrace(this.transport.options, traceId, options);
106
215
  }
216
+ /** The helper's current context: the innermost active Hue span, else the OpenTelemetry active context. */
107
217
  getContext() {
108
- return this.storage.getStore()?.context ?? context.active();
218
+ try {
219
+ return this.storage.getStore()?.context ?? context.active();
220
+ }
221
+ catch {
222
+ this.transport.instrumentationFailure();
223
+ return ROOT_CONTEXT;
224
+ }
109
225
  }
226
+ /**
227
+ * Runs `callback` inside a new span that nests under the active Hue span. Application errors are
228
+ * recorded on the span and rethrown unchanged; the span always ends when the callback settles.
229
+ */
110
230
  async withSpan(name, callback, options = {}) {
111
- if (this.closed)
112
- throw new Error("Hue client is shut down");
113
- const inherited = this.storage.getStore();
114
- const sessionId = identifier(options.sessionId ?? inherited?.sessionId);
115
- const userId = identifier(options.userId ?? inherited?.userId);
116
- const parent = options.parentContext ?? inherited?.context ?? context.active();
117
- const active = { context: parent, sessionId, userId };
118
- return this.storage.run(active, async () => {
119
- const span = this.tracer.startSpan(name, { kind: options.kind ?? SpanKind.INTERNAL, attributes: options.attributes }, parent);
120
- const spanContext = trace.setSpan(parent, span);
121
- const handle = {
122
- span,
123
- context: spanContext,
124
- traceId: span.spanContext().traceId,
125
- spanId: span.spanContext().spanId,
126
- setInput: (value) => this.setContent(span, "input.value", value),
127
- setOutput: (value) => this.setContent(span, "output.value", value),
128
- };
129
- return this.storage.run({ ...active, context: spanContext }, async () => {
130
- try {
131
- if (options.input !== undefined)
132
- handle.setInput(options.input);
133
- return await callback(handle);
134
- }
135
- catch (error) {
136
- this.recordError(span, error);
137
- throw error;
138
- }
139
- finally {
140
- span.end();
141
- }
142
- });
143
- });
231
+ let span = noopSpan();
232
+ let active = { context: this.getContext() };
233
+ if (this.enabled && !this.closed) {
234
+ try {
235
+ const inherited = this.storage.getStore();
236
+ active = {
237
+ context: options.parentContext ?? inherited?.context ?? context.active(),
238
+ sessionId: identifier(options.sessionId ?? inherited?.sessionId),
239
+ userId: identifier(options.userId ?? inherited?.userId),
240
+ model: inherited?.model,
241
+ };
242
+ span = this.storage.run(active, () => this.tracer.startSpan(name, { kind: options.kind ?? SpanKind.INTERNAL, attributes: options.attributes }, active.context));
243
+ }
244
+ catch {
245
+ this.transport.instrumentationFailure();
246
+ }
247
+ }
248
+ // A disabled or failed client creates no span; the application's own active span then stays
249
+ // visible through getContext(), inject() and HueSpan.context instead of an invalid one.
250
+ const created = isSpanContextValid(span.spanContext());
251
+ let spanContext = active.context;
252
+ if (created)
253
+ try {
254
+ spanContext = trace.setSpan(active.context, span);
255
+ }
256
+ catch {
257
+ this.transport.instrumentationFailure();
258
+ spanContext = trace.setSpan(ROOT_CONTEXT, span);
259
+ }
260
+ const handle = {
261
+ span,
262
+ context: spanContext,
263
+ traceId: span.spanContext().traceId,
264
+ spanId: span.spanContext().spanId,
265
+ setInput: (value) => this.setContent(span, "input.value", value),
266
+ setOutput: (value) => this.setContent(span, "output.value", value),
267
+ setUsage: (usage) => this.setUsage(span, usage),
268
+ };
269
+ const execute = async () => {
270
+ // Setup, capture and cleanup have separate failure boundaries from customer code.
271
+ try {
272
+ if (this.enabled && !this.closed && options.input !== undefined)
273
+ handle.setInput(options.input);
274
+ }
275
+ catch {
276
+ this.transport.instrumentationFailure();
277
+ }
278
+ try {
279
+ return await callback(handle);
280
+ }
281
+ catch (error) {
282
+ this.recordError(span, error);
283
+ throw error;
284
+ }
285
+ finally {
286
+ span.end();
287
+ }
288
+ };
289
+ // The span is also OpenTelemetry's active span while the callback runs, so spans from other
290
+ // instrumentations (HTTP clients, provider SDKs) join this trace when the application has
291
+ // registered a context manager. Hue still registers none itself.
292
+ return this.storage.run({ ...active, context: spanContext }, () => created
293
+ ? runInContext(spanContext, execute, () => this.transport.instrumentationFailure())
294
+ : execute());
144
295
  }
145
- async tool(name, input, execute) {
146
- return this.withSpan(name, async ({ span }) => {
296
+ /**
297
+ * Runs `execute` inside an `execute_tool` span named after the tool. `input` and a defined result
298
+ * are recorded as `gen_ai.tool.call.arguments` / `gen_ai.tool.call.result` when `captureContent`
299
+ * is true; values that are not JSON-encodable are omitted with an instrumentation failure.
300
+ * `options.callId` is recorded as `gen_ai.tool.call.id`, like the Python `call_id=` keyword.
301
+ */
302
+ async tool(name, input, execute, options = {}) {
303
+ const attributes = {
304
+ "gen_ai.operation.name": "execute_tool",
305
+ "gen_ai.tool.name": name,
306
+ };
307
+ const callId = options.callId;
308
+ if (callId !== undefined) {
309
+ // A blank or non-string id is omitted and counted; the tool call itself still runs.
310
+ if (isLabel(callId))
311
+ attributes["gen_ai.tool.call.id"] = callId;
312
+ else if (this.enabled && !this.closed)
313
+ this.transport.instrumentationFailure();
314
+ }
315
+ return this.withSpan(`execute_tool ${name}`, async ({ span }) => {
147
316
  this.setContent(span, "gen_ai.tool.call.arguments", input);
148
317
  const result = await execute();
149
318
  if (result !== undefined)
150
319
  this.setContent(span, "gen_ai.tool.call.result", result);
151
320
  return result;
152
- }, { attributes: { "gen_ai.operation.name": "execute_tool", "gen_ai.tool.name": name } });
153
- }
154
- recordError(span, error) {
155
- const type = error instanceof Error ? error.name : "Error";
156
- span.setStatus({
157
- code: SpanStatusCode.ERROR,
158
- ...(this.captureContent
159
- ? { message: error instanceof Error ? error.message : "Operation failed" }
160
- : {}),
321
+ }, {
322
+ attributes,
323
+ ...(options.parentContext ? { parentContext: options.parentContext } : {}),
161
324
  });
162
- span.addEvent("exception", {
163
- "exception.type": type,
164
- ...(this.captureContent && error instanceof Error
165
- ? {
166
- "exception.message": error.message,
167
- ...(error.stack ? { "exception.stacktrace": error.stack } : {}),
168
- }
169
- : {}),
325
+ }
326
+ /**
327
+ * Runs `callback` inside a GenAI client span for one direct provider call, named
328
+ * `{operation} {model}` unless `options.name` is given and carrying `gen_ai.operation.name`,
329
+ * `gen_ai.request.model` and `gen_ai.provider.name`. The argument order matches `withSpan`. The
330
+ * handle's `setInput`/`setOutput` record `gen_ai.input.messages` / `gen_ai.output.messages`,
331
+ * which should use the GenAI semantic-convention message shape; `recordMessages` inside the
332
+ * callback inherits the request metadata.
333
+ */
334
+ async model(model, callback, options) {
335
+ // A disabled or closed client creates no span, so invalid metadata is not an instrumentation
336
+ // failure either; only an active client records it (matching the other helpers).
337
+ const active = this.enabled && !this.closed;
338
+ const label = (value, fallback) => {
339
+ if (isLabel(value))
340
+ return value;
341
+ if (active)
342
+ this.transport.instrumentationFailure();
343
+ return fallback;
344
+ };
345
+ const requestModel = label(model, "unknown");
346
+ const operation = label(options?.operation ?? "chat", "chat");
347
+ const provider = label(options?.provider, "unknown");
348
+ const name = options?.name === undefined
349
+ ? `${operation} ${requestModel}`
350
+ : label(options.name, `${operation} ${requestModel}`);
351
+ const metadata = { operation, provider, requestModel };
352
+ const { sessionId, userId, parentContext, input } = options ?? {};
353
+ return this.withSpan(name, (span) => {
354
+ const handle = {
355
+ ...span,
356
+ setInput: (value) => this.setContent(span.span, "gen_ai.input.messages", value),
357
+ setOutput: (value) => this.setContent(span.span, "gen_ai.output.messages", value),
358
+ };
359
+ if (input !== undefined)
360
+ handle.setInput(input);
361
+ // recordMessages inside the callback copies this request metadata onto its log record.
362
+ const store = this.storage.getStore() ?? { context: span.context };
363
+ return this.storage.run({ ...store, model: metadata }, () => callback(handle));
364
+ }, {
365
+ sessionId,
366
+ userId,
367
+ parentContext,
368
+ kind: SpanKind.CLIENT,
369
+ attributes: {
370
+ "gen_ai.operation.name": operation,
371
+ "gen_ai.request.model": requestModel,
372
+ "gen_ai.provider.name": provider,
373
+ },
170
374
  });
171
375
  }
376
+ setUsage(span, usage) {
377
+ if (!this.enabled || this.closed)
378
+ return;
379
+ for (const [key, value] of [
380
+ ["gen_ai.usage.input_tokens", usage?.inputTokens],
381
+ ["gen_ai.usage.output_tokens", usage?.outputTokens],
382
+ ]) {
383
+ if (value === undefined)
384
+ continue;
385
+ if (!Number.isInteger(value) || value < 0) {
386
+ this.transport.instrumentationFailure();
387
+ continue;
388
+ }
389
+ try {
390
+ span.setAttribute(key, value);
391
+ }
392
+ catch {
393
+ this.transport.instrumentationFailure();
394
+ }
395
+ }
396
+ }
397
+ /**
398
+ * Writes W3C `traceparent` for the active span into a carrier; never the API key or baggage.
399
+ * Propagation also runs for a disabled or closed client so downstream tracing stays connected.
400
+ */
401
+ inject(carrier, activeContext = this.getContext()) {
402
+ try {
403
+ propagator.inject(activeContext, carrier, defaultTextMapSetter);
404
+ }
405
+ catch {
406
+ this.transport.instrumentationFailure();
407
+ }
408
+ }
409
+ /** Reads W3C trace context from a carrier for use as `parentContext`. */
410
+ extract(carrier) {
411
+ try {
412
+ return propagator.extract(ROOT_CONTEXT, carrier, defaultTextMapGetter);
413
+ }
414
+ catch {
415
+ this.transport.instrumentationFailure();
416
+ return ROOT_CONTEXT;
417
+ }
418
+ }
419
+ /**
420
+ * Marks a span failed the same way in every helper: `error.type`, an ERROR status without a
421
+ * description and an `exception` event carrying only the type. Exception messages and stack
422
+ * traces are never recorded, whatever `captureContent` is, matching the Python SDK.
423
+ */
424
+ recordError(span, error) {
425
+ if (!this.enabled || this.closed)
426
+ return;
427
+ try {
428
+ const type = errorType(error);
429
+ span.setAttribute("error.type", type);
430
+ span.setStatus({ code: SpanStatusCode.ERROR });
431
+ span.addEvent("exception", { "exception.type": type });
432
+ }
433
+ catch {
434
+ this.transport.instrumentationFailure();
435
+ }
436
+ }
437
+ /**
438
+ * Emits a `gen_ai.client.inference.operation.details` log record correlated with the active (or
439
+ * given) span, carrying the messages in its body. `gen_ai.operation.name`, `gen_ai.provider.name`
440
+ * and `gen_ai.request.model` are set as record attributes from the caller's values or the
441
+ * enclosing {@link model} span, and `gen_ai.conversation.id` from the active session. Nothing is
442
+ * emitted when `captureContent` is false; capture failures are counted, never thrown.
443
+ */
172
444
  recordMessages(messages, explicitContext) {
173
- if (this.closed)
174
- throw new Error("Hue client is shut down");
175
- if (!this.captureContent)
445
+ if (!this.enabled || this.closed || !this.captureContent)
176
446
  return;
177
- const active = explicitContext ?? this.getContext();
178
- if (!trace.getSpanContext(active))
179
- throw new Error("Message records require an active span or explicit span context");
180
- const body = {};
181
- if (messages.input !== undefined)
182
- body["gen_ai.input.messages"] = messages.input;
183
- if (messages.output !== undefined)
184
- body["gen_ai.output.messages"] = messages.output;
185
- this.logger.emit({
186
- context: active,
187
- severityNumber: SeverityNumber.INFO,
188
- eventName: "gen_ai.client.inference.operation.details",
189
- body,
190
- });
447
+ try {
448
+ const store = this.storage.getStore();
449
+ const active = explicitContext ?? store?.context ?? context.active();
450
+ if (!trace.getSpanContext(active))
451
+ throw new Error("Message records require an active span or explicit span context");
452
+ const body = {};
453
+ if (messages.input !== undefined)
454
+ body["gen_ai.input.messages"] = messages.input;
455
+ if (messages.output !== undefined)
456
+ body["gen_ai.output.messages"] = messages.output;
457
+ // Request metadata is inherited only when the record correlates with the enclosing helper
458
+ // scope; an unrelated explicit context carries caller-supplied values alone.
459
+ const enclosing = explicitContext === undefined || explicitContext === store?.context ? store : undefined;
460
+ const attributes = {};
461
+ const stamp = (key, explicit, inherited) => {
462
+ if (explicit === undefined) {
463
+ if (inherited !== undefined)
464
+ attributes[key] = inherited;
465
+ }
466
+ else if (typeof explicit === "string" && explicit.trim() && explicit.length <= 256)
467
+ attributes[key] = explicit;
468
+ else
469
+ this.transport.instrumentationFailure("logs");
470
+ };
471
+ stamp("gen_ai.operation.name", messages.operation, enclosing?.model?.operation);
472
+ stamp("gen_ai.provider.name", messages.provider, enclosing?.model?.provider);
473
+ stamp("gen_ai.request.model", messages.model, enclosing?.model?.requestModel);
474
+ stamp("gen_ai.conversation.id", undefined, enclosing?.sessionId);
475
+ this.logger.emit({
476
+ context: active,
477
+ severityNumber: SeverityNumber.INFO,
478
+ eventName: "gen_ai.client.inference.operation.details",
479
+ attributes,
480
+ body: JSON.parse(encodeContent(body)),
481
+ });
482
+ }
483
+ catch {
484
+ this.transport.instrumentationFailure("logs");
485
+ }
191
486
  }
192
487
  setContent(span, key, value) {
193
- if (!this.captureContent)
488
+ if (!this.enabled || this.closed || !this.captureContent)
194
489
  return;
195
- const encoded = JSON.stringify(value, (_key, item) => {
196
- if (typeof item === "number" && !Number.isFinite(item))
197
- throw new TypeError("Captured JSON numbers must be finite");
198
- return item;
199
- });
200
- if (encoded === undefined || Buffer.byteLength(encoded) > MAX_CONTENT_BYTES)
201
- throw new RangeError("Captured content must be JSON and no more than 256 KiB");
202
- span.setAttribute(key, encoded);
490
+ try {
491
+ span.setAttribute(key, encodeContent(value));
492
+ }
493
+ catch {
494
+ this.transport.instrumentationFailure();
495
+ }
203
496
  }
497
+ /**
498
+ * Confirms the key and origin by reading the current project; a setup and CI diagnostic, not a
499
+ * readiness gate. Redirects are refused and the response is bounded.
500
+ *
501
+ * @throws HueConnectionError when the client is disabled, Hue is unreachable (the network or
502
+ * timeout error is the `cause`), the key is rejected (`status` is set) or the response is invalid.
503
+ */
204
504
  async checkConnection() {
505
+ if (!this.enabled)
506
+ throw new HueConnectionError("Hue telemetry is disabled");
205
507
  const options = this.transport.options;
206
508
  let response;
207
509
  try {
@@ -211,8 +513,8 @@ export class HueClient {
211
513
  signal: AbortSignal.timeout(options.timeoutMillis),
212
514
  });
213
515
  }
214
- catch {
215
- throw new HueConnectionError("Unable to connect to Hue; check the endpoint and network");
516
+ catch (error) {
517
+ throw new HueConnectionError("Unable to connect to Hue; check the endpoint and network", undefined, { cause: error });
216
518
  }
217
519
  if (!response.ok) {
218
520
  await response.body?.cancel();
@@ -254,10 +556,75 @@ export class HueClient {
254
556
  slug: fields.slug,
255
557
  };
256
558
  }
559
+ catch (error) {
560
+ throw new HueConnectionError("Hue returned an invalid project response", undefined, {
561
+ cause: error,
562
+ });
563
+ }
564
+ }
565
+ /** Production lifecycle path: never rejects; timeouts do not cancel borrowed provider work. */
566
+ flushSafe(options = {}) {
567
+ if (this.safeFlushPromise)
568
+ return this.safeFlushPromise;
569
+ const work = this.flushPromise ?? this.flush();
570
+ const result = this.safeLifecycle(() => work, options);
571
+ this.safeFlushPromise = result;
572
+ const clear = () => {
573
+ this.safeFlushPromise = undefined;
574
+ };
575
+ // Retain the timed-out result until its underlying drain settles. Repeated
576
+ // timeouts must not accumulate promises against a hung borrowed provider.
577
+ void work.then(clear, clear);
578
+ return result;
579
+ }
580
+ /** Safe for finally blocks; preserves the application's result or original exception. */
581
+ shutdownSafe(options = {}) {
582
+ if (this.safeShutdownPromise)
583
+ return this.safeShutdownPromise;
584
+ const work = this.shutdown();
585
+ const result = this.safeLifecycle(() => work, options);
586
+ this.safeShutdownPromise = result;
587
+ const clear = () => {
588
+ this.safeShutdownPromise = undefined;
589
+ };
590
+ void work.then(clear, clear);
591
+ return result;
592
+ }
593
+ async safeLifecycle(work, options) {
594
+ let timer;
595
+ try {
596
+ const timeout = options.timeoutMillis ?? 1000;
597
+ if (!Number.isInteger(timeout) || timeout < 1 || timeout > 60000)
598
+ throw new TypeError("Invalid lifecycle budget");
599
+ const outcome = await Promise.race([
600
+ work().then(() => "complete"),
601
+ new Promise((resolve) => {
602
+ timer = setTimeout(() => resolve("timeout"), timeout);
603
+ }),
604
+ ]);
605
+ const report = this.transport.getReport();
606
+ return {
607
+ ok: outcome === "complete" &&
608
+ this.transport.getFailureSequence() === 0 &&
609
+ report.pendingSpans + report.pendingLogs === 0,
610
+ timedOut: outcome === "timeout",
611
+ report,
612
+ };
613
+ }
257
614
  catch {
258
- throw new HueConnectionError("Hue returned an invalid project response");
615
+ return { ok: false, timedOut: false, report: this.transport.getReport() };
616
+ }
617
+ finally {
618
+ clearTimeout(timer);
259
619
  }
260
620
  }
621
+ /**
622
+ * Drains the trace and log providers and waits for Hue's acknowledgements. Each caller gets a
623
+ * fresh serialized drain that includes records emitted before its call.
624
+ *
625
+ * @throws HueExportError when this drain observed a new rejection, delivery failure, drop or
626
+ * invalid record; its `issues` and `report` are sanitized counts, never server text.
627
+ */
261
628
  flush() {
262
629
  // Each caller needs a drain after its own preceding span/log emissions.
263
630
  // Joining an earlier drain can acknowledge records that were not in its batch.
@@ -281,14 +648,20 @@ export class HueClient {
281
648
  }
282
649
  async flushOnce() {
283
650
  const results = await Promise.allSettled([
284
- this.tracerProvider.forceFlush(),
285
- this.loggerProvider.forceFlush(),
651
+ Promise.resolve().then(() => this.tracerProvider.forceFlush()),
652
+ Promise.resolve().then(() => this.loggerProvider.forceFlush()),
286
653
  ]);
287
654
  for (const [index, result] of results.entries())
288
655
  if (result.status === "rejected")
289
656
  this.transport.issue(index === 0 ? "traces" : "logs", "failed", 0, "OpenTelemetry provider flush failed");
290
657
  return this.transport.flush();
291
658
  }
659
+ /**
660
+ * Flushes, then shuts down the providers this client owns; borrowed providers are left running.
661
+ * Idempotent: later calls return the same promise, and later helper calls only run their callbacks.
662
+ *
663
+ * @throws HueExportError when the final flush observed new failures; owned providers are still released.
664
+ */
292
665
  shutdown() {
293
666
  this.shutdownPromise ??= (async () => {
294
667
  this.closed = true;
@@ -308,6 +681,41 @@ export class HueClient {
308
681
  return this.shutdownPromise;
309
682
  }
310
683
  }
684
+ /**
685
+ * Creates a client that owns its providers ({@link HueOptions}) or attaches to the application's
686
+ * ({@link ExistingHueProviders}). Strict: use it for setup and CI, `createHueSafe` in serving code.
687
+ *
688
+ * @throws TypeError for invalid options, including a missing `captureContent` choice, an invalid
689
+ * key or `serviceName`, a non-origin or insecure `baseUrl`, or out-of-range budgets.
690
+ */
311
691
  export function createHue(options) {
312
692
  return new HueClient(options);
313
693
  }
694
+ /**
695
+ * Fail-open initialization for production: invalid options return a disabled client with one
696
+ * instrumentation failure recorded instead of throwing. Strict {@link createHue} remains for setup/CI.
697
+ */
698
+ export function createHueSafe(options) {
699
+ try {
700
+ return new HueClient(options);
701
+ }
702
+ catch (error) {
703
+ // The caller's diagnostics hook stays attached and hears why telemetry is off.
704
+ const disabled = new HueClient({ enabled: false, onExportIssue: issueCallback(options) });
705
+ const reason = error instanceof Error && error.message ? error.message : "unknown error";
706
+ disabled.transport.instrumentationFailure("traces", `Hue is disabled: ${reason.slice(0, 256)}`);
707
+ return disabled;
708
+ }
709
+ }
710
+ function issueCallback(options) {
711
+ try {
712
+ const source = options && typeof options === "object" && "transport" in options
713
+ ? options.transport?.options
714
+ : options;
715
+ const callback = source?.onExportIssue;
716
+ return typeof callback === "function" ? callback : undefined;
717
+ }
718
+ catch {
719
+ return undefined;
720
+ }
721
+ }