dyadic 1.3.1 → 1.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/CHANGELOG.md +85 -0
- package/LICENSE +21 -0
- package/README.md +193 -2
- package/dist/blackbox.d.ts +266 -0
- package/dist/blackbox.js +852 -0
- package/dist/index.d.ts +1095 -11
- package/dist/index.js +560 -29
- package/dist/otel.d.ts +103 -0
- package/dist/otel.js +305 -0
- package/package.json +5 -5
package/dist/otel.d.ts
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
type HrTime = [number, number];
|
|
2
|
+
type TimeLike = HrTime | number | bigint | string | Date | null | undefined;
|
|
3
|
+
type Attributes = Record<string, unknown>;
|
|
4
|
+
export interface SpanContextLike {
|
|
5
|
+
traceId: string;
|
|
6
|
+
spanId: string;
|
|
7
|
+
traceFlags?: number;
|
|
8
|
+
traceState?: {
|
|
9
|
+
serialize(): string;
|
|
10
|
+
} | string;
|
|
11
|
+
}
|
|
12
|
+
export interface InstrumentationScopeLike {
|
|
13
|
+
name: string;
|
|
14
|
+
version?: string;
|
|
15
|
+
schemaUrl?: string;
|
|
16
|
+
}
|
|
17
|
+
/** The parts of an OpenTelemetry ReadableSpan the exporter reads (SDK 1.x and 2.x). */
|
|
18
|
+
export interface ReadableSpanLike {
|
|
19
|
+
name: string;
|
|
20
|
+
kind?: number;
|
|
21
|
+
spanContext(): SpanContextLike;
|
|
22
|
+
/** SDK 2.x */
|
|
23
|
+
parentSpanContext?: SpanContextLike | null;
|
|
24
|
+
/** SDK 1.x */
|
|
25
|
+
parentSpanId?: string;
|
|
26
|
+
startTime?: TimeLike;
|
|
27
|
+
endTime?: TimeLike;
|
|
28
|
+
status?: {
|
|
29
|
+
code?: number;
|
|
30
|
+
message?: string;
|
|
31
|
+
};
|
|
32
|
+
attributes?: Attributes;
|
|
33
|
+
events?: {
|
|
34
|
+
name: string;
|
|
35
|
+
time?: TimeLike;
|
|
36
|
+
attributes?: Attributes;
|
|
37
|
+
droppedAttributesCount?: number;
|
|
38
|
+
}[];
|
|
39
|
+
links?: {
|
|
40
|
+
context: SpanContextLike;
|
|
41
|
+
attributes?: Attributes;
|
|
42
|
+
droppedAttributesCount?: number;
|
|
43
|
+
}[];
|
|
44
|
+
resource?: {
|
|
45
|
+
attributes?: Attributes;
|
|
46
|
+
schemaUrl?: string;
|
|
47
|
+
};
|
|
48
|
+
/** SDK 2.x */
|
|
49
|
+
instrumentationScope?: InstrumentationScopeLike;
|
|
50
|
+
/** SDK 1.x */
|
|
51
|
+
instrumentationLibrary?: InstrumentationScopeLike;
|
|
52
|
+
droppedAttributesCount?: number;
|
|
53
|
+
droppedEventsCount?: number;
|
|
54
|
+
droppedLinksCount?: number;
|
|
55
|
+
}
|
|
56
|
+
/** OpenTelemetry's ExportResult: code 0 is SUCCESS, 1 is FAILED. */
|
|
57
|
+
export interface ExportResultLike {
|
|
58
|
+
code: 0 | 1;
|
|
59
|
+
error?: Error;
|
|
60
|
+
}
|
|
61
|
+
export interface SpanExporterOptions {
|
|
62
|
+
/** Send content attributes (prompts, outputs, tool arguments and results). Off by default. */
|
|
63
|
+
captureContent?: boolean;
|
|
64
|
+
/** Applied to every attribute value before it leaves the process; null sends values as they are. Default: redactSecrets. */
|
|
65
|
+
redact?: ((value: unknown) => unknown) | null;
|
|
66
|
+
/** Spans per request (the service takes up to 1,000). */
|
|
67
|
+
maxSpansPerRequest?: number;
|
|
68
|
+
/** Bytes per request (the service takes up to 4 MB). */
|
|
69
|
+
maxBytesPerRequest?: number;
|
|
70
|
+
/** Replaces the HTTP call (tests, a proxy). */
|
|
71
|
+
send?: (request: Record<string, unknown>) => Promise<unknown>;
|
|
72
|
+
onError?: (error: unknown, request?: Record<string, unknown>) => void;
|
|
73
|
+
}
|
|
74
|
+
interface OtlpSender {
|
|
75
|
+
blackbox: {
|
|
76
|
+
sendOtlp: (request: Record<string, unknown>) => Promise<unknown>;
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
export declare const isContentAttribute: (key: string) => boolean;
|
|
80
|
+
/** One span (a ReadableSpan or the same shape) as an OTLP/JSON span. */
|
|
81
|
+
export declare function spanToOtlp(span: ReadableSpanLike, options?: Pick<SpanExporterOptions, "captureContent" | "redact">): Record<string, unknown>;
|
|
82
|
+
/** Spans as OTLP/JSON ExportTraceServiceRequests, grouped by resource and instrumentation scope, split to fit the service. */
|
|
83
|
+
export declare function toOtlpRequests(spans: readonly ReadableSpanLike[], options?: Omit<SpanExporterOptions, "send" | "onError">): Record<string, unknown>[];
|
|
84
|
+
/** An OpenTelemetry SpanExporter that sends to the Black Box (see the top of this file). Never throws into the app. */
|
|
85
|
+
export declare class BlackBoxSpanExporter {
|
|
86
|
+
readonly stats: {
|
|
87
|
+
spans: number;
|
|
88
|
+
requests: number;
|
|
89
|
+
failedSpans: number;
|
|
90
|
+
rejectedSpans: number;
|
|
91
|
+
};
|
|
92
|
+
private readonly options;
|
|
93
|
+
private readonly send;
|
|
94
|
+
private readonly pending;
|
|
95
|
+
private stopped;
|
|
96
|
+
constructor(client: OtlpSender | null, options?: SpanExporterOptions);
|
|
97
|
+
export(spans: ReadableSpanLike[], resultCallback: (result: ExportResultLike) => void): void;
|
|
98
|
+
private deliver;
|
|
99
|
+
/** Resolves when every export in flight has been sent (or given up on). */
|
|
100
|
+
forceFlush(): Promise<void>;
|
|
101
|
+
shutdown(): Promise<void>;
|
|
102
|
+
}
|
|
103
|
+
export {};
|
package/dist/otel.js
ADDED
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
// OpenTelemetry: send the spans your agent framework already makes to the Black Box.
|
|
2
|
+
//
|
|
3
|
+
// import { BasicTracerProvider, BatchSpanProcessor } from "@opentelemetry/sdk-trace-base";
|
|
4
|
+
// import { Dyadic, BlackBoxSpanExporter } from "dyadic";
|
|
5
|
+
//
|
|
6
|
+
// const provider = new BasicTracerProvider({ spanProcessors: [new BatchSpanProcessor(new BlackBoxSpanExporter(new Dyadic()))] });
|
|
7
|
+
//
|
|
8
|
+
// Spans following the GenAI semantic conventions (gen_ai.operation.name: chat, execute_tool, invoke_agent, ...) or
|
|
9
|
+
// OpenInference (openinference.span.kind) become model calls, tool calls and agents in the Black Box; others are kept
|
|
10
|
+
// as generic spans. One run per trace. The exporter sends OTLP/JSON to POST /v1/blackbox/otlp/v1/traces through the
|
|
11
|
+
// client (its transport and retries; the service dedupes by span id, so a resent batch is harmless) and needs nothing
|
|
12
|
+
// from OpenTelemetry at runtime: it takes the SDK's ReadableSpan objects (1.x and 2.x), or anything shaped like them.
|
|
13
|
+
// Like the recorder, it sends content attributes (gen_ai.input.messages, input.value, tool arguments and results, ...)
|
|
14
|
+
// only with { captureContent: true }, and every value is redacted first (secrets, by default).
|
|
15
|
+
import { redactSecrets } from "./blackbox.js";
|
|
16
|
+
const SCOPE = "dyadic.blackbox.exporter";
|
|
17
|
+
const MAX_SPANS = 1000;
|
|
18
|
+
const MAX_BYTES = 4 * 1024 * 1024 - 64 * 1024;
|
|
19
|
+
const LONG_STRING = 16 * 1024;
|
|
20
|
+
const MAX_DEPTH = 16;
|
|
21
|
+
// What a span or span event can carry of the conversation itself (GenAI semantic conventions, their older
|
|
22
|
+
// prompt/completion forms, OpenInference). Everything else is metadata.
|
|
23
|
+
const CONTENT_KEYS = new Set(["gen_ai.input.messages", "gen_ai.output.messages", "gen_ai.system_instructions", "gen_ai.tool.definitions",
|
|
24
|
+
"gen_ai.tool.call.arguments", "gen_ai.tool.call.result", "gen_ai.prompt", "gen_ai.completion", "gen_ai.event.content",
|
|
25
|
+
"tool.parameters", "input.value", "output.value", "llm.prompts", "llm.tools", "message.content"]);
|
|
26
|
+
const CONTENT_PREFIXES = ["gen_ai.prompt.", "gen_ai.completion.", "llm.input_messages.", "llm.output_messages.", "llm.prompt_template.",
|
|
27
|
+
"retrieval.documents.", "reranker.input_documents.", "reranker.output_documents.", "embedding.embeddings."];
|
|
28
|
+
export const isContentAttribute = (key) => CONTENT_KEYS.has(key) || CONTENT_PREFIXES.some((prefix) => key.startsWith(prefix));
|
|
29
|
+
const encoder = new TextEncoder();
|
|
30
|
+
const byteLength = (value) => encoder.encode(JSON.stringify(value)).length;
|
|
31
|
+
function base64(bytes) {
|
|
32
|
+
let binary = "";
|
|
33
|
+
for (let i = 0; i < bytes.length; i += 0x8000)
|
|
34
|
+
binary += String.fromCharCode(...bytes.subarray(i, i + 0x8000));
|
|
35
|
+
return btoa(binary);
|
|
36
|
+
}
|
|
37
|
+
/** An attribute value as OTLP/JSON's AnyValue. */
|
|
38
|
+
function anyValue(value, depth = 0) {
|
|
39
|
+
if (value === null || value === undefined)
|
|
40
|
+
return {};
|
|
41
|
+
if (typeof value === "string")
|
|
42
|
+
return { stringValue: value };
|
|
43
|
+
if (typeof value === "boolean")
|
|
44
|
+
return { boolValue: value };
|
|
45
|
+
if (typeof value === "bigint")
|
|
46
|
+
return { intValue: value.toString() };
|
|
47
|
+
if (typeof value === "number") {
|
|
48
|
+
if (Number.isSafeInteger(value))
|
|
49
|
+
return { intValue: String(value) };
|
|
50
|
+
return Number.isFinite(value) ? { doubleValue: value } : { stringValue: String(value) };
|
|
51
|
+
}
|
|
52
|
+
if (value instanceof Uint8Array)
|
|
53
|
+
return { bytesValue: base64(value) };
|
|
54
|
+
if (value instanceof ArrayBuffer)
|
|
55
|
+
return { bytesValue: base64(new Uint8Array(value)) };
|
|
56
|
+
if (depth >= MAX_DEPTH)
|
|
57
|
+
return { stringValue: "[nested too deeply]" };
|
|
58
|
+
if (Array.isArray(value))
|
|
59
|
+
return { arrayValue: { values: value.map((v) => anyValue(v, depth + 1)) } };
|
|
60
|
+
if (typeof value === "object") {
|
|
61
|
+
return { kvlistValue: { values: Object.entries(value).filter(([, v]) => v !== undefined)
|
|
62
|
+
.map(([key, v]) => ({ key, value: anyValue(v, depth + 1) })) } };
|
|
63
|
+
}
|
|
64
|
+
return { stringValue: String(value) };
|
|
65
|
+
}
|
|
66
|
+
/** Nanoseconds since the epoch, as OTLP/JSON writes them: from an HrTime [s, ns], milliseconds, a Date or nanoseconds. */
|
|
67
|
+
function nanos(time) {
|
|
68
|
+
if (Array.isArray(time))
|
|
69
|
+
return (BigInt(Math.trunc(time[0] || 0)) * BigInt(1e9) + BigInt(Math.trunc(time[1] || 0))).toString();
|
|
70
|
+
if (typeof time === "bigint")
|
|
71
|
+
return time.toString();
|
|
72
|
+
if (typeof time === "number" && Number.isFinite(time)) {
|
|
73
|
+
const ms = Math.trunc(time);
|
|
74
|
+
return (BigInt(ms) * BigInt(1e6) + BigInt(Math.round((time - ms) * 1e6))).toString();
|
|
75
|
+
}
|
|
76
|
+
if (time instanceof Date && !Number.isNaN(time.getTime()))
|
|
77
|
+
return (BigInt(time.getTime()) * BigInt(1e6)).toString();
|
|
78
|
+
if (typeof time === "string" && /^\d{1,20}$/.test(time))
|
|
79
|
+
return time;
|
|
80
|
+
return "0";
|
|
81
|
+
}
|
|
82
|
+
const hexId = (value, size) => {
|
|
83
|
+
const text = String(value ?? "").toLowerCase();
|
|
84
|
+
return new RegExp(`^[0-9a-f]{${size}}$`).test(text) && !/^0+$/.test(text) ? text : "";
|
|
85
|
+
};
|
|
86
|
+
const traceState = (context) => {
|
|
87
|
+
const state = context?.traceState;
|
|
88
|
+
if (!state)
|
|
89
|
+
return undefined;
|
|
90
|
+
try {
|
|
91
|
+
return (typeof state === "string" ? state : state.serialize()) || undefined;
|
|
92
|
+
}
|
|
93
|
+
catch {
|
|
94
|
+
return undefined;
|
|
95
|
+
}
|
|
96
|
+
};
|
|
97
|
+
class Converter {
|
|
98
|
+
constructor(captureContent, redact) {
|
|
99
|
+
this.captureContent = captureContent;
|
|
100
|
+
this.redact = redact;
|
|
101
|
+
}
|
|
102
|
+
/** A value through the redactor; the default one also redacts by the attribute's last segment, so credential-named
|
|
103
|
+
* fields (…authorization, …api_key) are caught. JSON text (tool arguments, messages) is redacted inside and written back. */
|
|
104
|
+
value(key, value) {
|
|
105
|
+
if (!this.redact)
|
|
106
|
+
return value;
|
|
107
|
+
const field = key.split(".").pop() || key;
|
|
108
|
+
if (value instanceof Uint8Array || value instanceof ArrayBuffer) {
|
|
109
|
+
// Bytes aren't text to scan; a credential-named field is still withheld.
|
|
110
|
+
return this.redact === redactSecrets && redactSecrets({ [field]: "x" })[field] !== "x" ? "[REDACTED:field]" : value;
|
|
111
|
+
}
|
|
112
|
+
if (typeof value === "string" && /^\s*[[{]/.test(value) && value.length <= 1024 * 1024) {
|
|
113
|
+
try {
|
|
114
|
+
const parsed = JSON.parse(value);
|
|
115
|
+
const redacted = this.apply(field, parsed);
|
|
116
|
+
if (JSON.stringify(redacted) !== JSON.stringify(parsed))
|
|
117
|
+
return typeof redacted === "string" ? redacted : JSON.stringify(redacted);
|
|
118
|
+
return this.apply(field, value);
|
|
119
|
+
}
|
|
120
|
+
catch { /* not JSON: a plain string */ }
|
|
121
|
+
}
|
|
122
|
+
return this.apply(field, value);
|
|
123
|
+
}
|
|
124
|
+
apply(field, value) {
|
|
125
|
+
const out = this.redact(value);
|
|
126
|
+
return this.redact === redactSecrets ? redactSecrets({ [field]: out })[field] : out;
|
|
127
|
+
}
|
|
128
|
+
attributes(values, content) {
|
|
129
|
+
const out = [];
|
|
130
|
+
for (const [key, raw] of Object.entries(values ?? {})) {
|
|
131
|
+
if (raw === undefined || raw === null)
|
|
132
|
+
continue;
|
|
133
|
+
if (isContentAttribute(key) && !(content && this.captureContent))
|
|
134
|
+
continue;
|
|
135
|
+
out.push({ key, value: anyValue(this.value(key, raw)) });
|
|
136
|
+
}
|
|
137
|
+
return out;
|
|
138
|
+
}
|
|
139
|
+
span(span) {
|
|
140
|
+
const shape = span;
|
|
141
|
+
const context = (typeof shape.spanContext === "function" ? span.spanContext() : (shape.spanContext ?? shape.context ?? {}));
|
|
142
|
+
const parent = span.parentSpanContext?.spanId ?? span.parentSpanId ?? "";
|
|
143
|
+
const kind = Number.isInteger(span.kind) && span.kind >= 0 && span.kind <= 4 ? span.kind + 1 : 1;
|
|
144
|
+
const status = { code: [0, 1, 2].includes(Number(span.status?.code)) ? Number(span.status?.code) : 0 };
|
|
145
|
+
if (span.status?.message)
|
|
146
|
+
status.message = String(this.value("status.message", String(span.status.message)));
|
|
147
|
+
const out = {
|
|
148
|
+
traceId: hexId(context.traceId, 32), spanId: hexId(context.spanId, 16), parentSpanId: hexId(parent, 16),
|
|
149
|
+
name: String(span.name ?? ""), kind, startTimeUnixNano: nanos(span.startTime), endTimeUnixNano: nanos(span.endTime),
|
|
150
|
+
attributes: this.attributes(span.attributes, true),
|
|
151
|
+
events: (span.events ?? []).map((event) => ({ timeUnixNano: nanos(event.time), name: String(event.name ?? ""),
|
|
152
|
+
attributes: this.attributes(event.attributes, true), ...(event.droppedAttributesCount ? { droppedAttributesCount: event.droppedAttributesCount } : {}) })),
|
|
153
|
+
links: (span.links ?? []).filter((link) => link?.context).map((link) => ({ traceId: hexId(link.context.traceId, 32), spanId: hexId(link.context.spanId, 16),
|
|
154
|
+
...(traceState(link.context) ? { traceState: traceState(link.context) } : {}), attributes: this.attributes(link.attributes, false),
|
|
155
|
+
...(typeof link.context.traceFlags === "number" ? { flags: link.context.traceFlags } : {}),
|
|
156
|
+
...(link.droppedAttributesCount ? { droppedAttributesCount: link.droppedAttributesCount } : {}) })),
|
|
157
|
+
status,
|
|
158
|
+
};
|
|
159
|
+
if (traceState(context))
|
|
160
|
+
out.traceState = traceState(context);
|
|
161
|
+
if (typeof context.traceFlags === "number")
|
|
162
|
+
out.flags = context.traceFlags;
|
|
163
|
+
for (const field of ["droppedAttributesCount", "droppedEventsCount", "droppedLinksCount"])
|
|
164
|
+
if (span[field])
|
|
165
|
+
out[field] = span[field];
|
|
166
|
+
return out;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
/** A span too big for one request: content first, then long strings, then its attributes, until it fits. */
|
|
170
|
+
function shrink(span, limit) {
|
|
171
|
+
const strip = (items, test) => items.filter((item) => !test(item));
|
|
172
|
+
const steps = [
|
|
173
|
+
(s) => { s.attributes = strip(s.attributes, (a) => isContentAttribute(a.key)); s.events = s.events.map((e) => ({ ...e, attributes: strip(e.attributes, (a) => isContentAttribute(a.key)) })); },
|
|
174
|
+
(s) => {
|
|
175
|
+
const cut = (v) => typeof v?.stringValue === "string" && v.stringValue.length > LONG_STRING ? { stringValue: `${v.stringValue.slice(0, LONG_STRING)}…[truncated]` }
|
|
176
|
+
: v?.arrayValue ? { arrayValue: { values: v.arrayValue.values.map(cut) } } : v?.kvlistValue ? { kvlistValue: { values: v.kvlistValue.values.map((kv) => ({ ...kv, value: cut(kv.value) })) } } : v;
|
|
177
|
+
s.attributes = s.attributes.map((a) => ({ ...a, value: cut(a.value) }));
|
|
178
|
+
s.events = s.events.map((e) => ({ ...e, attributes: e.attributes.map((a) => ({ ...a, value: cut(a.value) })) }));
|
|
179
|
+
},
|
|
180
|
+
(s) => { s.droppedEventsCount = Number(s.droppedEventsCount ?? 0) + s.events.length; s.events = []; s.droppedLinksCount = Number(s.droppedLinksCount ?? 0) + s.links.length; s.links = []; },
|
|
181
|
+
(s) => { s.droppedAttributesCount = Number(s.droppedAttributesCount ?? 0) + s.attributes.length; s.attributes = []; },
|
|
182
|
+
];
|
|
183
|
+
let current = span;
|
|
184
|
+
for (const step of steps) {
|
|
185
|
+
if (byteLength(current) <= limit)
|
|
186
|
+
break;
|
|
187
|
+
current = JSON.parse(JSON.stringify(current));
|
|
188
|
+
step(current);
|
|
189
|
+
}
|
|
190
|
+
if (current !== span)
|
|
191
|
+
current.attributes.push({ key: "dyadic.blackbox.truncated", value: { boolValue: true } });
|
|
192
|
+
return current;
|
|
193
|
+
}
|
|
194
|
+
/** One span (a ReadableSpan or the same shape) as an OTLP/JSON span. */
|
|
195
|
+
export function spanToOtlp(span, options = {}) {
|
|
196
|
+
return new Converter(options.captureContent ?? false, options.redact === undefined ? redactSecrets : options.redact).span(span);
|
|
197
|
+
}
|
|
198
|
+
/** Spans as OTLP/JSON ExportTraceServiceRequests, grouped by resource and instrumentation scope, split to fit the service. */
|
|
199
|
+
export function toOtlpRequests(spans, options = {}) {
|
|
200
|
+
const converter = new Converter(options.captureContent ?? false, options.redact === undefined ? redactSecrets : options.redact);
|
|
201
|
+
const maxSpans = Math.max(1, Math.min(MAX_SPANS, options.maxSpansPerRequest ?? MAX_SPANS));
|
|
202
|
+
const maxBytes = Math.max(16 * 1024, Math.min(MAX_BYTES, options.maxBytesPerRequest ?? MAX_BYTES));
|
|
203
|
+
const resources = new Map();
|
|
204
|
+
const requests = [];
|
|
205
|
+
let groups = new Map();
|
|
206
|
+
let count = 0, bytes = 64;
|
|
207
|
+
const close = () => {
|
|
208
|
+
if (!count)
|
|
209
|
+
return;
|
|
210
|
+
requests.push({ resourceSpans: [...groups.values()].map((group) => ({ ...group.resource, scopeSpans: [...group.scopes.values()].map((scope) => ({ ...scope })) })) });
|
|
211
|
+
groups = new Map();
|
|
212
|
+
count = 0;
|
|
213
|
+
bytes = 64;
|
|
214
|
+
};
|
|
215
|
+
for (const span of spans) {
|
|
216
|
+
let resource = span.resource ? resources.get(span.resource) : undefined;
|
|
217
|
+
if (!resource) {
|
|
218
|
+
const attributes = converter.attributes(span.resource?.attributes, false);
|
|
219
|
+
const value = { resource: { attributes }, ...(span.resource?.schemaUrl ? { schemaUrl: span.resource.schemaUrl } : {}) };
|
|
220
|
+
resource = { key: JSON.stringify(value), resource: value };
|
|
221
|
+
if (span.resource)
|
|
222
|
+
resources.set(span.resource, resource);
|
|
223
|
+
}
|
|
224
|
+
const library = span.instrumentationScope ?? span.instrumentationLibrary;
|
|
225
|
+
const scope = { name: library?.name || SCOPE, ...(library?.version ? { version: library.version } : {}) };
|
|
226
|
+
const scopeKey = JSON.stringify([scope, library?.schemaUrl ?? ""]);
|
|
227
|
+
let item = converter.span(span);
|
|
228
|
+
let size = byteLength(item) + 1;
|
|
229
|
+
const overhead = byteLength(resource.resource) + byteLength(scope) + 64;
|
|
230
|
+
if (size + overhead > maxBytes) {
|
|
231
|
+
item = shrink(item, maxBytes - overhead);
|
|
232
|
+
size = byteLength(item) + 1;
|
|
233
|
+
}
|
|
234
|
+
const group = groups.get(resource.key), fresh = (group ? 0 : byteLength(resource.resource)) + (group?.scopes.has(scopeKey) ? 0 : byteLength(scope) + 32);
|
|
235
|
+
if (count && (count + 1 > maxSpans || bytes + size + fresh > maxBytes))
|
|
236
|
+
close();
|
|
237
|
+
if (!groups.has(resource.key)) {
|
|
238
|
+
groups.set(resource.key, { resource: resource.resource, scopes: new Map() });
|
|
239
|
+
bytes += byteLength(resource.resource) + 32;
|
|
240
|
+
}
|
|
241
|
+
const scopes = groups.get(resource.key).scopes;
|
|
242
|
+
if (!scopes.has(scopeKey)) {
|
|
243
|
+
scopes.set(scopeKey, { scope, ...(library?.schemaUrl ? { schemaUrl: library.schemaUrl } : {}), spans: [] });
|
|
244
|
+
bytes += byteLength(scope) + 32;
|
|
245
|
+
}
|
|
246
|
+
scopes.get(scopeKey).spans.push(item);
|
|
247
|
+
count++;
|
|
248
|
+
bytes += size;
|
|
249
|
+
}
|
|
250
|
+
close();
|
|
251
|
+
return requests;
|
|
252
|
+
}
|
|
253
|
+
/** An OpenTelemetry SpanExporter that sends to the Black Box (see the top of this file). Never throws into the app. */
|
|
254
|
+
export class BlackBoxSpanExporter {
|
|
255
|
+
constructor(client, options = {}) {
|
|
256
|
+
this.stats = { spans: 0, requests: 0, failedSpans: 0, rejectedSpans: 0 };
|
|
257
|
+
this.pending = new Set();
|
|
258
|
+
this.stopped = false;
|
|
259
|
+
const send = options.send ?? (client ? (request) => client.blackbox.sendOtlp(request) : null);
|
|
260
|
+
if (!send)
|
|
261
|
+
throw new Error("BlackBoxSpanExporter needs a Dyadic client or options.send");
|
|
262
|
+
this.send = send;
|
|
263
|
+
this.options = { ...options };
|
|
264
|
+
}
|
|
265
|
+
export(spans, resultCallback) {
|
|
266
|
+
if (this.stopped) {
|
|
267
|
+
resultCallback({ code: 1, error: new Error("BlackBoxSpanExporter has been shut down") });
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
270
|
+
if (!spans?.length) {
|
|
271
|
+
resultCallback({ code: 0 });
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
274
|
+
const task = Promise.resolve().then(() => this.deliver(spans)).then(() => resultCallback({ code: 0 }), (error) => resultCallback({ code: 1, error: error instanceof Error ? error : new Error(String(error)) }));
|
|
275
|
+
this.pending.add(task);
|
|
276
|
+
void task.finally(() => this.pending.delete(task));
|
|
277
|
+
}
|
|
278
|
+
async deliver(spans) {
|
|
279
|
+
let failure = null;
|
|
280
|
+
for (const request of toOtlpRequests(spans, this.options)) {
|
|
281
|
+
const size = request.resourceSpans.reduce((n, r) => n + r.scopeSpans.reduce((m, s) => m + s.spans.length, 0), 0);
|
|
282
|
+
try {
|
|
283
|
+
const answer = await this.send(request);
|
|
284
|
+
this.stats.requests++;
|
|
285
|
+
this.stats.spans += size;
|
|
286
|
+
this.stats.rejectedSpans += Number(answer?.partialSuccess?.rejectedSpans ?? 0) || 0;
|
|
287
|
+
}
|
|
288
|
+
catch (error) {
|
|
289
|
+
this.stats.failedSpans += size;
|
|
290
|
+
this.options.onError?.(error, request);
|
|
291
|
+
failure ?? (failure = error);
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
if (failure !== null)
|
|
295
|
+
throw failure;
|
|
296
|
+
}
|
|
297
|
+
/** Resolves when every export in flight has been sent (or given up on). */
|
|
298
|
+
async forceFlush() {
|
|
299
|
+
await Promise.allSettled([...this.pending]);
|
|
300
|
+
}
|
|
301
|
+
async shutdown() {
|
|
302
|
+
this.stopped = true;
|
|
303
|
+
await this.forceFlush();
|
|
304
|
+
}
|
|
305
|
+
}
|
package/package.json
CHANGED
|
@@ -1,15 +1,15 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dyadic",
|
|
3
|
-
"version": "1.
|
|
4
|
-
"description": "The official TypeScript and JavaScript SDK for the Dyadic API: runs with your own agents and functions, plus the OpenAI-compatible
|
|
3
|
+
"version": "1.7.0",
|
|
4
|
+
"description": "The official TypeScript and JavaScript SDK for the Dyadic API: runs with your own agents and functions, plus the OpenAI-, Anthropic- and OpenRouter-compatible APIs.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
7
7
|
"types": "./dist/index.d.ts",
|
|
8
8
|
"exports": { ".": { "types": "./dist/index.d.ts", "import": "./dist/index.js" } },
|
|
9
|
-
"files": ["dist", "README.md", "CHANGELOG.md"],
|
|
9
|
+
"files": ["dist", "README.md", "CHANGELOG.md", "LICENSE"],
|
|
10
10
|
"engines": { "node": ">=18" },
|
|
11
11
|
"license": "MIT",
|
|
12
12
|
"homepage": "https://dyadic.app/developers/",
|
|
13
|
-
"scripts": { "build": "tsc", "pretest": "tsc", "test": "node --test test/client.test.js test/transport.test.js", "prepack": "tsc" },
|
|
14
|
-
"devDependencies": { "typescript": "5.6.3" }
|
|
13
|
+
"scripts": { "build": "tsc", "pretest": "tsc", "test": "node --test test/client.test.js test/transport.test.js test/blackbox.test.js test/otel.test.js test/api.test.js test/live.test.js test/plans.test.js test/compatible.test.js", "test:production": "tsc && node --test live_tests/production.test.js", "prepack": "tsc" },
|
|
14
|
+
"devDependencies": { "@opentelemetry/api": "1.9.1", "@opentelemetry/sdk-trace-base": "2.12.0", "typescript": "5.6.3" }
|
|
15
15
|
}
|