@hue-run/sdk 0.1.5 → 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 (56) hide show
  1. package/ENVIRONMENTS.md +182 -0
  2. package/EVALUATIONS.md +12 -0
  3. package/README.md +194 -18
  4. package/dist/ai-sdk.d.ts +9 -1
  5. package/dist/ai-sdk.js +34 -8
  6. package/dist/client.d.ts +121 -6
  7. package/dist/client.js +329 -56
  8. package/dist/config.d.ts +11 -2
  9. package/dist/config.js +36 -7
  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 +3 -0
  42. package/dist/index.js +2 -0
  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 +16 -1
  47. package/dist/receipt.d.ts +12 -1
  48. package/dist/receipt.js +10 -1
  49. package/dist/safety.d.ts +1 -2
  50. package/dist/snapshot.js +4 -0
  51. package/dist/transport.d.ts +41 -9
  52. package/dist/transport.js +80 -22
  53. package/dist/types.d.ts +144 -8
  54. package/dist/version.d.ts +2 -0
  55. package/dist/version.js +3 -0
  56. 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, ROOT_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";
8
+ import { defaultResource, resourceFromAttributes } from "@opentelemetry/resources";
7
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,6 +36,42 @@ 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;
@@ -77,21 +123,36 @@ class ContextualTracer {
77
123
  this.failed();
78
124
  }
79
125
  const span = this.startSpan(name, options, parent);
80
- let active = ROOT_CONTEXT;
81
- try {
82
- active = trace.setSpan(parent, span);
83
- }
84
- catch {
85
- this.failed();
86
- active = trace.setSpan(ROOT_CONTEXT, span);
87
- }
88
- return this.storage.run({ ...this.storage.getStore(), context: active }, () => 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));
89
140
  }
