@hue-run/sdk 0.1.2 → 0.1.3
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/README.md +33 -0
- package/dist/client.d.ts +2 -1
- package/dist/client.js +6 -2
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/receipt.d.ts +10 -0
- package/dist/receipt.js +207 -0
- package/dist/types.d.ts +21 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -177,6 +177,39 @@ drain, including records emitted before its call. Stop request production
|
|
|
177
177
|
before shutdown so late spans cannot race it. A client does not own instrumented
|
|
178
178
|
operations still running in the application.
|
|
179
179
|
|
|
180
|
+
## Verify a stored application trace
|
|
181
|
+
|
|
182
|
+
After exercising a real application request and finishing its stream, flush the
|
|
183
|
+
providers that own its spans, then verify their OpenTelemetry IDs:
|
|
184
|
+
|
|
185
|
+
```ts
|
|
186
|
+
// traceId and requestSpanId come from the application request you just exercised.
|
|
187
|
+
await hue.flush(); // borrowed providers must also finish their own work
|
|
188
|
+
const result = await hue.verifyTrace(traceId, {
|
|
189
|
+
expectedSpanIds: [requestSpanId], // include known model/tool span IDs when available
|
|
190
|
+
requiredFields: ["input", "output", "model"], // choose fields this request should emit
|
|
191
|
+
});
|
|
192
|
+
if (!result.verified) throw new Error("Trace verification timed out; inspect missing spans and fields.");
|
|
193
|
+
console.log(result.receipt?.traceUrl);
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
Available in TypeScript `0.1.3`. `verifyTrace` makes a read-only, project-key-authenticated
|
|
197
|
+
receipt request. It does not flush, run your application, create a test span, or
|
|
198
|
+
read captured values. `fields` reports the presence of stored normalized input,
|
|
199
|
+
output, model, usage, and session data across the trace; it does not establish
|
|
200
|
+
content correctness or that every possible span has arrived. Leave unknown usage
|
|
201
|
+
and intentionally disabled content out of `requiredFields`.
|
|
202
|
+
|
|
203
|
+
The default budget is 10 seconds; set `timeoutMillis` up to 60,000. Only a recognized
|
|
204
|
+
missing trace, HTTP 429, or HTTP 503 is retried, respecting `Retry-After` and the
|
|
205
|
+
overall deadline. Incomplete evidence is also checked again within that deadline.
|
|
206
|
+
A timeout returns `{ verified: false, receipt }`, retaining the latest observed
|
|
207
|
+
receipt or `null`. Authentication, unsupported endpoint, transport, and invalid
|
|
208
|
+
response failures throw `HueTraceVerificationError` with a safe `code` and optional
|
|
209
|
+
HTTP `status`. Missing expected spans and required fields remain explicit; a 200
|
|
210
|
+
response alone is not success. A successful result verifies those requested
|
|
211
|
+
conditions only. Use Hue's UI to inspect captured values and redaction.
|
|
212
|
+
|
|
180
213
|
## Package verification
|
|
181
214
|
|
|
182
215
|
From the repository root with Node 24 and Bun 1.3.9 on PATH:
|
package/dist/client.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { type Context, type Span, type Tracer } from "@opentelemetry/api";
|
|
2
2
|
import { HueTransport } from "./transport.js";
|
|
3
|
-
import type { ExportReport, FlushableLoggerProvider, FlushableTracerProvider, HueOptions, HueSpan, JsonValue, ProjectConnection, SpanOptions } from "./types.js";
|
|
3
|
+
import type { ExportReport, FlushableLoggerProvider, FlushableTracerProvider, HueOptions, HueSpan, JsonValue, ProjectConnection, SpanOptions, VerifyTraceOptions, TraceVerification } from "./types.js";
|
|
4
4
|
export interface ExistingHueProviders {
|
|
5
5
|
transport: HueTransport;
|
|
6
6
|
tracerProvider: FlushableTracerProvider;
|
|
@@ -23,6 +23,7 @@ export declare class HueClient {
|
|
|
23
23
|
private shutdownPromise?;
|
|
24
24
|
private flushPromise?;
|
|
25
25
|
constructor(options: HueOptions | ExistingHueProviders);
|
|
26
|
+
verifyTrace(traceId: string, options?: VerifyTraceOptions): Promise<TraceVerification>;
|
|
26
27
|
getContext(): Context;
|
|
27
28
|
withSpan<T>(name: string, callback: (span: HueSpan) => Promise<T> | T, options?: SpanOptions): Promise<T>;
|
|
28
29
|
tool<T extends JsonValue | undefined>(name: string, input: JsonValue, execute: () => Promise<T> | T): Promise<T>;
|
package/dist/client.js
CHANGED
|
@@ -6,6 +6,7 @@ import { TracerProvider } from "@opentelemetry/sdk-trace";
|
|
|
6
6
|
import { resourceFromAttributes } from "@opentelemetry/resources";
|
|
7
7
|
import { MAX_CONTENT_BYTES } from "./config.js";
|
|
8
8
|
import { createHueTransport, HueExportError } from "./transport.js";
|
|
9
|
+
import { verifyTrace } from "./receipt.js";
|
|
9
10
|
export class HueConnectionError extends Error {
|
|
10
11
|
status;
|
|
11
12
|
constructor(message, status) {
|
|
@@ -97,8 +98,11 @@ export class HueClient {
|
|
|
97
98
|
this.loggerProvider = logger;
|
|
98
99
|
}
|
|
99
100
|
this.captureContent = this.transport.options.captureContent;
|
|
100
|
-
this.tracer = new ContextualTracer(this.tracerProvider.getTracer("@hue-run/sdk", "0.1.
|
|
101
|
-
this.logger = this.loggerProvider.getLogger("@hue-run/sdk", "0.1.
|
|
101
|
+
this.tracer = new ContextualTracer(this.tracerProvider.getTracer("@hue-run/sdk", "0.1.3"), this.storage);
|
|
102
|
+
this.logger = this.loggerProvider.getLogger("@hue-run/sdk", "0.1.3");
|
|
103
|
+
}
|
|
104
|
+
verifyTrace(traceId, options = {}) {
|
|
105
|
+
return verifyTrace(this.transport.options, traceId, options);
|
|
102
106
|
}
|
|
103
107
|
getContext() {
|
|
104
108
|
return this.storage.getStore()?.context ?? context.active();
|
package/dist/index.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
1
|
export { createHue, HueClient, HueConnectionError, type ExistingHueProviders } from "./client.js";
|
|
2
2
|
export { createHueTransport, HueTransport, HueExportError } from "./transport.js";
|
|
3
|
+
export { HueTraceVerificationError } from "./receipt.js";
|
|
3
4
|
export type * from "./types.js";
|
package/dist/index.js
CHANGED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { HueOptions, TraceVerification, VerifyTraceOptions } from "./types.js";
|
|
2
|
+
export declare class HueTraceVerificationError extends Error {
|
|
3
|
+
readonly code: "authentication" | "http" | "invalid_response" | "transport";
|
|
4
|
+
readonly status?: number | undefined;
|
|
5
|
+
constructor(code: "authentication" | "http" | "invalid_response" | "transport", message: string, status?: number | undefined);
|
|
6
|
+
}
|
|
7
|
+
/** Observe persisted evidence after the application and its exporter have finished. */
|
|
8
|
+
export declare function verifyTrace(connection: Pick<HueOptions, "apiKey"> & {
|
|
9
|
+
baseUrl: string;
|
|
10
|
+
}, traceId: string, options?: VerifyTraceOptions): Promise<TraceVerification>;
|
package/dist/receipt.js
ADDED
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
const fields = ["input", "output", "model", "usage", "session"];
|
|
2
|
+
const MAX_RESPONSE_BYTES = 64 * 1024;
|
|
3
|
+
export class HueTraceVerificationError extends Error {
|
|
4
|
+
code;
|
|
5
|
+
status;
|
|
6
|
+
constructor(code, message, status) {
|
|
7
|
+
super(message);
|
|
8
|
+
this.code = code;
|
|
9
|
+
this.status = status;
|
|
10
|
+
this.name = "HueTraceVerificationError";
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
function invalidResponse() {
|
|
14
|
+
throw new HueTraceVerificationError("invalid_response", "Hue returned an invalid trace receipt. Check the server and SDK versions.");
|
|
15
|
+
}
|
|
16
|
+
function validId(value, length) {
|
|
17
|
+
return (typeof value === "string" &&
|
|
18
|
+
value.length === length &&
|
|
19
|
+
new RegExp(`^[0-9a-f]{${length}}$`).test(value) &&
|
|
20
|
+
!/^0+$/.test(value));
|
|
21
|
+
}
|
|
22
|
+
function record(value) {
|
|
23
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
24
|
+
}
|
|
25
|
+
function parseReceipt(value, traceId, expected, origin) {
|
|
26
|
+
if (!record(value) ||
|
|
27
|
+
value.traceId !== traceId ||
|
|
28
|
+
!Number.isSafeInteger(value.spanCount) ||
|
|
29
|
+
value.spanCount < 0 ||
|
|
30
|
+
!Number.isSafeInteger(value.revision) ||
|
|
31
|
+
value.revision < 0 ||
|
|
32
|
+
!record(value.fields) ||
|
|
33
|
+
fields.some((field) => typeof value.fields[field] !== "boolean") ||
|
|
34
|
+
typeof value.traceUrl !== "string" ||
|
|
35
|
+
value.traceUrl.length > 2048)
|
|
36
|
+
invalidResponse();
|
|
37
|
+
let traceUrl;
|
|
38
|
+
try {
|
|
39
|
+
traceUrl = new URL(value.traceUrl);
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
invalidResponse();
|
|
43
|
+
}
|
|
44
|
+
if (traceUrl.origin !== origin || traceUrl.username || traceUrl.password)
|
|
45
|
+
invalidResponse();
|
|
46
|
+
const matched = value.matchedSpanIds, missing = value.missingSpanIds;
|
|
47
|
+
if (!Array.isArray(matched) ||
|
|
48
|
+
!Array.isArray(missing) ||
|
|
49
|
+
matched.length + missing.length !== expected.length ||
|
|
50
|
+
[...matched, ...missing].some((id) => !validId(id, 16) || !expected.includes(id)) ||
|
|
51
|
+
new Set([...matched, ...missing]).size !== expected.length ||
|
|
52
|
+
matched.length > value.spanCount)
|
|
53
|
+
invalidResponse();
|
|
54
|
+
// Return only the documented fields, never arbitrary response content.
|
|
55
|
+
return {
|
|
56
|
+
traceId,
|
|
57
|
+
spanCount: value.spanCount,
|
|
58
|
+
revision: value.revision,
|
|
59
|
+
fields: Object.fromEntries(fields.map((field) => [field, value.fields[field]])),
|
|
60
|
+
matchedSpanIds: expected.filter((id) => matched.includes(id)),
|
|
61
|
+
missingSpanIds: expected.filter((id) => missing.includes(id)),
|
|
62
|
+
traceUrl: traceUrl.href,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
async function readJson(response) {
|
|
66
|
+
const length = response.headers.get("content-length");
|
|
67
|
+
if (length && /^\d+$/.test(length) && Number(length) > MAX_RESPONSE_BYTES) {
|
|
68
|
+
await response.body?.cancel();
|
|
69
|
+
invalidResponse();
|
|
70
|
+
}
|
|
71
|
+
const reader = response.body?.getReader();
|
|
72
|
+
if (!reader)
|
|
73
|
+
invalidResponse();
|
|
74
|
+
const chunks = [];
|
|
75
|
+
let size = 0;
|
|
76
|
+
try {
|
|
77
|
+
while (true) {
|
|
78
|
+
const next = await reader.read();
|
|
79
|
+
if (next.done)
|
|
80
|
+
break;
|
|
81
|
+
size += next.value.byteLength;
|
|
82
|
+
if (size > MAX_RESPONSE_BYTES) {
|
|
83
|
+
await reader.cancel();
|
|
84
|
+
invalidResponse();
|
|
85
|
+
}
|
|
86
|
+
chunks.push(next.value);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
finally {
|
|
90
|
+
reader.releaseLock();
|
|
91
|
+
}
|
|
92
|
+
try {
|
|
93
|
+
return JSON.parse(Buffer.concat(chunks).toString("utf8"));
|
|
94
|
+
}
|
|
95
|
+
catch {
|
|
96
|
+
invalidResponse();
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
function retryDelay(value) {
|
|
100
|
+
if (!value)
|
|
101
|
+
return 0;
|
|
102
|
+
if (/^\d+$/.test(value))
|
|
103
|
+
return Number(value) * 1000;
|
|
104
|
+
const date = Date.parse(value);
|
|
105
|
+
return Number.isFinite(date) ? Math.max(0, date - Date.now()) : 0;
|
|
106
|
+
}
|
|
107
|
+
async function pause(milliseconds, signal) {
|
|
108
|
+
if (signal.aborted)
|
|
109
|
+
return;
|
|
110
|
+
await new Promise((resolve) => {
|
|
111
|
+
const finish = () => {
|
|
112
|
+
clearTimeout(timer);
|
|
113
|
+
signal.removeEventListener("abort", finish);
|
|
114
|
+
resolve();
|
|
115
|
+
};
|
|
116
|
+
const timer = setTimeout(finish, milliseconds);
|
|
117
|
+
signal.addEventListener("abort", finish, { once: true });
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
/** Observe persisted evidence after the application and its exporter have finished. */
|
|
121
|
+
export async function verifyTrace(connection, traceId, options = {}) {
|
|
122
|
+
if (!validId(traceId, 32))
|
|
123
|
+
throw new TypeError("traceId must be a nonzero lowercase 32-character OpenTelemetry trace ID");
|
|
124
|
+
if (options === null || typeof options !== "object" || Array.isArray(options))
|
|
125
|
+
throw new TypeError("Trace verification options must be an object");
|
|
126
|
+
const expected = options.expectedSpanIds === undefined ? [] : options.expectedSpanIds;
|
|
127
|
+
if (!Array.isArray(expected) ||
|
|
128
|
+
expected.length > 100 ||
|
|
129
|
+
expected.some((id) => !validId(id, 16)) ||
|
|
130
|
+
new Set(expected).size !== expected.length)
|
|
131
|
+
throw new TypeError("expectedSpanIds must contain at most 100 unique nonzero lowercase 16-character span IDs");
|
|
132
|
+
const required = options.requiredFields === undefined ? [] : options.requiredFields;
|
|
133
|
+
if (!Array.isArray(required) ||
|
|
134
|
+
required.some((field) => !fields.includes(field)) ||
|
|
135
|
+
new Set(required).size !== required.length)
|
|
136
|
+
throw new TypeError("requiredFields must contain unique receipt field names: input, output, model, usage, session");
|
|
137
|
+
const timeout = options.timeoutMillis === undefined ? 10_000 : options.timeoutMillis;
|
|
138
|
+
if (!Number.isFinite(timeout) || timeout <= 0 || timeout > 60_000)
|
|
139
|
+
throw new TypeError("timeoutMillis must be greater than zero and at most 60000");
|
|
140
|
+
// Snapshot caller arrays so concurrent mutation cannot alter the verification criteria.
|
|
141
|
+
const expectedIds = [...expected], requiredFields = [...required];
|
|
142
|
+
const url = new URL(`/api/v1/traces/${traceId}/receipt`, connection.baseUrl);
|
|
143
|
+
for (const id of expectedIds)
|
|
144
|
+
url.searchParams.append("expectedSpanId", id);
|
|
145
|
+
const controller = new AbortController();
|
|
146
|
+
const deadline = performance.now() + timeout;
|
|
147
|
+
const timer = setTimeout(() => controller.abort(), timeout);
|
|
148
|
+
let receipt = null;
|
|
149
|
+
let delay = 250;
|
|
150
|
+
try {
|
|
151
|
+
while (!controller.signal.aborted && performance.now() < deadline) {
|
|
152
|
+
const response = await fetch(url, {
|
|
153
|
+
headers: { Authorization: `Bearer ${connection.apiKey}`, Accept: "application/json" },
|
|
154
|
+
redirect: "manual",
|
|
155
|
+
credentials: "omit",
|
|
156
|
+
cache: "no-store",
|
|
157
|
+
signal: controller.signal,
|
|
158
|
+
});
|
|
159
|
+
let retryAfter = 0;
|
|
160
|
+
if (response.status === 200) {
|
|
161
|
+
receipt = parseReceipt(await readJson(response), traceId, expectedIds, url.origin);
|
|
162
|
+
if (!controller.signal.aborted &&
|
|
163
|
+
performance.now() < deadline &&
|
|
164
|
+
receipt.missingSpanIds.length === 0 &&
|
|
165
|
+
requiredFields.every((field) => receipt.fields[field]))
|
|
166
|
+
return { verified: true, receipt };
|
|
167
|
+
}
|
|
168
|
+
else if (response.status === 404) {
|
|
169
|
+
const body = await readJson(response);
|
|
170
|
+
if (!record(body) || body.code !== "TRACE_NOT_FOUND")
|
|
171
|
+
throw new HueTraceVerificationError("http", "This Hue server does not support trace receipts. Check the server version and baseUrl.", 404);
|
|
172
|
+
}
|
|
173
|
+
else {
|
|
174
|
+
await response.body?.cancel();
|
|
175
|
+
if (response.status === 429 || response.status === 503) {
|
|
176
|
+
retryAfter = retryDelay(response.headers.get("retry-after"));
|
|
177
|
+
}
|
|
178
|
+
else if (response.status === 401 || response.status === 403) {
|
|
179
|
+
throw new HueTraceVerificationError("authentication", "Hue denied trace verification. Check the project key, its access, and expiration.", response.status);
|
|
180
|
+
}
|
|
181
|
+
else {
|
|
182
|
+
throw new HueTraceVerificationError("http", "Hue refused trace verification. Check the server version and configured origin; redirects are not followed.", response.status);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
const remaining = deadline - performance.now();
|
|
186
|
+
if (remaining <= 0)
|
|
187
|
+
break;
|
|
188
|
+
const wait = Math.max(delay, retryAfter);
|
|
189
|
+
await pause(Math.min(wait, remaining), controller.signal);
|
|
190
|
+
// A truncated backoff exhausts this call even if a timer wakes just early.
|
|
191
|
+
if (wait >= remaining)
|
|
192
|
+
break;
|
|
193
|
+
delay = Math.min(delay * 2, 1000);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
catch (error) {
|
|
197
|
+
if (!controller.signal.aborted && performance.now() < deadline) {
|
|
198
|
+
if (error instanceof HueTraceVerificationError)
|
|
199
|
+
throw error;
|
|
200
|
+
throw new HueTraceVerificationError("transport", "Hue trace verification could not reach the server. Check the network and configured origin.");
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
finally {
|
|
204
|
+
clearTimeout(timer);
|
|
205
|
+
}
|
|
206
|
+
return { verified: false, receipt };
|
|
207
|
+
}
|
package/dist/types.d.ts
CHANGED
|
@@ -61,3 +61,24 @@ export interface ProjectConnection {
|
|
|
61
61
|
organizationId: string;
|
|
62
62
|
slug: string;
|
|
63
63
|
}
|
|
64
|
+
export type TraceReceiptField = "input" | "output" | "model" | "usage" | "session";
|
|
65
|
+
export interface TraceReceipt {
|
|
66
|
+
traceId: string;
|
|
67
|
+
spanCount: number;
|
|
68
|
+
revision: number;
|
|
69
|
+
/** Presence of stored normalized fields; not a judgment of content correctness. */
|
|
70
|
+
fields: Record<TraceReceiptField, boolean>;
|
|
71
|
+
matchedSpanIds: string[];
|
|
72
|
+
missingSpanIds: string[];
|
|
73
|
+
traceUrl: string;
|
|
74
|
+
}
|
|
75
|
+
export interface VerifyTraceOptions {
|
|
76
|
+
expectedSpanIds?: string[];
|
|
77
|
+
requiredFields?: TraceReceiptField[];
|
|
78
|
+
/** Total request/retry budget, including response bodies. Default 10000; maximum 60000. */
|
|
79
|
+
timeoutMillis?: number;
|
|
80
|
+
}
|
|
81
|
+
export interface TraceVerification {
|
|
82
|
+
verified: boolean;
|
|
83
|
+
receipt: TraceReceipt | null;
|
|
84
|
+
}
|