@hue-run/sdk 0.6.0 → 0.7.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/CLI.md +6 -5
- package/README.md +32 -1
- package/dist/client.js +3 -2
- package/dist/config.d.ts +3 -1
- package/dist/config.js +8 -1
- package/dist/live-spans.d.ts +25 -0
- package/dist/live-spans.js +115 -0
- package/dist/transport.d.ts +17 -0
- package/dist/transport.js +390 -61
- package/dist/types.d.ts +7 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
package/CLI.md
CHANGED
|
@@ -42,8 +42,8 @@ project manifest are refused because managers can update ancestor locks; Python
|
|
|
42
42
|
The generated `hue.setup.mjs` or `hue_setup.py` always selects `captureContent: false` /
|
|
43
43
|
`capture_content=False`. For a supported application, setup installs the dependency and adds the
|
|
44
44
|
managed import and middleware registration to the existing entrypoint; an unreferenced helper is
|
|
45
|
-
not a completed integration. TypeScript uses `@hue-run/sdk@0.
|
|
46
|
-
`@opentelemetry/context-async-hooks@2.11.0`; Python uses
|
|
45
|
+
not a completed integration. TypeScript uses `@hue-run/sdk@0.7.0`, `@opentelemetry/api@1.9.1` and
|
|
46
|
+
`@opentelemetry/context-async-hooks@2.11.0`; Python setup uses its separately tested package pin.
|
|
47
47
|
Content capture requires an ordinary account-managed key and a later explicit application decision.
|
|
48
48
|
|
|
49
49
|
The generated bootstrap supplies standard active SERVER-span context across asynchronous/streaming
|
|
@@ -231,7 +231,8 @@ capability `setup_telemetry_write`.
|
|
|
231
231
|
Normal project credentials and unknown token shapes are refused in setup responses and private
|
|
232
232
|
managed state. These credentials authorize metadata-only OTLP at `/api/v1/otlp/v1/traces` and exact
|
|
233
233
|
content-free receipt verification at `/api/v1/setup/traces/{traceId}/receipt`. They do not authorize
|
|
234
|
-
generic project, receipt, evaluation, log or browsing APIs.
|
|
234
|
+
generic project, receipt, evaluation, log or browsing APIs. The Python package version
|
|
235
|
+
pinned by setup can export with the setup
|
|
235
236
|
credential; the CLI verifies the dedicated setup receipt instead of Python's generic receipt helper.
|
|
236
237
|
|
|
237
238
|
Each command uses bounded timeouts and retries. Provisioning records at most five attempts per local
|
|
@@ -300,7 +301,7 @@ node packages/sdk-typescript/scripts/verify-package.mjs --artifacts-dir .artifac
|
|
|
300
301
|
# Set project to an existing supported fixture; use the same directory on resume.
|
|
301
302
|
project=/absolute/path/to/supported-fixture
|
|
302
303
|
node packages/sdk-typescript/scripts/verify-setup-live.mjs \
|
|
303
|
-
--archive .artifacts/typescript/hue-run-sdk-0.
|
|
304
|
+
--archive .artifacts/typescript/hue-run-sdk-0.7.0.tgz \
|
|
304
305
|
--origin https://STAGING_ORIGIN \
|
|
305
306
|
--project "$project" --command setup \
|
|
306
307
|
--evidence .context/setup-staging-before-claim.json
|
|
@@ -311,7 +312,7 @@ the private local handoff and finish the real browser claim, then reconcile the
|
|
|
311
312
|
|
|
312
313
|
```sh
|
|
313
314
|
node packages/sdk-typescript/scripts/verify-setup-live.mjs \
|
|
314
|
-
--archive .artifacts/typescript/hue-run-sdk-0.
|
|
315
|
+
--archive .artifacts/typescript/hue-run-sdk-0.7.0.tgz \
|
|
315
316
|
--origin https://STAGING_ORIGIN \
|
|
316
317
|
--project "$project" --command claim \
|
|
317
318
|
--evidence .context/setup-staging-after-claim.json
|
package/README.md
CHANGED
|
@@ -329,7 +329,8 @@ const hue = createHue({
|
|
|
329
329
|
|
|
330
330
|
## Delivery behavior
|
|
331
331
|
|
|
332
|
-
Exports
|
|
332
|
+
Exports retry temporary HTTP/network failures (429, 502, 503, 504 and connection errors,
|
|
333
|
+
honoring `Retry-After`) within the export timeout, by OpenTelemetry's OTLP/HTTP exporter rules. Each
|
|
333
334
|
request is limited to 1 MiB before gzip (with space reserved for gzip overhead) and each content value to 256 KiB. Batches
|
|
334
335
|
split at record boundaries. Each signal queues at most 2,048 records, including
|
|
335
336
|
exports in flight; overflow is reported through the callback, counters and next
|
|
@@ -349,6 +350,36 @@ drain, including records emitted before its call. Stop request production
|
|
|
349
350
|
before shutdown so late spans cannot race it. A client does not own instrumented
|
|
350
351
|
operations still running in the application.
|
|
351
352
|
|
|
353
|
+
## Live spans
|
|
354
|
+
|
|
355
|
+
OpenTelemetry exports a span only when it ends, so a long streamed turn would otherwise stay
|
|
356
|
+
invisible until it finishes. When a Hue or AI span is still open at the transport's next 500 ms
|
|
357
|
+
tick, the transport queues a placeholder: an ordinary OTLP span whose parent is the running span,
|
|
358
|
+
with its name, kind, start time and current attributes, an end time of 0,
|
|
359
|
+
`hue.span_type = "pending_span"` and `hue.pending_parent_id` (the running span's own parent,
|
|
360
|
+
omitted for a root). Hue shows the span as running and replaces the placeholder when the real span
|
|
361
|
+
arrives. A placeholder whose span has ended by the time it is exported is not sent.
|
|
362
|
+
|
|
363
|
+
- Only spans from the client's tracer (`withSpan`, `tool`, `model`, `hue.tracer` and the AI SDK
|
|
364
|
+
adapters) and spans with a `gen_ai.`, `ai.`, `llm.` or `traceloop.` attribute at start, or a
|
|
365
|
+
name starting with `ai.`, are announced. HTTP, database and other framework spans are not.
|
|
366
|
+
- Placeholder attributes follow `captureContent` and `redact` like the real span. Tool
|
|
367
|
+
definitions, system instructions and any value over 64 KiB are left out.
|
|
368
|
+
- Placeholders are advisory. They are queued only while the queue is under a quarter of its
|
|
369
|
+
record and byte budgets, and skipped silently otherwise. While queued they count in
|
|
370
|
+
`pendingSpans` and `pendingBytes`, but never as accepted, rejected, failed or dropped records.
|
|
371
|
+
Losing only placeholders records a warning and does not make `flush()` throw.
|
|
372
|
+
- A Hue server that accepts placeholders sends `Hue-Pending-Spans: 1` on trace acknowledgements.
|
|
373
|
+
When a response to a request carrying placeholders lacks it, the receiver predates them: the
|
|
374
|
+
transport attributes up to one rejection per placeholder to them, records one warning and stops
|
|
375
|
+
sending placeholders for that client. Other rejections count against real spans as usual.
|
|
376
|
+
- Opt out with `liveSpans: false`. Setup credentials never send placeholders.
|
|
377
|
+
- With an existing provider, announcements start in `spanProcessor.onStart`. A wrapping processor
|
|
378
|
+
that forwards `onStart` should forward `onEnd` for the same spans: a span that ends without
|
|
379
|
+
reaching Hue is forgotten at the next tick, but a placeholder already sent keeps it showing as
|
|
380
|
+
running until Hue marks the trace stalled. The filtering wrapper above forwards no starts, so it
|
|
381
|
+
sends no placeholders.
|
|
382
|
+
|
|
352
383
|
## Verify a stored application trace
|
|
353
384
|
|
|
354
385
|
After exercising a real application request and finishing its stream, flush the
|
package/dist/client.js
CHANGED
|
@@ -6,6 +6,7 @@ import { W3CTraceContextPropagator } from "@opentelemetry/core";
|
|
|
6
6
|
import { LoggerProvider } from "@opentelemetry/sdk-logs";
|
|
7
7
|
import { TracerProvider } from "@opentelemetry/sdk-trace";
|
|
8
8
|
import { defaultResource, resourceFromAttributes } from "@opentelemetry/resources";
|
|
9
|
+
import { HUE_SCOPE } from "./config.js";
|
|
9
10
|
import { encodeContent, noopSpan, safeSpan } from "./safety.js";
|
|
10
11
|
import { createHueTransport, HueExportError } from "./transport.js";
|
|
11
12
|
import { verifyTrace } from "./receipt.js";
|
|
@@ -197,8 +198,8 @@ export class HueClient {
|
|
|
197
198
|
}
|
|
198
199
|
this.captureContent = this.transport.options.captureContent;
|
|
199
200
|
this.enabled = this.transport.options.enabled !== false;
|
|
200
|
-
this.tracer = new ContextualTracer(this.tracerProvider.getTracer(
|
|
201
|
-
this.logger = this.loggerProvider.getLogger(
|
|
201
|
+
this.tracer = new ContextualTracer(this.tracerProvider.getTracer(HUE_SCOPE, sdkVersion), this.storage, () => this.enabled && !this.closed, () => this.transport.instrumentationFailure());
|
|
202
|
+
this.logger = this.loggerProvider.getLogger(HUE_SCOPE, sdkVersion);
|
|
202
203
|
}
|
|
203
204
|
/**
|
|
204
205
|
* Verifies that Hue stored a trace by ID, optionally waiting for expected span IDs and normalized
|
package/dist/config.d.ts
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
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
|
+
/** Instrumentation scope of the client's own tracer and logger. */
|
|
5
|
+
export declare const HUE_SCOPE = "@hue-run/sdk";
|
|
4
6
|
/** Loopback hostnames that may use plain HTTP without opting in. */
|
|
5
7
|
export declare function isLoopbackHost(hostname: string): boolean;
|
|
6
8
|
/** True when a validated origin exports over plain HTTP to a host other than loopback. */
|
|
7
9
|
export declare function isInsecureOrigin(baseUrl: string): boolean;
|
|
8
|
-
export declare function validateOptions(options: HueOptions): HueOptions & Required<Pick<SharedHueOptions, "captureContent" | "baseUrl" | "timeoutMillis" | "maxQueueBytes">> & {
|
|
10
|
+
export declare function validateOptions(options: HueOptions): HueOptions & Required<Pick<SharedHueOptions, "captureContent" | "baseUrl" | "timeoutMillis" | "maxQueueBytes" | "liveSpans">> & {
|
|
9
11
|
/** Project key after validation; empty for a disabled client. */
|
|
10
12
|
apiKey: string;
|
|
11
13
|
/** Service name after validation; `hue-disabled` for a disabled client. */
|
package/dist/config.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
export const MAX_BODY_BYTES = 1024 * 1024;
|
|
2
2
|
export const MAX_CONTENT_BYTES = 256 * 1024;
|
|
3
|
+
/** Instrumentation scope of the client's own tracer and logger. */
|
|
4
|
+
export const HUE_SCOPE = "@hue-run/sdk";
|
|
3
5
|
/** Loopback hostnames that may use plain HTTP without opting in. */
|
|
4
6
|
export function isLoopbackHost(hostname) {
|
|
5
7
|
return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "[::1]";
|
|
@@ -30,6 +32,7 @@ export function validateOptions(options) {
|
|
|
30
32
|
baseUrl: "https://app.hue.run",
|
|
31
33
|
timeoutMillis: 10000,
|
|
32
34
|
maxQueueBytes: 8 * 1024 * 1024,
|
|
35
|
+
liveSpans: false,
|
|
33
36
|
};
|
|
34
37
|
}
|
|
35
38
|
if (typeof options.captureContent !== "boolean")
|
|
@@ -74,5 +77,9 @@ export function validateOptions(options) {
|
|
|
74
77
|
maxQueueBytes < 1024 ||
|
|
75
78
|
maxQueueBytes > 64 * 1024 * 1024)
|
|
76
79
|
throw new TypeError("maxQueueBytes must be 1024–67108864");
|
|
77
|
-
|
|
80
|
+
if (options.liveSpans !== undefined && typeof options.liveSpans !== "boolean")
|
|
81
|
+
throw new TypeError("liveSpans must be a boolean");
|
|
82
|
+
// Setup credentials send installer telemetry only, never in-progress placeholders.
|
|
83
|
+
const liveSpans = options.liveSpans !== false && !options.apiKey.startsWith("hue_setup_");
|
|
84
|
+
return { ...options, baseUrl: url.origin, timeoutMillis, maxQueueBytes, liveSpans };
|
|
78
85
|
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { type Attributes } from "@opentelemetry/api";
|
|
2
|
+
import { type ReadableSpan } from "@opentelemetry/sdk-trace";
|
|
3
|
+
/**
|
|
4
|
+
* A finished span never carries the placeholder markers, whatever an application set: Hue would
|
|
5
|
+
* read such a span as a malformed placeholder and reject it.
|
|
6
|
+
*/
|
|
7
|
+
export declare function withoutPlaceholderMarkers(attributes: Attributes): Attributes;
|
|
8
|
+
/** Open spans tracked for announcement at once; later starts are not announced. */
|
|
9
|
+
export declare const MAX_LIVE_SPANS = 1024;
|
|
10
|
+
export declare const LIVE_SPAN_INTERVAL_MILLIS = 500;
|
|
11
|
+
/**
|
|
12
|
+
* Response header on every trace acknowledgement from a Hue that accepts placeholders. Without
|
|
13
|
+
* it the receiver predates them and rejects each one by its zero end time.
|
|
14
|
+
*/
|
|
15
|
+
export declare const PLACEHOLDERS_HEADER = "hue-pending-spans";
|
|
16
|
+
/** Hue's own spans and recognizable AI spans, judged from what is known when the span starts. */
|
|
17
|
+
export declare function announcesLiveSpan(span: ReadableSpan): boolean;
|
|
18
|
+
/**
|
|
19
|
+
* A normal OTLP span announcing `span` while it runs: a new identity whose parent is the real
|
|
20
|
+
* span, the real start time and an end time of 0. The markers are added after redaction.
|
|
21
|
+
*/
|
|
22
|
+
export declare function pendingPlaceholder(span: ReadableSpan): {
|
|
23
|
+
record: ReadableSpan;
|
|
24
|
+
markers: Attributes;
|
|
25
|
+
};
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { SpanStatusCode } from "@opentelemetry/api";
|
|
2
|
+
import { RandomIdGenerator } from "@opentelemetry/sdk-trace";
|
|
3
|
+
import { HUE_SCOPE } from "./config.js";
|
|
4
|
+
/** Marks a placeholder for a span that has started but not ended. The value versions the shape. */
|
|
5
|
+
const PENDING_SPAN_TYPE_KEY = "hue.span_type";
|
|
6
|
+
const PENDING_SPAN_TYPE = "pending_span";
|
|
7
|
+
/** The real span's own parent. Absent when the real span is a root. */
|
|
8
|
+
const PENDING_PARENT_KEY = "hue.pending_parent_id";
|
|
9
|
+
const markerKeys = [PENDING_SPAN_TYPE_KEY, PENDING_PARENT_KEY];
|
|
10
|
+
/**
|
|
11
|
+
* A finished span never carries the placeholder markers, whatever an application set: Hue would
|
|
12
|
+
* read such a span as a malformed placeholder and reject it.
|
|
13
|
+
*/
|
|
14
|
+
export function withoutPlaceholderMarkers(attributes) {
|
|
15
|
+
if (!markerKeys.some((key) => Object.hasOwn(attributes, key)))
|
|
16
|
+
return attributes;
|
|
17
|
+
const kept = { ...attributes };
|
|
18
|
+
for (const key of markerKeys)
|
|
19
|
+
delete kept[key];
|
|
20
|
+
return kept;
|
|
21
|
+
}
|
|
22
|
+
/** Open spans tracked for announcement at once; later starts are not announced. */
|
|
23
|
+
export const MAX_LIVE_SPANS = 1024;
|
|
24
|
+
export const LIVE_SPAN_INTERVAL_MILLIS = 500;
|
|
25
|
+
/**
|
|
26
|
+
* Response header on every trace acknowledgement from a Hue that accepts placeholders. Without
|
|
27
|
+
* it the receiver predates them and rejects each one by its zero end time.
|
|
28
|
+
*/
|
|
29
|
+
export const PLACEHOLDERS_HEADER = "hue-pending-spans";
|
|
30
|
+
const MAX_PLACEHOLDER_VALUE_BYTES = 64 * 1024;
|
|
31
|
+
// Kept narrow on purpose: common application processors export only these spans, and a
|
|
32
|
+
// placeholder whose real span is filtered later would read as running until the trace stalls.
|
|
33
|
+
const livePrefixes = ["gen_ai.", "ai.", "llm.", "traceloop."];
|
|
34
|
+
// Definitions and instructions are large and rarely useful while a span runs; the real span
|
|
35
|
+
// still carries them. Copied markers would misplace the placeholder.
|
|
36
|
+
const omittedKeys = [
|
|
37
|
+
"gen_ai.tool.definitions",
|
|
38
|
+
"gen_ai.system_instructions",
|
|
39
|
+
PENDING_SPAN_TYPE_KEY,
|
|
40
|
+
PENDING_PARENT_KEY,
|
|
41
|
+
];
|
|
42
|
+
const ids = new RandomIdGenerator();
|
|
43
|
+
/** Hue's own spans and recognizable AI spans, judged from what is known when the span starts. */
|
|
44
|
+
export function announcesLiveSpan(span) {
|
|
45
|
+
if (span.instrumentationScope.name === HUE_SCOPE || span.name.startsWith("ai."))
|
|
46
|
+
return true;
|
|
47
|
+
for (const key in span.attributes)
|
|
48
|
+
if (livePrefixes.some((prefix) => key.startsWith(prefix)))
|
|
49
|
+
return true;
|
|
50
|
+
return false;
|
|
51
|
+
}
|
|
52
|
+
function valueBytes(value) {
|
|
53
|
+
if (typeof value === "string")
|
|
54
|
+
return Buffer.byteLength(value);
|
|
55
|
+
if (!Array.isArray(value))
|
|
56
|
+
return 8;
|
|
57
|
+
let bytes = 0;
|
|
58
|
+
for (const item of value)
|
|
59
|
+
bytes += typeof item === "string" ? Buffer.byteLength(item) : 8;
|
|
60
|
+
return bytes;
|
|
61
|
+
}
|
|
62
|
+
function placeholderAttributes(source) {
|
|
63
|
+
const attributes = {};
|
|
64
|
+
for (const key in source) {
|
|
65
|
+
if (!Object.hasOwn(source, key))
|
|
66
|
+
continue;
|
|
67
|
+
if (omittedKeys.some((omitted) => key === omitted || key.startsWith(`${omitted}.`)))
|
|
68
|
+
continue;
|
|
69
|
+
const value = source[key];
|
|
70
|
+
if (valueBytes(value) <= MAX_PLACEHOLDER_VALUE_BYTES)
|
|
71
|
+
attributes[key] = value;
|
|
72
|
+
}
|
|
73
|
+
return attributes;
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* A normal OTLP span announcing `span` while it runs: a new identity whose parent is the real
|
|
77
|
+
* span, the real start time and an end time of 0. The markers are added after redaction.
|
|
78
|
+
*/
|
|
79
|
+
export function pendingPlaceholder(span) {
|
|
80
|
+
const real = span.spanContext();
|
|
81
|
+
const own = {
|
|
82
|
+
traceId: real.traceId,
|
|
83
|
+
spanId: ids.generateSpanId(),
|
|
84
|
+
traceFlags: real.traceFlags,
|
|
85
|
+
...(real.traceState ? { traceState: real.traceState } : {}),
|
|
86
|
+
};
|
|
87
|
+
const parent = span.parentSpanContext?.spanId;
|
|
88
|
+
const record = {
|
|
89
|
+
name: span.name,
|
|
90
|
+
kind: span.kind,
|
|
91
|
+
spanContext: () => own,
|
|
92
|
+
// Hue re-keys a placeholder as its real span, so the flags describe the real span's parent.
|
|
93
|
+
parentSpanContext: { ...real, isRemote: span.parentSpanContext?.isRemote === true },
|
|
94
|
+
startTime: span.startTime,
|
|
95
|
+
endTime: [0, 0],
|
|
96
|
+
duration: [0, 0],
|
|
97
|
+
ended: true,
|
|
98
|
+
status: { code: SpanStatusCode.UNSET },
|
|
99
|
+
attributes: placeholderAttributes(span.attributes),
|
|
100
|
+
links: [],
|
|
101
|
+
events: [],
|
|
102
|
+
resource: span.resource,
|
|
103
|
+
instrumentationScope: span.instrumentationScope,
|
|
104
|
+
droppedAttributesCount: 0,
|
|
105
|
+
droppedEventsCount: 0,
|
|
106
|
+
droppedLinksCount: 0,
|
|
107
|
+
};
|
|
108
|
+
return {
|
|
109
|
+
record,
|
|
110
|
+
markers: {
|
|
111
|
+
[PENDING_SPAN_TYPE_KEY]: PENDING_SPAN_TYPE,
|
|
112
|
+
...(parent ? { [PENDING_PARENT_KEY]: parent } : {}),
|
|
113
|
+
},
|
|
114
|
+
};
|
|
115
|
+
}
|
package/dist/transport.d.ts
CHANGED
|
@@ -50,8 +50,25 @@ export declare class HueTransport {
|
|
|
50
50
|
private closed;
|
|
51
51
|
private shutdownPromise?;
|
|
52
52
|
private flushPromise?;
|
|
53
|
+
private live;
|
|
54
|
+
private liveTimer?;
|
|
55
|
+
private liveSpans;
|
|
56
|
+
private placeholdersRejected;
|
|
57
|
+
private placeholders;
|
|
58
|
+
private placeholderSources;
|
|
59
|
+
private batchSpans?;
|
|
53
60
|
constructor(options: HueOptions);
|
|
61
|
+
/**
|
|
62
|
+
* Advisory records (placeholders) are admitted only while the queue is under a quarter of its
|
|
63
|
+
* record and byte budgets, so they never take more than a quarter from real records. They are
|
|
64
|
+
* skipped silently.
|
|
65
|
+
*/
|
|
54
66
|
private enqueue;
|
|
67
|
+
private track;
|
|
68
|
+
/** Placeholders are built lazily, so input set right after a span starts is included. */
|
|
69
|
+
private announceLiveSpans;
|
|
70
|
+
private stopLiveTimer;
|
|
71
|
+
private stopLiveSpans;
|
|
55
72
|
/** Cumulative counters and current queue gauges. */
|
|
56
73
|
getReport(): ExportReport;
|
|
57
74
|
/** Copies of the latest 128 sanitized issues, oldest first. */
|
package/dist/transport.js
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
|
+
import { gzip } from "node:zlib";
|
|
1
2
|
import { ExportResultCode } from "@opentelemetry/core";
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
3
|
+
import { createOtlpNetworkExportDelegate, OTLPExporterBase, OTLPExporterError, } from "@opentelemetry/otlp-exporter-base";
|
|
4
|
+
import { createOtlpHttpExporterMetrics } from "@opentelemetry/otlp-exporter-base/node-http";
|
|
4
5
|
import { LogsExporterMetricsHelper, ProtobufLogsSerializer, ProtobufTraceSerializer, TraceExporterMetricsHelper, } from "@opentelemetry/otlp-transformer";
|
|
5
6
|
import { BatchSpanProcessor, } from "@opentelemetry/sdk-trace";
|
|
6
7
|
import { BatchLogRecordProcessor, } from "@opentelemetry/sdk-logs";
|
|
7
8
|
import { isInsecureOrigin, MAX_BODY_BYTES, validateOptions } from "./config.js";
|
|
9
|
+
import { announcesLiveSpan, LIVE_SPAN_INTERVAL_MILLIS, MAX_LIVE_SPANS, pendingPlaceholder, PLACEHOLDERS_HEADER, withoutPlaceholderMarkers, } from "./live-spans.js";
|
|
8
10
|
import { estimateRecordBytes } from "./safety.js";
|
|
9
11
|
import { snapshotLog, snapshotSpan } from "./snapshot.js";
|
|
10
12
|
import { redactLog, redactSpan } from "./privacy.js";
|
|
@@ -83,9 +85,20 @@ export class HueTransport {
|
|
|
83
85
|
closed = false;
|
|
84
86
|
shutdownPromise;
|
|
85
87
|
flushPromise;
|
|
88
|
+
// Open spans not yet announced. Each is considered once, at the next tick after it starts.
|
|
89
|
+
live = new Set();
|
|
90
|
+
liveTimer;
|
|
91
|
+
liveSpans;
|
|
92
|
+
placeholdersRejected = false;
|
|
93
|
+
// Admitted placeholder snapshots and the marker attributes added after redaction.
|
|
94
|
+
placeholders = new WeakMap();
|
|
95
|
+
// The running span each admitted placeholder announces, so one it has outlived is not sent.
|
|
96
|
+
placeholderSources = new WeakMap();
|
|
97
|
+
batchSpans;
|
|
86
98
|
constructor(options) {
|
|
87
99
|
this.options = validateOptions(options);
|
|
88
100
|
Object.defineProperty(this, "options", { enumerable: false });
|
|
101
|
+
this.liveSpans = this.options.enabled !== false && this.options.liveSpans;
|
|
89
102
|
this.traceExporter = new ReportingExporter(this, "traces", ProtobufTraceSerializer, TraceExporterMetricsHelper, (span, cache) => redactSpan(span, this.options, cache));
|
|
90
103
|
this.logExporter = new ReportingExporter(this, "logs", ProtobufLogsSerializer, LogsExporterMetricsHelper, (log, cache) => redactLog(log, this.options, cache));
|
|
91
104
|
if (this.options.enabled === false) {
|
|
@@ -103,6 +116,7 @@ export class HueTransport {
|
|
|
103
116
|
};
|
|
104
117
|
const spans = new BatchSpanProcessor({ exporter: this.traceExporter, ...batching });
|
|
105
118
|
const logs = new BatchLogRecordProcessor({ exporter: this.logExporter, ...batching });
|
|
119
|
+
this.batchSpans = spans;
|
|
106
120
|
this.spanProcessor = {
|
|
107
121
|
onStart: (span, parent) => {
|
|
108
122
|
try {
|
|
@@ -111,8 +125,11 @@ export class HueTransport {
|
|
|
111
125
|
catch {
|
|
112
126
|
this.instrumentationFailure();
|
|
113
127
|
}
|
|
128
|
+
this.track(span);
|
|
114
129
|
},
|
|
115
130
|
onEnd: (span) => {
|
|
131
|
+
if (this.live.delete(span) && !this.live.size)
|
|
132
|
+
this.stopLiveTimer();
|
|
116
133
|
let admitted;
|
|
117
134
|
try {
|
|
118
135
|
if (!(span.spanContext().traceFlags & 1))
|
|
@@ -130,7 +147,10 @@ export class HueTransport {
|
|
|
130
147
|
}
|
|
131
148
|
},
|
|
132
149
|
forceFlush: () => spans.forceFlush(),
|
|
133
|
-
shutdown: () =>
|
|
150
|
+
shutdown: () => {
|
|
151
|
+
this.stopLiveSpans();
|
|
152
|
+
return spans.shutdown();
|
|
153
|
+
},
|
|
134
154
|
};
|
|
135
155
|
this.logRecordProcessor = {
|
|
136
156
|
onEmit: (log) => {
|
|
@@ -152,8 +172,15 @@ export class HueTransport {
|
|
|
152
172
|
shutdown: () => logs.shutdown(),
|
|
153
173
|
};
|
|
154
174
|
}
|
|
155
|
-
|
|
175
|
+
/**
|
|
176
|
+
* Advisory records (placeholders) are admitted only while the queue is under a quarter of its
|
|
177
|
+
* record and byte budgets, so they never take more than a quarter from real records. They are
|
|
178
|
+
* skipped silently.
|
|
179
|
+
*/
|
|
180
|
+
enqueue(signal, record, advisory = false) {
|
|
156
181
|
const pending = signal === "traces" ? this.spans : this.logs;
|
|
182
|
+
if (advisory && (this.closed || pending.size >= 2048 / 4))
|
|
183
|
+
return undefined;
|
|
157
184
|
if (this.closed || pending.size >= 2048) {
|
|
158
185
|
this.issue(signal, "dropped", 1, this.closed
|
|
159
186
|
? "Telemetry emitted after transport shutdown"
|
|
@@ -161,7 +188,8 @@ export class HueTransport {
|
|
|
161
188
|
return undefined;
|
|
162
189
|
}
|
|
163
190
|
try {
|
|
164
|
-
const remaining = this.options.maxQueueBytes
|
|
191
|
+
const remaining = (advisory ? this.options.maxQueueBytes / 4 : this.options.maxQueueBytes) -
|
|
192
|
+
this.pendingBytes;
|
|
165
193
|
const snapshot = signal === "traces"
|
|
166
194
|
? snapshotSpan(record, remaining)
|
|
167
195
|
: snapshotLog(record, remaining);
|
|
@@ -170,15 +198,94 @@ export class HueTransport {
|
|
|
170
198
|
this.spans.set(snapshot.record, snapshot.bytes);
|
|
171
199
|
else
|
|
172
200
|
this.logs.set(snapshot.record, snapshot.bytes);
|
|
173
|
-
if (snapshot.unresolvedResource)
|
|
201
|
+
if (snapshot.unresolvedResource && !advisory)
|
|
174
202
|
this.issue(signal, "warning", 0, "Unresolved resource attributes omitted from the telemetry snapshot");
|
|
175
203
|
return snapshot.record;
|
|
176
204
|
}
|
|
177
205
|
catch {
|
|
206
|
+
if (advisory)
|
|
207
|
+
return undefined;
|
|
178
208
|
this.issue(signal, "dropped", 1, "Telemetry snapshot exceeded its byte or complexity budget or contained unsupported data");
|
|
179
209
|
return undefined;
|
|
180
210
|
}
|
|
181
211
|
}
|
|
212
|
+
track(span) {
|
|
213
|
+
try {
|
|
214
|
+
if (!this.liveSpans || this.live.size >= MAX_LIVE_SPANS)
|
|
215
|
+
return;
|
|
216
|
+
if (!(span.spanContext().traceFlags & 1) || !span.isRecording() || !announcesLiveSpan(span))
|
|
217
|
+
return;
|
|
218
|
+
this.live.add(span);
|
|
219
|
+
if (!this.liveTimer) {
|
|
220
|
+
const timer = setInterval(() => this.announceLiveSpans(), LIVE_SPAN_INTERVAL_MILLIS);
|
|
221
|
+
timer.unref();
|
|
222
|
+
this.liveTimer = timer;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
catch {
|
|
226
|
+
// Live announcements are advisory. They never report failures or affect the span.
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
/** Placeholders are built lazily, so input set right after a span starts is included. */
|
|
230
|
+
announceLiveSpans() {
|
|
231
|
+
for (const span of this.live) {
|
|
232
|
+
this.live.delete(span);
|
|
233
|
+
try {
|
|
234
|
+
// Ended without reaching onEnd: a wrapping processor filtered it, so no real span follows.
|
|
235
|
+
if (!this.liveSpans || span.ended || !this.batchSpans)
|
|
236
|
+
continue;
|
|
237
|
+
const { record, markers } = pendingPlaceholder(span);
|
|
238
|
+
const admitted = this.enqueue("traces", record, true);
|
|
239
|
+
if (!admitted)
|
|
240
|
+
continue;
|
|
241
|
+
this.placeholders.set(admitted, markers);
|
|
242
|
+
this.placeholderSources.set(admitted, span);
|
|
243
|
+
try {
|
|
244
|
+
this.batchSpans.onEnd(admitted);
|
|
245
|
+
}
|
|
246
|
+
catch {
|
|
247
|
+
this.finish("traces", [admitted]);
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
catch {
|
|
251
|
+
// Skipped silently, like any placeholder the queue has no room for.
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
if (!this.live.size)
|
|
255
|
+
this.stopLiveTimer();
|
|
256
|
+
}
|
|
257
|
+
stopLiveTimer() {
|
|
258
|
+
clearInterval(this.liveTimer);
|
|
259
|
+
this.liveTimer = undefined;
|
|
260
|
+
}
|
|
261
|
+
stopLiveSpans() {
|
|
262
|
+
this.liveSpans = false;
|
|
263
|
+
this.live.clear();
|
|
264
|
+
this.stopLiveTimer();
|
|
265
|
+
}
|
|
266
|
+
/** @internal Exporter callback: marker attributes when `record` is an admitted placeholder. */
|
|
267
|
+
placeholderMarkers(record) {
|
|
268
|
+
return this.placeholders.get(record);
|
|
269
|
+
}
|
|
270
|
+
/** @internal Exporter callback: whether a placeholder's span has ended, so it announces nothing. */
|
|
271
|
+
placeholderSettled(record) {
|
|
272
|
+
return this.placeholderSources.get(record)?.ended === true;
|
|
273
|
+
}
|
|
274
|
+
/** @internal Exporter callback: false once the receiver refused placeholders; queued ones are then dropped. */
|
|
275
|
+
sendsPlaceholders() {
|
|
276
|
+
return !this.placeholdersRejected;
|
|
277
|
+
}
|
|
278
|
+
/**
|
|
279
|
+
* @internal Exporter callback: a receiver without placeholder support rejects each by its zero
|
|
280
|
+
* end time. Stops announcing for this transport and records one warning.
|
|
281
|
+
*/
|
|
282
|
+
rejectPlaceholders(count) {
|
|
283
|
+
this.stopLiveSpans();
|
|
284
|
+
if (this.placeholdersRejected)
|
|
285
|
+
return;
|
|
286
|
+
this.placeholdersRejected = true;
|
|
287
|
+
this.issue("traces", "warning", count, "This Hue server does not accept in-progress span placeholders; live spans are disabled");
|
|
288
|
+
}
|
|
182
289
|
/** @internal Exporter callback: releases queued records after an export attempt settles. */
|
|
183
290
|
finish(signal, records) {
|
|
184
291
|
for (const record of records) {
|
|
@@ -345,13 +452,52 @@ class ReportingExporter {
|
|
|
345
452
|
.catch(() => { });
|
|
346
453
|
this.pending.add(work);
|
|
347
454
|
}
|
|
455
|
+
/** Real records first, then placeholders whose real span is not already in this batch. */
|
|
456
|
+
ordered(records) {
|
|
457
|
+
if (this.signal !== "traces")
|
|
458
|
+
return records.map((record) => ({ record }));
|
|
459
|
+
const real = [];
|
|
460
|
+
const placeholders = [];
|
|
461
|
+
for (const record of records) {
|
|
462
|
+
const markers = this.transport.placeholderMarkers(record);
|
|
463
|
+
if (markers)
|
|
464
|
+
placeholders.push({ record, markers });
|
|
465
|
+
else
|
|
466
|
+
real.push({ record });
|
|
467
|
+
}
|
|
468
|
+
if (!placeholders.length || !this.transport.sendsPlaceholders())
|
|
469
|
+
return real;
|
|
470
|
+
const ended = new Set(real.map(({ record }) => record.spanContext().spanId));
|
|
471
|
+
// A span that already ended, in this batch or not (even one a wrapping processor filtered),
|
|
472
|
+
// is no longer running, so its placeholder is not sent.
|
|
473
|
+
for (const placeholder of placeholders)
|
|
474
|
+
if (!ended.has(placeholder.record.parentSpanContext?.spanId ?? "") &&
|
|
475
|
+
!this.transport.placeholderSettled(placeholder.record))
|
|
476
|
+
real.push(placeholder);
|
|
477
|
+
return real;
|
|
478
|
+
}
|
|
348
479
|
async exportRecords(records) {
|
|
349
480
|
const accepted = [];
|
|
481
|
+
// Placeholders are advisory: losing one is a warning, never an export failure.
|
|
482
|
+
const placeholders = new Set();
|
|
350
483
|
const cache = new WeakMap();
|
|
351
484
|
let failed = false;
|
|
352
485
|
let redactedBytes = 0;
|
|
486
|
+
const invalid = (placeholder, message) => {
|
|
487
|
+
if (placeholder)
|
|
488
|
+
this.transport.issue(this.signal, "warning", 1, `${message} (in-progress span placeholder)`);
|
|
489
|
+
else {
|
|
490
|
+
failed = true;
|
|
491
|
+
this.transport.issue(this.signal, "invalid", 1, message);
|
|
492
|
+
}
|
|
493
|
+
};
|
|
494
|
+
const send = async (batch) => {
|
|
495
|
+
const count = batch.filter((record) => placeholders.has(record)).length;
|
|
496
|
+
if (!(await this.send(batch, count)))
|
|
497
|
+
failed = true;
|
|
498
|
+
};
|
|
353
499
|
const resourceDeadline = Date.now() + this.transport.options.timeoutMillis;
|
|
354
|
-
for (const record of records) {
|
|
500
|
+
for (const { record, markers } of this.ordered(records)) {
|
|
355
501
|
try {
|
|
356
502
|
const ready = record.resource.waitForAsyncAttributes?.();
|
|
357
503
|
if (ready) {
|
|
@@ -368,17 +514,30 @@ class ReportingExporter {
|
|
|
368
514
|
clearTimeout(timer);
|
|
369
515
|
}
|
|
370
516
|
}
|
|
371
|
-
|
|
517
|
+
let redacted = this.redact(record, cache);
|
|
518
|
+
// Added after redaction, so a redactor cannot alter the markers or the parent identity.
|
|
519
|
+
if (markers)
|
|
520
|
+
redacted = {
|
|
521
|
+
...redacted,
|
|
522
|
+
attributes: { ...redacted.attributes, ...markers },
|
|
523
|
+
};
|
|
524
|
+
else if (this.signal === "traces") {
|
|
525
|
+
const attributes = redacted.attributes;
|
|
526
|
+
const kept = withoutPlaceholderMarkers(attributes);
|
|
527
|
+
if (kept !== attributes)
|
|
528
|
+
redacted = { ...redacted, attributes: kept };
|
|
529
|
+
}
|
|
372
530
|
const bytes = 512 +
|
|
373
531
|
estimateRecordBytes(recordData(redacted, this.signal), this.transport.options.maxQueueBytes - redactedBytes);
|
|
374
532
|
if (redactedBytes + bytes > this.transport.options.maxQueueBytes)
|
|
375
533
|
throw new RangeError("Redacted batch exceeds byte budget");
|
|
376
534
|
redactedBytes += bytes;
|
|
377
535
|
accepted.push(redacted);
|
|
536
|
+
if (markers)
|
|
537
|
+
placeholders.add(redacted);
|
|
378
538
|
}
|
|
379
539
|
catch {
|
|
380
|
-
|
|
381
|
-
this.transport.issue(this.signal, "invalid", 1, "Telemetry record could not be redacted or exceeds supported content limits");
|
|
540
|
+
invalid(markers !== undefined, "Telemetry record could not be redacted or exceeds supported content limits");
|
|
382
541
|
}
|
|
383
542
|
}
|
|
384
543
|
// Each record is encoded once to measure it; a request is encoded once more when it is sent.
|
|
@@ -394,37 +553,41 @@ class ReportingExporter {
|
|
|
394
553
|
recordBytes = this.serializer.serializeRequest([record])?.byteLength ?? Infinity;
|
|
395
554
|
}
|
|
396
555
|
catch {
|
|
397
|
-
|
|
398
|
-
this.transport.issue(this.signal, "invalid", 1, "Telemetry record could not be serialized");
|
|
556
|
+
invalid(placeholders.has(record), "Telemetry record could not be serialized");
|
|
399
557
|
continue;
|
|
400
558
|
}
|
|
401
559
|
const framedBytes = recordBytes + RECORD_FRAMING_BYTES;
|
|
402
560
|
if (batch.length && batchBytes + framedBytes > limit) {
|
|
403
|
-
|
|
404
|
-
failed = true;
|
|
561
|
+
await send(batch);
|
|
405
562
|
batch = [];
|
|
406
563
|
batchBytes = 0;
|
|
407
564
|
}
|
|
408
565
|
if (recordBytes > limit) {
|
|
409
|
-
|
|
410
|
-
this.transport.issue(this.signal, "invalid", 1, "Telemetry record exceeds the 1 MiB request limit");
|
|
566
|
+
invalid(placeholders.has(record), "Telemetry record exceeds the 1 MiB request limit");
|
|
411
567
|
continue;
|
|
412
568
|
}
|
|
413
569
|
batch.push(record);
|
|
414
570
|
batchBytes += framedBytes;
|
|
415
571
|
}
|
|
416
|
-
if (batch.length
|
|
417
|
-
|
|
572
|
+
if (batch.length)
|
|
573
|
+
await send(batch);
|
|
418
574
|
if (failed)
|
|
419
575
|
throw new Error("Hue telemetry export failed");
|
|
420
576
|
}
|
|
421
|
-
|
|
577
|
+
/** Sends one request; `placeholders` of `records` are advisory and never count as lost. */
|
|
578
|
+
async send(records, placeholders = 0) {
|
|
422
579
|
const options = this.transport.options;
|
|
580
|
+
const real = records.length - placeholders;
|
|
581
|
+
// A loss involving only placeholders is a warning. Mixed losses count only real records.
|
|
582
|
+
const lose = (message, status) => {
|
|
583
|
+
this.transport.issue(this.signal, real ? "failed" : "warning", real || placeholders, real ? message : `${message} (in-progress span placeholders only)`, status);
|
|
584
|
+
return !real;
|
|
585
|
+
};
|
|
423
586
|
let rejected = 0;
|
|
424
587
|
let validResponse = true;
|
|
425
588
|
let receivedResponse = false;
|
|
589
|
+
let acceptsPlaceholders = false;
|
|
426
590
|
let expired = false;
|
|
427
|
-
const agents = new Set();
|
|
428
591
|
const deadline = Date.now() + options.timeoutMillis;
|
|
429
592
|
let timer;
|
|
430
593
|
const serializer = {
|
|
@@ -439,17 +602,27 @@ class ReportingExporter {
|
|
|
439
602
|
const count = Number(partial?.[this.signal === "traces" ? "rejectedSpans" : "rejectedLogRecords"] ?? 0);
|
|
440
603
|
if (!Number.isSafeInteger(count) || count < 0 || count > records.length)
|
|
441
604
|
throw new Error("Invalid rejection count");
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
605
|
+
// A receiver that accepts placeholders never rejects them for being placeholders, so its
|
|
606
|
+
// rejections count as before. One without the header predates them and rejects each.
|
|
607
|
+
const downgrade = placeholders > 0 && !acceptsPlaceholders;
|
|
608
|
+
const placeholderRejections = downgrade ? Math.min(count, placeholders) : 0;
|
|
609
|
+
if (downgrade)
|
|
610
|
+
this.transport.rejectPlaceholders(placeholderRejections);
|
|
611
|
+
const remaining = count - placeholderRejections;
|
|
612
|
+
// Rejections are not matched to records. Attribute them to real records first.
|
|
613
|
+
rejected = Math.min(remaining, real);
|
|
614
|
+
if (remaining || (partial?.errorMessage && !downgrade))
|
|
615
|
+
this.transport.issue(this.signal, rejected ? "rejected" : "warning", rejected || remaining, rejected
|
|
445
616
|
? "Hue rejected telemetry records; inspect the project ingestion settings and supported limits"
|
|
446
|
-
:
|
|
617
|
+
: remaining
|
|
618
|
+
? "Hue rejected in-progress span placeholders"
|
|
619
|
+
: "Hue returned an ingestion warning");
|
|
447
620
|
// Do not pass backend error text or raw response bytes into the global OTel diagnostic logger.
|
|
448
621
|
return {};
|
|
449
622
|
}
|
|
450
623
|
catch {
|
|
451
624
|
validResponse = false;
|
|
452
|
-
|
|
625
|
+
lose("Hue returned an invalid OTLP acknowledgement; acceptance is uncertain");
|
|
453
626
|
return {};
|
|
454
627
|
}
|
|
455
628
|
},
|
|
@@ -457,64 +630,44 @@ class ReportingExporter {
|
|
|
457
630
|
const endpoint = `${options.baseUrl}/api/v1/otlp/v1/${this.signal}`;
|
|
458
631
|
// Explicit configuration only. OTEL_EXPORTER_OTLP_* environment variables are meant
|
|
459
632
|
// for generic exporters; merging them here could send another vendor's headers to Hue.
|
|
460
|
-
const
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
timeoutMillis: options.timeoutMillis,
|
|
469
|
-
concurrencyLimit: 1,
|
|
470
|
-
compression: CompressionAlgorithm.GZIP,
|
|
471
|
-
agentFactory: async (protocol) => {
|
|
472
|
-
if (expired || Date.now() >= deadline)
|
|
473
|
-
throw new Error("Hue export deadline exceeded");
|
|
474
|
-
const { Agent } = await import(protocol === "https:" ? "node:https" : "node:http");
|
|
475
|
-
const agent = new Agent({ keepAlive: false });
|
|
476
|
-
agents.add(agent);
|
|
477
|
-
if (expired)
|
|
478
|
-
agent.destroy();
|
|
479
|
-
return agent;
|
|
480
|
-
},
|
|
481
|
-
}, serializer, this.signal === "traces" ? "otlp_http_span_exporter" : "otlp_http_log_exporter", this.metrics, undefined);
|
|
633
|
+
const transport = new OtlpHttpTransport(endpoint, {
|
|
634
|
+
"Content-Type": "application/x-protobuf",
|
|
635
|
+
Authorization: `Bearer ${options.apiKey}`,
|
|
636
|
+
"User-Agent": `hue-sdk-typescript/${sdkVersion} ${OTEL_USER_AGENT}`,
|
|
637
|
+
}, (headers) => {
|
|
638
|
+
acceptsPlaceholders = headers[PLACEHOLDERS_HEADER] === "1";
|
|
639
|
+
});
|
|
640
|
+
const delegate = createOtlpNetworkExportDelegate({ timeoutMillis: options.timeoutMillis, concurrencyLimit: 1, compression: "gzip" }, serializer, createOtlpHttpExporterMetrics(this.signal === "traces" ? "otlp_http_span_exporter" : "otlp_http_log_exporter", this.metrics, endpoint, undefined), transport);
|
|
482
641
|
const exporter = new OTLPExporterBase(delegate);
|
|
483
642
|
try {
|
|
484
643
|
const result = await new Promise((resolve) => {
|
|
485
644
|
timer = setTimeout(() => {
|
|
486
645
|
expired = true;
|
|
487
|
-
|
|
488
|
-
agent.destroy();
|
|
646
|
+
transport.abort();
|
|
489
647
|
resolve({ code: ExportResultCode.FAILED });
|
|
490
648
|
}, Math.max(1, deadline - Date.now()));
|
|
491
649
|
exporter.export(records, resolve);
|
|
492
650
|
});
|
|
493
651
|
if (result.code === ExportResultCode.SUCCESS) {
|
|
494
|
-
if (!receivedResponse)
|
|
495
|
-
|
|
496
|
-
return false;
|
|
497
|
-
}
|
|
652
|
+
if (!receivedResponse)
|
|
653
|
+
return lose("Hue response ended without a complete OTLP acknowledgement; acceptance is uncertain");
|
|
498
654
|
if (validResponse)
|
|
499
|
-
this.transport.acceptedRecords(this.signal,
|
|
500
|
-
return validResponse;
|
|
655
|
+
this.transport.acceptedRecords(this.signal, real - rejected);
|
|
656
|
+
return validResponse || !real;
|
|
501
657
|
}
|
|
502
658
|
else {
|
|
503
659
|
const status = result.error instanceof OTLPExporterError && Number.isInteger(result.error.code)
|
|
504
660
|
? result.error.code
|
|
505
661
|
: undefined;
|
|
506
|
-
|
|
507
|
-
return false;
|
|
662
|
+
return lose("Hue telemetry request failed", status);
|
|
508
663
|
}
|
|
509
664
|
}
|
|
510
665
|
catch {
|
|
511
|
-
|
|
512
|
-
return false;
|
|
666
|
+
return lose("Hue telemetry request failed");
|
|
513
667
|
}
|
|
514
668
|
finally {
|
|
515
669
|
clearTimeout(timer);
|
|
516
|
-
|
|
517
|
-
agent.destroy();
|
|
670
|
+
transport.abort();
|
|
518
671
|
// Delegate cleanup cannot extend the hard request wait. Sockets are closed
|
|
519
672
|
// and the cleanup promise is always observed, even after a caller timeout.
|
|
520
673
|
void exporter.shutdown().catch(() => { });
|
|
@@ -527,6 +680,182 @@ class ReportingExporter {
|
|
|
527
680
|
await this.forceFlush();
|
|
528
681
|
}
|
|
529
682
|
}
|
|
683
|
+
// OpenTelemetry's OTLP/HTTP transport rules (otlp-exporter-base 0.222), restated because that
|
|
684
|
+
// transport discards response headers, which Hue uses to announce features.
|
|
685
|
+
const MAX_RETRIES = 5;
|
|
686
|
+
const INITIAL_BACKOFF_MILLIS = 1000;
|
|
687
|
+
const MAX_BACKOFF_MILLIS = 5000;
|
|
688
|
+
const BACKOFF_MULTIPLIER = 1.5;
|
|
689
|
+
const JITTER = 0.2;
|
|
690
|
+
const MAX_RESPONSE_BYTES = 4 * 1024 * 1024;
|
|
691
|
+
const RETRYABLE_STATUS = new Set([429, 502, 503, 504]);
|
|
692
|
+
const RETRYABLE_NETWORK_ERRORS = new Set([
|
|
693
|
+
"ECONNRESET",
|
|
694
|
+
"ECONNREFUSED",
|
|
695
|
+
"EPIPE",
|
|
696
|
+
"ETIMEDOUT",
|
|
697
|
+
"EAI_AGAIN",
|
|
698
|
+
"ENOTFOUND",
|
|
699
|
+
"ENETUNREACH",
|
|
700
|
+
"EHOSTUNREACH",
|
|
701
|
+
]);
|
|
702
|
+
/** OpenTelemetry's exporter token, which follows Hue's in the User-Agent as it always has. */
|
|
703
|
+
const OTEL_USER_AGENT = "OTel-OTLP-Exporter-JavaScript/0.222.0";
|
|
704
|
+
function retryAfterMillis(value) {
|
|
705
|
+
if (value == null)
|
|
706
|
+
return undefined;
|
|
707
|
+
const seconds = Number.parseInt(value, 10);
|
|
708
|
+
if (Number.isInteger(seconds))
|
|
709
|
+
return seconds > 0 ? seconds * 1000 : -1;
|
|
710
|
+
const delay = new Date(value).getTime() - Date.now();
|
|
711
|
+
return delay >= 0 ? delay : 0;
|
|
712
|
+
}
|
|
713
|
+
function networkFailure(error) {
|
|
714
|
+
const code = error.code;
|
|
715
|
+
return typeof code === "string" && RETRYABLE_NETWORK_ERRORS.has(code)
|
|
716
|
+
? { status: "retryable", error }
|
|
717
|
+
: { status: "failure", error };
|
|
718
|
+
}
|
|
719
|
+
function compress(data) {
|
|
720
|
+
return new Promise((resolve, reject) => gzip(data, (error, result) => (error ? reject(error) : resolve(result))));
|
|
721
|
+
}
|
|
722
|
+
/**
|
|
723
|
+
* Gzip-compressed OTLP/HTTP POSTs that never follow redirects, retried like OpenTelemetry's
|
|
724
|
+
* exporter within the request budget. `onSuccess` receives the headers of the 2xx response whose
|
|
725
|
+
* body becomes the acknowledgement, before that body is decoded. One instance serves a single
|
|
726
|
+
* export request: its connections are never reused, and `abort()` closes them at the export
|
|
727
|
+
* deadline.
|
|
728
|
+
*/
|
|
729
|
+
class OtlpHttpTransport {
|
|
730
|
+
url;
|
|
731
|
+
headers;
|
|
732
|
+
onSuccess;
|
|
733
|
+
// Requests in flight and how to settle each, so an abort never leaves an attempt pending.
|
|
734
|
+
requests = new Map();
|
|
735
|
+
agent;
|
|
736
|
+
aborted = false;
|
|
737
|
+
constructor(url, headers, onSuccess) {
|
|
738
|
+
this.url = url;
|
|
739
|
+
this.headers = headers;
|
|
740
|
+
this.onSuccess = onSuccess;
|
|
741
|
+
}
|
|
742
|
+
async send(data, timeoutMillis) {
|
|
743
|
+
const deadline = Date.now() + timeoutMillis;
|
|
744
|
+
let backoff = INITIAL_BACKOFF_MILLIS;
|
|
745
|
+
let result = await this.attempt(data, timeoutMillis);
|
|
746
|
+
for (let retries = MAX_RETRIES; result.status === "retryable" && retries > 0; retries--) {
|
|
747
|
+
const jitter = Math.random() * 2 * JITTER - JITTER;
|
|
748
|
+
const wait = result.retryInMillis ?? Math.max(Math.min(backoff * (1 + jitter), MAX_BACKOFF_MILLIS), 0);
|
|
749
|
+
backoff *= BACKOFF_MULTIPLIER;
|
|
750
|
+
// Return when the next attempt would start after the export deadline.
|
|
751
|
+
if (this.aborted || wait > deadline - Date.now())
|
|
752
|
+
return result;
|
|
753
|
+
await new Promise((resolve) => setTimeout(resolve, Math.max(0, wait)));
|
|
754
|
+
result = await this.attempt(data, Math.max(1, deadline - Date.now()));
|
|
755
|
+
}
|
|
756
|
+
return result;
|
|
757
|
+
}
|
|
758
|
+
async attempt(data, timeoutMillis) {
|
|
759
|
+
try {
|
|
760
|
+
if (this.aborted)
|
|
761
|
+
throw new Error("Hue export deadline exceeded");
|
|
762
|
+
const url = new URL(this.url);
|
|
763
|
+
const protocol = url.protocol;
|
|
764
|
+
// Loaded on first use, as OpenTelemetry's exporter does, so importing Hue never loads http
|
|
765
|
+
// before the application's http instrumentation can patch it.
|
|
766
|
+
const [{ Agent: ConnectionAgent, request }, body] = await Promise.all([
|
|
767
|
+
import(protocol === "https:" ? "node:https" : "node:http"),
|
|
768
|
+
compress(data),
|
|
769
|
+
]);
|
|
770
|
+
if (this.aborted)
|
|
771
|
+
throw new Error("Hue export deadline exceeded");
|
|
772
|
+
// Never kept alive: each attempt's socket closes with its response or at the deadline.
|
|
773
|
+
this.agent ??= new ConnectionAgent({ keepAlive: false });
|
|
774
|
+
return await new Promise((resolve) => {
|
|
775
|
+
const req = request(url, {
|
|
776
|
+
method: "POST",
|
|
777
|
+
agent: this.agent,
|
|
778
|
+
headers: {
|
|
779
|
+
...this.headers,
|
|
780
|
+
"Content-Encoding": "gzip",
|
|
781
|
+
"Content-Length": body.byteLength,
|
|
782
|
+
},
|
|
783
|
+
}, (res) => {
|
|
784
|
+
const chunks = [];
|
|
785
|
+
let size = 0;
|
|
786
|
+
const status = res.statusCode ?? 0;
|
|
787
|
+
const success = status >= 200 && status <= 299;
|
|
788
|
+
res.on("data", (chunk) => {
|
|
789
|
+
size += chunk.length;
|
|
790
|
+
if (size > MAX_RESPONSE_BYTES) {
|
|
791
|
+
// Oversized responses fail regardless of status; resolve before tearing down.
|
|
792
|
+
resolve({ status: "failure", error: new Error("OTLP response exceeded 4 MiB") });
|
|
793
|
+
res.destroy();
|
|
794
|
+
return;
|
|
795
|
+
}
|
|
796
|
+
chunks.push(chunk);
|
|
797
|
+
});
|
|
798
|
+
res.on("end", () => {
|
|
799
|
+
if (success) {
|
|
800
|
+
this.onSuccess(res.headers);
|
|
801
|
+
resolve({ status: "success", data: Buffer.concat(chunks) });
|
|
802
|
+
}
|
|
803
|
+
else if (RETRYABLE_STATUS.has(status))
|
|
804
|
+
resolve({
|
|
805
|
+
status: "retryable",
|
|
806
|
+
retryInMillis: retryAfterMillis(res.headers["retry-after"]),
|
|
807
|
+
});
|
|
808
|
+
else
|
|
809
|
+
resolve({
|
|
810
|
+
status: "failure",
|
|
811
|
+
error: new OTLPExporterError(res.statusMessage, status, Buffer.concat(chunks).toString()),
|
|
812
|
+
});
|
|
813
|
+
});
|
|
814
|
+
res.on("error", (error) => {
|
|
815
|
+
// Sent, but the acknowledgement was not read: success without a body to decode.
|
|
816
|
+
if (success)
|
|
817
|
+
resolve({ status: "success" });
|
|
818
|
+
else if (RETRYABLE_STATUS.has(status))
|
|
819
|
+
resolve({
|
|
820
|
+
status: "retryable",
|
|
821
|
+
error,
|
|
822
|
+
retryInMillis: retryAfterMillis(res.headers["retry-after"]),
|
|
823
|
+
});
|
|
824
|
+
else
|
|
825
|
+
resolve({ status: "failure", error });
|
|
826
|
+
});
|
|
827
|
+
});
|
|
828
|
+
this.requests.set(req, resolve);
|
|
829
|
+
req.on("close", () => this.requests.delete(req));
|
|
830
|
+
req.setTimeout(timeoutMillis, () => {
|
|
831
|
+
req.destroy();
|
|
832
|
+
resolve({ status: "retryable", error: new Error("Request timed out") });
|
|
833
|
+
});
|
|
834
|
+
req.on("error", (error) => resolve(networkFailure(error)));
|
|
835
|
+
req.end(body);
|
|
836
|
+
});
|
|
837
|
+
}
|
|
838
|
+
catch (error) {
|
|
839
|
+
return {
|
|
840
|
+
status: "failure",
|
|
841
|
+
error: error instanceof Error ? error : new Error(String(error)),
|
|
842
|
+
};
|
|
843
|
+
}
|
|
844
|
+
}
|
|
845
|
+
/** Fails and destroys requests in flight and their sockets, and every later attempt. */
|
|
846
|
+
abort() {
|
|
847
|
+
this.aborted = true;
|
|
848
|
+
for (const [req, settle] of this.requests) {
|
|
849
|
+
settle({ status: "failure", error: new Error("Hue export deadline exceeded") });
|
|
850
|
+
req.destroy();
|
|
851
|
+
}
|
|
852
|
+
this.requests.clear();
|
|
853
|
+
this.agent?.destroy();
|
|
854
|
+
}
|
|
855
|
+
shutdown() {
|
|
856
|
+
this.abort();
|
|
857
|
+
}
|
|
858
|
+
}
|
|
530
859
|
/**
|
|
531
860
|
* Creates the export pipeline for attach mode; pass it with the application's providers to
|
|
532
861
|
* {@link createHue}. Validates options like an owned client.
|
package/dist/types.d.ts
CHANGED
|
@@ -43,6 +43,12 @@ export interface SharedHueOptions {
|
|
|
43
43
|
timeoutMillis?: number;
|
|
44
44
|
/** Aggregate estimated retained telemetry bytes across both signals, including in-flight work. Default 8 MiB. */
|
|
45
45
|
maxQueueBytes?: number;
|
|
46
|
+
/**
|
|
47
|
+
* Announces AI and Hue spans that are still running with in-progress placeholder spans, so Hue
|
|
48
|
+
* shows a trace while it runs. Default `true`; always off for setup credentials (`hue_setup_…`),
|
|
49
|
+
* and turned off for the client when its receiver does not accept placeholders.
|
|
50
|
+
*/
|
|
51
|
+
liveSpans?: boolean;
|
|
46
52
|
}
|
|
47
53
|
/**
|
|
48
54
|
* Options for a client that owns its OpenTelemetry providers. An enabled client needs a project
|
|
@@ -75,7 +81,7 @@ export interface ExportIssue {
|
|
|
75
81
|
signal: Signal;
|
|
76
82
|
/** `rejected` by Hue, `failed` to deliver, `dropped` from the queue, `invalid` record or capture, or a non-failing `warning`. */
|
|
77
83
|
kind: "rejected" | "failed" | "dropped" | "invalid" | "warning";
|
|
78
|
-
/** Records affected; zero for
|
|
84
|
+
/** Records affected; zero for capture failures and for warnings other than lost in-progress span placeholders. */
|
|
79
85
|
count: number;
|
|
80
86
|
/** HTTP status when the issue came from a response. */
|
|
81
87
|
status?: number;
|
package/dist/version.d.ts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
/** Package version shared by the instrumentation scope and the export User-Agent. */
|
|
2
|
-
export declare const sdkVersion = "0.
|
|
2
|
+
export declare const sdkVersion = "0.7.0";
|
package/dist/version.js
CHANGED