90
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
+ */
91
148
  export class HueClient {
149
+ /** Export pipeline: counters, issue history and the processors that feed Hue. */
92
150
  transport;
151
+ /** Hue's tracer for other instrumentations; spans parent under `withSpan` and inherit identifiers. */
93
152
  tracer;
153
+ /** Whether helpers record content, as configured. */
94
154
  captureContent;
155
+ /** False for `enabled: false` clients and for `createHueSafe` fallbacks; helpers then only run callbacks. */
95
156
  enabled;
96
157
  logger;
97
158
  storage = new AsyncLocalStorage();
@@ -108,13 +169,20 @@ export class HueClient {
108
169
  this.transport = options.transport;
109
170
  this.tracerProvider = options.tracerProvider;
110
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");
111
174
  }
112
175
  else {
113
176
  this.transport = createHueTransport(options);
114
- const resource = resourceFromAttributes({
115
- "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,
116
184
  ...(options.serviceVersion ? { "service.version": options.serviceVersion } : {}),
117
- });
185
+ }));
118
186
  const tracer = new TracerProvider({
119
187
  resource,
120
188
  spanProcessors: this.transport.options.enabled === false ? [] : [this.transport.spanProcessor],
@@ -129,14 +197,23 @@ export class HueClient {
129
197
  }
130
198
  this.captureContent = this.transport.options.captureContent;
131
199
  this.enabled = this.transport.options.enabled !== false;
132
- this.tracer = new ContextualTracer(this.tracerProvider.getTracer("@hue-run/sdk", "0.1.5"), this.storage, () => this.enabled && !this.closed, () => this.transport.instrumentationFailure());
133
- this.logger = this.loggerProvider.getLogger("@hue-run/sdk", "0.1.5");
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);
134
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
+ */
135
211
  verifyTrace(traceId, options = {}) {
136
212
  if (!this.enabled)
137
213
  return Promise.reject(new HueConnectionError("Hue telemetry is disabled"));
138
214
  return verifyTrace(this.transport.options, traceId, options);
139
215
  }
216
+ /** The helper's current context: the innermost active Hue span, else the OpenTelemetry active context. */
140
217
  getContext() {
141
218
  try {
142
219
  return this.storage.getStore()?.context ?? context.active();
@@ -146,6 +223,10 @@ export class HueClient {
146
223
  return ROOT_CONTEXT;
147
224
  }
148
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
+ */
149
230
  async withSpan(name, callback, options = {}) {
150
231
  let span = noopSpan();
151
232
  let active = { context: this.getContext() };
@@ -156,6 +237,7 @@ export class HueClient {
156
237
  context: options.parentContext ?? inherited?.context ?? context.active(),
157
238
  sessionId: identifier(options.sessionId ?? inherited?.sessionId),
158
239
  userId: identifier(options.userId ?? inherited?.userId),
240
+ model: inherited?.model,
159
241
  };
160
242
  span = this.storage.run(active, () => this.tracer.startSpan(name, { kind: options.kind ?? SpanKind.INTERNAL, attributes: options.attributes }, active.context));
161
243
  }
@@ -163,14 +245,18 @@ export class HueClient {
163
245
  this.transport.instrumentationFailure();
164
246
  }
165
247
  }
166
- let spanContext;
167
- try {
168
- spanContext = trace.setSpan(active.context, span);
169
- }
170
- catch {
171
- this.transport.instrumentationFailure();
172
- spanContext = trace.setSpan(ROOT_CONTEXT, span);
173
- }
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
+ }
174
260
  const handle = {
175
261
  span,
176
262
  context: spanContext,
@@ -178,8 +264,9 @@ export class HueClient {
178
264
  spanId: span.spanContext().spanId,
179
265
  setInput: (value) => this.setContent(span, "input.value", value),
180
266
  setOutput: (value) => this.setContent(span, "output.value", value),
267
+ setUsage: (usage) => this.setUsage(span, usage),
181
268
  };
182
- return this.storage.run({ ...active, context: spanContext }, async () => {
269
+ const execute = async () => {
183
270
  // Setup, capture and cleanup have separate failure boundaries from customer code.
184
271
  try {
185
272
  if (this.enabled && !this.closed && options.input !== undefined)
@@ -198,47 +285,168 @@ export class HueClient {
198
285
  finally {
199
286
  span.end();
200
287
  }
201
- });
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());
202
295
  }
203
- async tool(name, input, execute) {
204
- 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 }) => {
205
316
  this.setContent(span, "gen_ai.tool.call.arguments", input);
206
317
  const result = await execute();
207
318
  if (result !== undefined)
208
319
  this.setContent(span, "gen_ai.tool.call.result", result);
209
320
  return result;
210
- }, { attributes: { "gen_ai.operation.name": "execute_tool", "gen_ai.tool.name": name } });
321
+ }, {
322
+ attributes,
323
+ ...(options.parentContext ? { parentContext: options.parentContext } : {}),
324
+ });
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
+ },
374
+ });
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
+ }
211
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
+ */
212
424
  recordError(span, error) {
213
425
  if (!this.enabled || this.closed)
214
426
  return;
215
427
  try {
216
- const type = error instanceof Error ? error.name : "Error";
217
- span.setStatus({
218
- code: SpanStatusCode.ERROR,
219
- ...(this.captureContent
220
- ? { message: error instanceof Error ? error.message : "Operation failed" }
221
- : {}),
222
- });
223
- span.addEvent("exception", {
224
- "exception.type": type,
225
- ...(this.captureContent && error instanceof Error
226
- ? {
227
- "exception.message": error.message,
228
- ...(error.stack ? { "exception.stacktrace": error.stack } : {}),
229
- }
230
- : {}),
231
- });
428
+ const type = errorType(error);
429
+ span.setAttribute("error.type", type);
430
+ span.setStatus({ code: SpanStatusCode.ERROR });
431
+ span.addEvent("exception", { "exception.type": type });
232
432
  }
233
433
  catch {
234
434
  this.transport.instrumentationFailure();
235
435
  }
236
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
+ */
237
444
  recordMessages(messages, explicitContext) {
238
445
  if (!this.enabled || this.closed || !this.captureContent)
239
446
  return;
240
447
  try {
241
- const active = explicitContext ?? this.getContext();
448
+ const store = this.storage.getStore();
449
+ const active = explicitContext ?? store?.context ?? context.active();
242
450
  if (!trace.getSpanContext(active))
243
451
  throw new Error("Message records require an active span or explicit span context");
244
452
  const body = {};
@@ -246,10 +454,29 @@ export class HueClient {
246
454
  body["gen_ai.input.messages"] = messages.input;
247
455
  if (messages.output !== undefined)
248
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);
249
475
  this.logger.emit({
250
476
  context: active,
251
477
  severityNumber: SeverityNumber.INFO,
252
478
  eventName: "gen_ai.client.inference.operation.details",
479
+ attributes,
253
480
  body: JSON.parse(encodeContent(body)),
254
481
  });
255
482
  }
@@ -267,6 +494,13 @@ export class HueClient {
267
494
  this.transport.instrumentationFailure();
268
495
  }
269
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
+ */
270
504
  async checkConnection() {
271
505
  if (!this.enabled)
272
506
  throw new HueConnectionError("Hue telemetry is disabled");
@@ -279,8 +513,8 @@ export class HueClient {
279
513
  signal: AbortSignal.timeout(options.timeoutMillis),
280
514
  });
281
515
  }
282
- catch {
283
- 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 });
284
518
  }
285
519
  if (!response.ok) {
286
520
  await response.body?.cancel();
@@ -322,8 +556,10 @@ export class HueClient {
322
556
  slug: fields.slug,
323
557
  };
324
558
  }
325
- catch {
326
- throw new HueConnectionError("Hue returned an invalid project response");
559
+ catch (error) {
560
+ throw new HueConnectionError("Hue returned an invalid project response", undefined, {
561
+ cause: error,
562
+ });
327
563
  }
328
564
  }
329
565
  /** Production lifecycle path: never rejects; timeouts do not cancel borrowed provider work. */
@@ -382,6 +618,13 @@ export class HueClient {
382
618
  clearTimeout(timer);
383
619
  }
384
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
+ */
385
628
  flush() {
386
629
  // Each caller needs a drain after its own preceding span/log emissions.
387
630
  // Joining an earlier drain can acknowledge records that were not in its batch.
@@ -413,6 +656,12 @@ export class HueClient {
413
656
  this.transport.issue(index === 0 ? "traces" : "logs", "failed", 0, "OpenTelemetry provider flush failed");
414
657
  return this.transport.flush();
415
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
+ */
416
665
  shutdown() {
417
666
  this.shutdownPromise ??= (async () => {
418
667
  this.closed = true;
@@ -432,17 +681,41 @@ export class HueClient {
432
681
  return this.shutdownPromise;
433
682
  }
434
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
+ */
435
691
  export function createHue(options) {
436
692
  return new HueClient(options);
437
693
  }
438
- /** Fail-open initialization for production; strict createHue remains available for setup/CI. */
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
+ */
439
698
  export function createHueSafe(options) {
440
699
  try {
441
700
  return new HueClient(options);
442
701
  }
443
- catch {
444
- const disabled = new HueClient({ enabled: false, captureContent: false });
445
- disabled.transport.instrumentationFailure();
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)}`);
446
707
  return disabled;
447
708
  }
448
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
+ }
package/dist/config.d.ts CHANGED
@@ -1,4 +1,13 @@
1
- import type { HueOptions } from "./types.js";
1
+ import type { HueOptions, SharedHueOptions } from "./types.js";
2
2
  export declare const MAX_BODY_BYTES: number;
3
3
  export declare const MAX_CONTENT_BYTES: number;
4
- export declare function validateOptions(options: HueOptions): Required<Pick<HueOptions, "apiKey" | "serviceName" | "baseUrl" | "captureContent" | "timeoutMillis" | "maxQueueBytes">> & HueOptions;
4
+ /** Loopback hostnames that may use plain HTTP without opting in. */
5
+ export declare function isLoopbackHost(hostname: string): boolean;
6
+ /** True when a validated origin exports over plain HTTP to a host other than loopback. */
7
+ export declare function isInsecureOrigin(baseUrl: string): boolean;
8
+ export declare function validateOptions(options: HueOptions): HueOptions & Required<Pick<SharedHueOptions, "captureContent" | "baseUrl" | "timeoutMillis" | "maxQueueBytes">> & {
9
+ /** Project key after validation; empty for a disabled client. */
10
+ apiKey: string;
11
+ /** Service name after validation; `hue-disabled` for a disabled client. */
12
+ serviceName: string;
13
+ };
package/dist/config.js CHANGED
@@ -1,13 +1,29 @@
1
1
  export const MAX_BODY_BYTES = 1024 * 1024;
2
2
  export const MAX_CONTENT_BYTES = 256 * 1024;
3
+ /** Loopback hostnames that may use plain HTTP without opting in. */
4
+ export function isLoopbackHost(hostname) {
5
+ return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "[::1]";
6
+ }
7
+ /** True when a validated origin exports over plain HTTP to a host other than loopback. */
8
+ export function isInsecureOrigin(baseUrl) {
9
+ const url = new URL(baseUrl);
10
+ return url.protocol === "http:" && !isLoopbackHost(url.hostname);
11
+ }
12
+ // The return type stays anonymous: HueTransport.options exposes it through ReturnType, and the
13
+ // API reference must not reference a name that is not part of the public entry points.
3
14
  export function validateOptions(options) {
4
- if (typeof options.captureContent !== "boolean")
5
- throw new TypeError("Choose captureContent explicitly: true or false");
6
15
  if (options.enabled !== undefined && typeof options.enabled !== "boolean")
7
16
  throw new TypeError("enabled must be a boolean");
8
- if (options.enabled === false)
17
+ if (options.enabled === false) {
18
+ // The kill switch exports nothing, so the content decision defaults to metadata-only. The
19
+ // diagnostics hook stays attached so a disabled client can still report why it is off.
20
+ if (options.captureContent !== undefined && typeof options.captureContent !== "boolean")
21
+ throw new TypeError("captureContent must be a boolean");
9
22
  return {
10
- captureContent: options.captureContent,
23
+ ...(typeof options.onExportIssue === "function"
24
+ ? { onExportIssue: options.onExportIssue }
25
+ : {}),
26
+ captureContent: options.captureContent ?? false,
11
27
  enabled: false,
12
28
  apiKey: "",
13
29
  serviceName: "hue-disabled",
@@ -15,6 +31,9 @@ export function validateOptions(options) {
15
31
  timeoutMillis: 10000,
16
32
  maxQueueBytes: 8 * 1024 * 1024,
17
33
  };
34
+ }
35
+ if (typeof options.captureContent !== "boolean")
36
+ throw new TypeError("Choose captureContent explicitly: true or false");
18
37
  if (typeof options.apiKey !== "string" ||
19
38
  !options.apiKey ||
20
39
  options.apiKey.length > 4096 ||
@@ -25,6 +44,13 @@ export function validateOptions(options) {
25
44
  !options.serviceName.trim() ||
26
45
  options.serviceName.length > 256)
27
46
  throw new TypeError("A serviceName of 1–256 characters is required");
47
+ if (options.allowInsecureHttp !== undefined && typeof options.allowInsecureHttp !== "boolean")
48
+ throw new TypeError("allowInsecureHttp must be a boolean");
49
+ if (options.resourceAttributes !== undefined &&
50
+ (options.resourceAttributes === null ||
51
+ typeof options.resourceAttributes !== "object" ||
52
+ Array.isArray(options.resourceAttributes)))
53
+ throw new TypeError("resourceAttributes must be an object of attribute values");
28
54
  let url;
29
55
  try {
30
56
  url = new URL(options.baseUrl ?? "https://app.hue.run");
@@ -32,9 +58,12 @@ export function validateOptions(options) {
32
58
  catch {
33
59
  throw new TypeError("Invalid Hue baseUrl");
34
60
  }
35
- const loopback = url.hostname === "localhost" || url.hostname === "127.0.0.1" || url.hostname === "[::1]";
36
- if (url.protocol !== "https:" && !(url.protocol === "http:" && loopback))
37
- throw new TypeError("Hue requires HTTPS except for a loopback development server");
61
+ if (url.protocol !== "https:" && url.protocol !== "http:")
62
+ throw new TypeError("Hue baseUrl must use https, or http for a loopback development server");
63
+ if (url.protocol === "http:" &&
64
+ !isLoopbackHost(url.hostname) &&
65
+ options.allowInsecureHttp !== true)
66
+ throw new TypeError("Hue requires HTTPS except for a loopback development server; set allowInsecureHttp: true to export over plain HTTP to another host");
38
67
  if (url.username || url.password || url.pathname !== "/" || url.search || url.hash)
39
68
  throw new TypeError("Hue baseUrl must be an origin without credentials, a path, query parameters or fragments");
40
69
  const timeoutMillis = options.timeoutMillis ?? 10000;