assistant-cloud 0.1.42 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +40 -0
- package/dist/AssistantCloud.d.ts +4 -0
- package/dist/AssistantCloud.d.ts.map +1 -1
- package/dist/AssistantCloud.js +11 -5
- package/dist/AssistantCloud.js.map +1 -1
- package/dist/AssistantCloudAPI.d.ts +17 -1
- package/dist/AssistantCloudAPI.d.ts.map +1 -1
- package/dist/AssistantCloudAPI.js +20 -9
- package/dist/AssistantCloudAPI.js.map +1 -1
- package/dist/AssistantCloudAuthStrategy.d.ts +4 -1
- package/dist/AssistantCloudAuthStrategy.d.ts.map +1 -1
- package/dist/AssistantCloudAuthStrategy.js +79 -18
- package/dist/AssistantCloudAuthStrategy.js.map +1 -1
- package/dist/AssistantCloudEvents.d.ts +32 -0
- package/dist/AssistantCloudEvents.d.ts.map +1 -0
- package/dist/AssistantCloudEvents.js +115 -0
- package/dist/AssistantCloudEvents.js.map +1 -0
- package/dist/AssistantCloudFiles.d.ts +12 -1
- package/dist/AssistantCloudFiles.d.ts.map +1 -1
- package/dist/AssistantCloudFiles.js +13 -1
- package/dist/AssistantCloudFiles.js.map +1 -1
- package/dist/AssistantCloudRuns.d.ts +11 -0
- package/dist/AssistantCloudRuns.d.ts.map +1 -1
- package/dist/AssistantCloudRuns.js.map +1 -1
- package/dist/AssistantCloudScores.d.ts +27 -0
- package/dist/AssistantCloudScores.d.ts.map +1 -0
- package/dist/AssistantCloudScores.js +30 -0
- package/dist/AssistantCloudScores.js.map +1 -0
- package/dist/AssistantCloudThreadMessages.d.ts +11 -1
- package/dist/AssistantCloudThreadMessages.d.ts.map +1 -1
- package/dist/AssistantCloudThreadMessages.js +12 -1
- package/dist/AssistantCloudThreadMessages.js.map +1 -1
- package/dist/AssistantCloudThreads.d.ts +8 -0
- package/dist/AssistantCloudThreads.d.ts.map +1 -1
- package/dist/AssistantCloudThreads.js +9 -1
- package/dist/AssistantCloudThreads.js.map +1 -1
- package/dist/CloudMessagePersistence.d.ts +4 -0
- package/dist/CloudMessagePersistence.d.ts.map +1 -1
- package/dist/CloudMessagePersistence.js +53 -18
- package/dist/CloudMessagePersistence.js.map +1 -1
- package/dist/cloudResponse.d.ts +4 -1
- package/dist/cloudResponse.d.ts.map +1 -1
- package/dist/cloudResponse.js +14 -1
- package/dist/cloudResponse.js.map +1 -1
- package/dist/index.d.ts +7 -3
- package/dist/index.js +5 -2
- package/dist/runTelemetry.d.ts +79 -9
- package/dist/runTelemetry.d.ts.map +1 -1
- package/dist/runTelemetry.js +152 -6
- package/dist/runTelemetry.js.map +1 -1
- package/dist/telemetry/index.d.ts +32 -0
- package/dist/telemetry/index.d.ts.map +1 -0
- package/dist/telemetry/index.js +62 -0
- package/dist/telemetry/index.js.map +1 -0
- package/package.json +28 -4
- package/src/AssistantCloud.ts +20 -8
- package/src/AssistantCloudAPI.ts +42 -10
- package/src/AssistantCloudAuthStrategy.ts +153 -46
- package/src/AssistantCloudEvents.test.ts +124 -0
- package/src/AssistantCloudEvents.ts +185 -0
- package/src/AssistantCloudFiles.test.ts +59 -0
- package/src/AssistantCloudFiles.ts +39 -0
- package/src/AssistantCloudRuns.ts +12 -1
- package/src/AssistantCloudScores.test.ts +113 -0
- package/src/AssistantCloudScores.ts +61 -0
- package/src/AssistantCloudThreadMessages.test.ts +51 -0
- package/src/AssistantCloudThreadMessages.ts +33 -0
- package/src/AssistantCloudThreads.test.ts +21 -0
- package/src/AssistantCloudThreads.ts +21 -0
- package/src/CloudMessagePersistence.ts +65 -29
- package/src/cloudResponse.test.ts +34 -1
- package/src/cloudResponse.ts +30 -0
- package/src/index.ts +24 -1
- package/src/runTelemetry.test.ts +366 -0
- package/src/runTelemetry.ts +271 -16
- package/src/telemetry/index.test.ts +252 -0
- package/src/telemetry/index.ts +98 -0
- package/src/tests/AssistantCloud.test.ts +22 -0
- package/src/tests/AssistantCloudAPI.test.ts +44 -4
- package/src/tests/AssistantCloudAuthStrategy.test.ts +354 -11
- package/src/tests/CloudMessagePersistence.test.ts +208 -0
package/src/runTelemetry.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { SamplingCallData } from "./instrumentMcpSampling";
|
|
2
|
+
import type { AssistantCloudRunReport } from "./AssistantCloudRuns";
|
|
2
3
|
|
|
3
4
|
const MAX_TELEMETRY_TEXT_LENGTH = 50_000;
|
|
4
5
|
|
|
@@ -15,6 +16,86 @@ export type AssistantCloudRunReportToolCall = {
|
|
|
15
16
|
sampling_calls?: SamplingCallData[];
|
|
16
17
|
};
|
|
17
18
|
|
|
19
|
+
export type RunReportOutcome =
|
|
20
|
+
| "aborted"
|
|
21
|
+
| "disconnected"
|
|
22
|
+
| "length"
|
|
23
|
+
| "content_filter";
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Maps a finish event to the report status and outcome. `fallbackStatus`
|
|
27
|
+
* applies when the event carries neither a finish reason nor a failure flag.
|
|
28
|
+
*/
|
|
29
|
+
export function deriveRunOutcome(
|
|
30
|
+
input: {
|
|
31
|
+
finishReason?: string | undefined;
|
|
32
|
+
isAbort?: boolean | undefined;
|
|
33
|
+
isDisconnect?: boolean | undefined;
|
|
34
|
+
isError?: boolean | undefined;
|
|
35
|
+
},
|
|
36
|
+
fallbackStatus: "completed" | "incomplete" = "completed",
|
|
37
|
+
): {
|
|
38
|
+
status: "completed" | "incomplete" | "error";
|
|
39
|
+
outcome?: RunReportOutcome;
|
|
40
|
+
} {
|
|
41
|
+
if (input.isError) return { status: "error" };
|
|
42
|
+
if (input.isAbort) return { status: "incomplete", outcome: "aborted" };
|
|
43
|
+
if (input.isDisconnect) {
|
|
44
|
+
return { status: "incomplete", outcome: "disconnected" };
|
|
45
|
+
}
|
|
46
|
+
switch (input.finishReason) {
|
|
47
|
+
case "length":
|
|
48
|
+
return { status: "incomplete", outcome: "length" };
|
|
49
|
+
case "content-filter":
|
|
50
|
+
case "content_filter":
|
|
51
|
+
return { status: "incomplete", outcome: "content_filter" };
|
|
52
|
+
case "cancelled":
|
|
53
|
+
return { status: "incomplete", outcome: "aborted" };
|
|
54
|
+
case "error":
|
|
55
|
+
return { status: "error" };
|
|
56
|
+
default:
|
|
57
|
+
return {
|
|
58
|
+
status: input.finishReason === undefined ? fallbackStatus : "completed",
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const MAX_RUN_ERROR_CODE_LENGTH = 64;
|
|
64
|
+
const MAX_RUN_ERROR_LENGTH = 2048;
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Reads the message and code the runs endpoint stores for a failed run. The
|
|
68
|
+
* code is the error's `code` when it has one, else its class name.
|
|
69
|
+
*/
|
|
70
|
+
export function describeRunError(error: unknown): {
|
|
71
|
+
error?: string;
|
|
72
|
+
errorCode?: string;
|
|
73
|
+
} {
|
|
74
|
+
if (error == null) return {};
|
|
75
|
+
const record =
|
|
76
|
+
typeof error === "object" ? (error as Record<string, unknown>) : undefined;
|
|
77
|
+
const message =
|
|
78
|
+
typeof error === "string"
|
|
79
|
+
? error
|
|
80
|
+
: typeof record?.message === "string"
|
|
81
|
+
? record.message
|
|
82
|
+
: undefined;
|
|
83
|
+
const code =
|
|
84
|
+
typeof record?.code === "string"
|
|
85
|
+
? record.code
|
|
86
|
+
: typeof record?.name === "string" && record.name !== "Error"
|
|
87
|
+
? record.name
|
|
88
|
+
: undefined;
|
|
89
|
+
return {
|
|
90
|
+
...(message
|
|
91
|
+
? { error: message.slice(0, MAX_RUN_ERROR_LENGTH) }
|
|
92
|
+
: undefined),
|
|
93
|
+
...(code
|
|
94
|
+
? { errorCode: code.slice(0, MAX_RUN_ERROR_CODE_LENGTH) }
|
|
95
|
+
: undefined),
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
18
99
|
/**
|
|
19
100
|
* Clamps a string to the size the runs endpoint accepts for a single span
|
|
20
101
|
* field.
|
|
@@ -95,21 +176,194 @@ export function createRunTelemetryToolCall(
|
|
|
95
176
|
return call;
|
|
96
177
|
}
|
|
97
178
|
|
|
179
|
+
/**
|
|
180
|
+
* Resolves the model ID a run reports, in the order an app can supply it: an
|
|
181
|
+
* explicit `modelId`, the `custom` bag, then the per-step `response.modelId`
|
|
182
|
+
* that a `messageMetadata` callback copies off the AI SDK's finish-step part.
|
|
183
|
+
* The AI SDK puts no model ID on a UI message part, so message metadata is the
|
|
184
|
+
* only channel one arrives on.
|
|
185
|
+
*/
|
|
186
|
+
export function extractRunTelemetryModelId(
|
|
187
|
+
metadata: Record<string, unknown> | undefined,
|
|
188
|
+
): string | undefined {
|
|
189
|
+
if (!metadata) return undefined;
|
|
190
|
+
if (typeof metadata.modelId === "string") return metadata.modelId;
|
|
191
|
+
const custom = metadata.custom as Record<string, unknown> | undefined;
|
|
192
|
+
if (typeof custom?.modelId === "string") return custom.modelId;
|
|
193
|
+
|
|
194
|
+
const steps: unknown = metadata.steps;
|
|
195
|
+
if (!Array.isArray(steps)) return undefined;
|
|
196
|
+
for (const step of steps as unknown[]) {
|
|
197
|
+
if (!step || typeof step !== "object") continue;
|
|
198
|
+
const response = (step as Record<string, unknown>).response;
|
|
199
|
+
if (!response || typeof response !== "object") continue;
|
|
200
|
+
const modelId = (response as Record<string, unknown>).modelId;
|
|
201
|
+
if (typeof modelId === "string") return modelId;
|
|
202
|
+
}
|
|
203
|
+
return undefined;
|
|
204
|
+
}
|
|
205
|
+
|
|
98
206
|
export type RunTelemetryUsage = {
|
|
99
|
-
inputTokens?: number;
|
|
100
|
-
outputTokens?: number;
|
|
101
|
-
reasoningTokens?: number;
|
|
102
|
-
cachedInputTokens?: number;
|
|
207
|
+
inputTokens?: number | undefined;
|
|
208
|
+
outputTokens?: number | undefined;
|
|
209
|
+
reasoningTokens?: number | undefined;
|
|
210
|
+
cachedInputTokens?: number | undefined;
|
|
103
211
|
};
|
|
104
212
|
|
|
105
213
|
export type RunTelemetryUsageInit = RunTelemetryUsage & {
|
|
106
|
-
promptTokens?: number;
|
|
107
|
-
completionTokens?: number;
|
|
214
|
+
promptTokens?: number | undefined;
|
|
215
|
+
completionTokens?: number | undefined;
|
|
216
|
+
inputTokenDetails?: { cacheReadTokens?: number };
|
|
217
|
+
outputTokenDetails?: { reasoningTokens?: number };
|
|
218
|
+
};
|
|
219
|
+
|
|
220
|
+
export type RunReportStepInit = {
|
|
221
|
+
usage?: RunTelemetryUsageInit | undefined;
|
|
222
|
+
toolCalls?: AssistantCloudRunReportToolCall[] | undefined;
|
|
223
|
+
startMs?: number | undefined;
|
|
224
|
+
endMs?: number | undefined;
|
|
225
|
+
finishReason?: string | undefined;
|
|
226
|
+
};
|
|
227
|
+
|
|
228
|
+
export type RunReportInit = {
|
|
229
|
+
threadId: string;
|
|
230
|
+
status: AssistantCloudRunReport["status"];
|
|
231
|
+
outcome?: RunReportOutcome | undefined;
|
|
232
|
+
errorCode?: string | undefined;
|
|
233
|
+
error?: string | undefined;
|
|
234
|
+
messageId?: string | undefined;
|
|
235
|
+
traceId?: string | undefined;
|
|
236
|
+
modelId?: string | undefined;
|
|
237
|
+
provider?: string | undefined;
|
|
238
|
+
usage?: RunTelemetryUsageInit | undefined;
|
|
239
|
+
steps?: RunReportStepInit[] | undefined;
|
|
240
|
+
totalSteps?: number | undefined;
|
|
241
|
+
toolCalls?: AssistantCloudRunReportToolCall[] | undefined;
|
|
242
|
+
durationMs?: number | undefined;
|
|
243
|
+
firstTokenMs?: number | undefined;
|
|
244
|
+
outputText?: string | undefined;
|
|
245
|
+
metadata?: Record<string, unknown> | undefined;
|
|
246
|
+
telemetry?: {
|
|
247
|
+
environment?: string | undefined;
|
|
248
|
+
release?: string | undefined;
|
|
249
|
+
tags?: readonly string[] | undefined;
|
|
250
|
+
};
|
|
108
251
|
};
|
|
109
252
|
|
|
253
|
+
function assignUsage(
|
|
254
|
+
report: AssistantCloudRunReport,
|
|
255
|
+
usage: RunTelemetryUsageInit | undefined,
|
|
256
|
+
): void {
|
|
257
|
+
if (!usage) return;
|
|
258
|
+
const normalized = normalizeRunTelemetryUsage(usage);
|
|
259
|
+
if (!normalized) return;
|
|
260
|
+
if (normalized.inputTokens !== undefined) {
|
|
261
|
+
report.input_tokens = normalized.inputTokens;
|
|
262
|
+
}
|
|
263
|
+
if (normalized.outputTokens !== undefined) {
|
|
264
|
+
report.output_tokens = normalized.outputTokens;
|
|
265
|
+
}
|
|
266
|
+
if (normalized.reasoningTokens !== undefined) {
|
|
267
|
+
report.reasoning_tokens = normalized.reasoningTokens;
|
|
268
|
+
}
|
|
269
|
+
if (normalized.cachedInputTokens !== undefined) {
|
|
270
|
+
report.cached_input_tokens = normalized.cachedInputTokens;
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function createRunReportStep(
|
|
275
|
+
init: RunReportStepInit,
|
|
276
|
+
): NonNullable<AssistantCloudRunReport["steps"]>[number] {
|
|
277
|
+
const step: NonNullable<AssistantCloudRunReport["steps"]>[number] = {};
|
|
278
|
+
const usage = init.usage ? normalizeRunTelemetryUsage(init.usage) : undefined;
|
|
279
|
+
if (usage?.inputTokens !== undefined) step.input_tokens = usage.inputTokens;
|
|
280
|
+
if (usage?.outputTokens !== undefined)
|
|
281
|
+
step.output_tokens = usage.outputTokens;
|
|
282
|
+
if (usage?.reasoningTokens !== undefined) {
|
|
283
|
+
step.reasoning_tokens = usage.reasoningTokens;
|
|
284
|
+
}
|
|
285
|
+
if (usage?.cachedInputTokens !== undefined) {
|
|
286
|
+
step.cached_input_tokens = usage.cachedInputTokens;
|
|
287
|
+
}
|
|
288
|
+
if (init.toolCalls !== undefined) step.tool_calls = init.toolCalls;
|
|
289
|
+
if (init.startMs !== undefined) step.start_ms = init.startMs;
|
|
290
|
+
if (init.endMs !== undefined) step.end_ms = init.endMs;
|
|
291
|
+
if (init.finishReason !== undefined) {
|
|
292
|
+
step.finish_reason = init.finishReason.slice(0, 32);
|
|
293
|
+
}
|
|
294
|
+
return step;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function normalizeRunReportTags(
|
|
298
|
+
tags: readonly string[] | undefined,
|
|
299
|
+
): string[] | undefined {
|
|
300
|
+
if (!tags) return undefined;
|
|
301
|
+
const result: string[] = [];
|
|
302
|
+
const seen = new Set<string>();
|
|
303
|
+
for (const tag of tags) {
|
|
304
|
+
const normalized = tag.trim().slice(0, 64).trim();
|
|
305
|
+
if (!normalized || seen.has(normalized)) continue;
|
|
306
|
+
seen.add(normalized);
|
|
307
|
+
result.push(normalized);
|
|
308
|
+
if (result.length === 20) break;
|
|
309
|
+
}
|
|
310
|
+
return result.length > 0 ? result : undefined;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
function normalizeRunReportMilliseconds(
|
|
314
|
+
value: number | undefined,
|
|
315
|
+
): number | undefined {
|
|
316
|
+
if (value === undefined || !Number.isFinite(value)) return undefined;
|
|
317
|
+
return Math.max(0, Math.round(value));
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
export function createRunReport(init: RunReportInit): AssistantCloudRunReport {
|
|
321
|
+
const report: AssistantCloudRunReport = {
|
|
322
|
+
thread_id: init.threadId,
|
|
323
|
+
status: init.status,
|
|
324
|
+
};
|
|
325
|
+
const traceId = init.traceId?.toLowerCase();
|
|
326
|
+
if (traceId && /^[0-9a-f]{32}$/.test(traceId)) report.trace_id = traceId;
|
|
327
|
+
if (init.outcome !== undefined) report.outcome_type = init.outcome;
|
|
328
|
+
if (init.errorCode !== undefined) report.error_code = init.errorCode;
|
|
329
|
+
if (init.error !== undefined) report.error = init.error;
|
|
330
|
+
if (init.messageId !== undefined) report.message_id = init.messageId;
|
|
331
|
+
if (init.modelId !== undefined) report.model_id = init.modelId;
|
|
332
|
+
if (init.provider !== undefined) {
|
|
333
|
+
report.provider = init.provider;
|
|
334
|
+
report.provider_type = init.provider;
|
|
335
|
+
}
|
|
336
|
+
assignUsage(report, init.usage);
|
|
337
|
+
if (init.steps !== undefined) {
|
|
338
|
+
report.steps = init.steps.map(createRunReportStep);
|
|
339
|
+
report.total_steps = init.steps.length;
|
|
340
|
+
} else if (init.totalSteps !== undefined) {
|
|
341
|
+
report.total_steps = init.totalSteps;
|
|
342
|
+
}
|
|
343
|
+
if (init.toolCalls !== undefined) report.tool_calls = init.toolCalls;
|
|
344
|
+
const durationMs = normalizeRunReportMilliseconds(init.durationMs);
|
|
345
|
+
if (durationMs !== undefined) report.duration_ms = durationMs;
|
|
346
|
+
const firstTokenMs = normalizeRunReportMilliseconds(init.firstTokenMs);
|
|
347
|
+
if (firstTokenMs !== undefined) report.first_token_ms = firstTokenMs;
|
|
348
|
+
if (init.outputText !== undefined) {
|
|
349
|
+
report.output_text = truncateRunTelemetryText(init.outputText);
|
|
350
|
+
}
|
|
351
|
+
if (init.metadata !== undefined) report.metadata = init.metadata;
|
|
352
|
+
if (init.telemetry?.environment !== undefined) {
|
|
353
|
+
report.environment = init.telemetry.environment;
|
|
354
|
+
}
|
|
355
|
+
if (init.telemetry?.release !== undefined) {
|
|
356
|
+
report.release = init.telemetry.release;
|
|
357
|
+
}
|
|
358
|
+
const tags = normalizeRunReportTags(init.telemetry?.tags);
|
|
359
|
+
if (tags !== undefined) report.tags = tags;
|
|
360
|
+
return report;
|
|
361
|
+
}
|
|
362
|
+
|
|
110
363
|
/**
|
|
111
|
-
* Resolves the token counts a provider reports under
|
|
112
|
-
*
|
|
364
|
+
* Resolves the token counts a provider reports under any of the names the AI
|
|
365
|
+
* SDK has used: the current top-level ones, the legacy prompt/completion pair,
|
|
366
|
+
* and the v7 token detail objects. Returns undefined when no count is present,
|
|
113
367
|
* so callers can tell an empty usage object from a zeroed one.
|
|
114
368
|
*/
|
|
115
369
|
export function normalizeRunTelemetryUsage(
|
|
@@ -117,12 +371,17 @@ export function normalizeRunTelemetryUsage(
|
|
|
117
371
|
): RunTelemetryUsage | undefined {
|
|
118
372
|
const inputTokens = usage.inputTokens ?? usage.promptTokens;
|
|
119
373
|
const outputTokens = usage.outputTokens ?? usage.completionTokens;
|
|
374
|
+
// AI SDK v7 moved these under token detail objects; v6 kept them top-level.
|
|
375
|
+
const reasoningTokens =
|
|
376
|
+
usage.reasoningTokens ?? usage.outputTokenDetails?.reasoningTokens;
|
|
377
|
+
const cachedInputTokens =
|
|
378
|
+
usage.cachedInputTokens ?? usage.inputTokenDetails?.cacheReadTokens;
|
|
120
379
|
|
|
121
380
|
if (
|
|
122
381
|
inputTokens == null &&
|
|
123
382
|
outputTokens == null &&
|
|
124
|
-
|
|
125
|
-
|
|
383
|
+
reasoningTokens == null &&
|
|
384
|
+
cachedInputTokens == null
|
|
126
385
|
) {
|
|
127
386
|
return undefined;
|
|
128
387
|
}
|
|
@@ -130,11 +389,7 @@ export function normalizeRunTelemetryUsage(
|
|
|
130
389
|
return {
|
|
131
390
|
...(inputTokens != null ? { inputTokens } : undefined),
|
|
132
391
|
...(outputTokens != null ? { outputTokens } : undefined),
|
|
133
|
-
...(
|
|
134
|
-
|
|
135
|
-
: undefined),
|
|
136
|
-
...(usage.cachedInputTokens != null
|
|
137
|
-
? { cachedInputTokens: usage.cachedInputTokens }
|
|
138
|
-
: undefined),
|
|
392
|
+
...(reasoningTokens != null ? { reasoningTokens } : undefined),
|
|
393
|
+
...(cachedInputTokens != null ? { cachedInputTokens } : undefined),
|
|
139
394
|
};
|
|
140
395
|
}
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
import {
|
|
2
|
+
ROOT_CONTEXT,
|
|
3
|
+
context,
|
|
4
|
+
trace,
|
|
5
|
+
type Context,
|
|
6
|
+
type ContextManager,
|
|
7
|
+
} from "@opentelemetry/api";
|
|
8
|
+
import type { ReadableSpan, SpanExporter } from "@opentelemetry/sdk-trace-base";
|
|
9
|
+
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
|
|
10
|
+
import {
|
|
11
|
+
assistantCloudTraceExportOptions,
|
|
12
|
+
assistantCloudTraceMetadata,
|
|
13
|
+
createAssistantCloudSpanProcessor,
|
|
14
|
+
isAssistantCloudSpan,
|
|
15
|
+
withAssistantCloudTraceMetadata,
|
|
16
|
+
} from "./index";
|
|
17
|
+
|
|
18
|
+
const TRACE_ID = "0123456789abcdef0123456789abcdef";
|
|
19
|
+
const SPAN_ID = "0123456789abcdef";
|
|
20
|
+
|
|
21
|
+
class TestContextManager implements ContextManager {
|
|
22
|
+
private activeContext: Context = ROOT_CONTEXT;
|
|
23
|
+
|
|
24
|
+
active(): Context {
|
|
25
|
+
return this.activeContext;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
with<A extends unknown[], F extends (...args: A) => ReturnType<F>>(
|
|
29
|
+
nextContext: Context,
|
|
30
|
+
fn: F,
|
|
31
|
+
thisArg?: ThisParameterType<F>,
|
|
32
|
+
...args: A
|
|
33
|
+
): ReturnType<F> {
|
|
34
|
+
const previousContext = this.activeContext;
|
|
35
|
+
this.activeContext = nextContext;
|
|
36
|
+
try {
|
|
37
|
+
return fn.call(thisArg, ...args);
|
|
38
|
+
} finally {
|
|
39
|
+
this.activeContext = previousContext;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
bind<T>(_context: Context, target: T): T {
|
|
44
|
+
return target;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
enable(): this {
|
|
48
|
+
return this;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
disable(): this {
|
|
52
|
+
this.activeContext = ROOT_CONTEXT;
|
|
53
|
+
return this;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function span(
|
|
58
|
+
name: string,
|
|
59
|
+
attributes: Record<string, unknown> = {},
|
|
60
|
+
): ReadableSpan {
|
|
61
|
+
return {
|
|
62
|
+
name,
|
|
63
|
+
attributes,
|
|
64
|
+
spanContext: () => ({
|
|
65
|
+
traceId: TRACE_ID,
|
|
66
|
+
spanId: SPAN_ID,
|
|
67
|
+
traceFlags: 1,
|
|
68
|
+
}),
|
|
69
|
+
resource: { asyncAttributesPending: false },
|
|
70
|
+
} as ReadableSpan;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function recordingExporter() {
|
|
74
|
+
const spans: ReadableSpan[] = [];
|
|
75
|
+
const exporter = {
|
|
76
|
+
export: vi.fn(
|
|
77
|
+
(
|
|
78
|
+
nextSpans: ReadableSpan[],
|
|
79
|
+
resultCallback: Parameters<SpanExporter["export"]>[1],
|
|
80
|
+
) => {
|
|
81
|
+
spans.push(...nextSpans);
|
|
82
|
+
resultCallback({ code: 0 });
|
|
83
|
+
},
|
|
84
|
+
),
|
|
85
|
+
shutdown: vi.fn().mockResolvedValue(undefined),
|
|
86
|
+
} satisfies SpanExporter;
|
|
87
|
+
|
|
88
|
+
return { exporter, spans };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
beforeAll(() => {
|
|
92
|
+
context.disable();
|
|
93
|
+
context.setGlobalContextManager(new TestContextManager());
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
afterAll(() => {
|
|
97
|
+
context.disable();
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
describe("assistantCloudTraceExportOptions", () => {
|
|
101
|
+
it("uses the default receiver and authorization header", () => {
|
|
102
|
+
expect(assistantCloudTraceExportOptions({ apiKey: "key" })).toEqual({
|
|
103
|
+
url: "https://backend.assistant-api.com/v1/traces",
|
|
104
|
+
headers: { Authorization: "Bearer key" },
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it("uses a custom receiver without a trailing slash", () => {
|
|
109
|
+
expect(
|
|
110
|
+
assistantCloudTraceExportOptions({
|
|
111
|
+
apiKey: "key",
|
|
112
|
+
baseUrl: "https://cloud.example.com",
|
|
113
|
+
}),
|
|
114
|
+
).toEqual({
|
|
115
|
+
url: "https://cloud.example.com/v1/traces",
|
|
116
|
+
headers: { Authorization: "Bearer key" },
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it("removes trailing slashes and preserves extra headers", () => {
|
|
121
|
+
expect(
|
|
122
|
+
assistantCloudTraceExportOptions({
|
|
123
|
+
apiKey: "key",
|
|
124
|
+
baseUrl: "https://cloud.example.com/",
|
|
125
|
+
headers: { "X-Workspace": "workspace_1" },
|
|
126
|
+
}),
|
|
127
|
+
).toEqual({
|
|
128
|
+
url: "https://cloud.example.com/v1/traces",
|
|
129
|
+
headers: {
|
|
130
|
+
Authorization: "Bearer key",
|
|
131
|
+
"X-Workspace": "workspace_1",
|
|
132
|
+
},
|
|
133
|
+
});
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it("rejects an empty API key", () => {
|
|
137
|
+
expect(() => assistantCloudTraceExportOptions({ apiKey: "" })).toThrow(
|
|
138
|
+
"An Assistant Cloud API key is required",
|
|
139
|
+
);
|
|
140
|
+
});
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
describe("isAssistantCloudSpan", () => {
|
|
144
|
+
it("accepts AI span name prefixes", () => {
|
|
145
|
+
expect(isAssistantCloudSpan(span("gen_ai.client"))).toBe(true);
|
|
146
|
+
expect(isAssistantCloudSpan(span("ai.streamText"))).toBe(true);
|
|
147
|
+
expect(isAssistantCloudSpan(span("llm.generate"))).toBe(true);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it("accepts the GenAI operation attribute", () => {
|
|
151
|
+
expect(
|
|
152
|
+
isAssistantCloudSpan(
|
|
153
|
+
span("generate", { "gen_ai.operation.name": "ai.generateText" }),
|
|
154
|
+
),
|
|
155
|
+
).toBe(true);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it("rejects ordinary spans", () => {
|
|
159
|
+
expect(isAssistantCloudSpan(span("GET /api/chat"))).toBe(false);
|
|
160
|
+
});
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
describe("createAssistantCloudSpanProcessor", () => {
|
|
164
|
+
it("exports matching spans and drops other spans", async () => {
|
|
165
|
+
const { exporter, spans } = recordingExporter();
|
|
166
|
+
const processor = createAssistantCloudSpanProcessor(exporter);
|
|
167
|
+
|
|
168
|
+
processor.onEnd(span("gen_ai.client"));
|
|
169
|
+
processor.onEnd(span("GET /api/chat"));
|
|
170
|
+
await processor.forceFlush();
|
|
171
|
+
|
|
172
|
+
expect(spans.map((candidate) => candidate.name)).toEqual(["gen_ai.client"]);
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it("uses a custom filter when one is provided", async () => {
|
|
176
|
+
const { exporter, spans } = recordingExporter();
|
|
177
|
+
const processor = createAssistantCloudSpanProcessor(exporter, {
|
|
178
|
+
filter: (candidate) => candidate.name === "custom",
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
processor.onEnd(span("gen_ai.client"));
|
|
182
|
+
processor.onEnd(span("custom"));
|
|
183
|
+
await processor.forceFlush();
|
|
184
|
+
|
|
185
|
+
expect(spans.map((candidate) => candidate.name)).toEqual(["custom"]);
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it("flushes and shuts down the wrapped processor", async () => {
|
|
189
|
+
const { exporter } = recordingExporter();
|
|
190
|
+
const processor = createAssistantCloudSpanProcessor(exporter);
|
|
191
|
+
|
|
192
|
+
await processor.forceFlush();
|
|
193
|
+
await processor.shutdown();
|
|
194
|
+
|
|
195
|
+
expect(exporter.shutdown).toHaveBeenCalledTimes(1);
|
|
196
|
+
});
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
describe("assistantCloudTraceMetadata", () => {
|
|
200
|
+
it("returns the active trace ID", () => {
|
|
201
|
+
const result = context.with(
|
|
202
|
+
trace.setSpanContext(context.active(), {
|
|
203
|
+
traceId: TRACE_ID,
|
|
204
|
+
spanId: SPAN_ID,
|
|
205
|
+
traceFlags: 1,
|
|
206
|
+
}),
|
|
207
|
+
assistantCloudTraceMetadata,
|
|
208
|
+
);
|
|
209
|
+
|
|
210
|
+
expect(result).toEqual({ traceId: TRACE_ID });
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
it("returns an empty object without an active span", () => {
|
|
214
|
+
expect(assistantCloudTraceMetadata()).toEqual({});
|
|
215
|
+
});
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
describe("withAssistantCloudTraceMetadata", () => {
|
|
219
|
+
it("merges the active trace ID into start metadata", () => {
|
|
220
|
+
const messageMetadata = withAssistantCloudTraceMetadata(({ part }) => ({
|
|
221
|
+
type: part.type,
|
|
222
|
+
}));
|
|
223
|
+
|
|
224
|
+
const result = context.with(
|
|
225
|
+
trace.setSpanContext(context.active(), {
|
|
226
|
+
traceId: TRACE_ID,
|
|
227
|
+
spanId: SPAN_ID,
|
|
228
|
+
traceFlags: 1,
|
|
229
|
+
}),
|
|
230
|
+
() => messageMetadata({ part: { type: "start" } }),
|
|
231
|
+
);
|
|
232
|
+
|
|
233
|
+
expect(result).toEqual({ type: "start", traceId: TRACE_ID });
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
it("passes through start metadata when no span is active", () => {
|
|
237
|
+
const value = { started: true };
|
|
238
|
+
const messageMetadata = withAssistantCloudTraceMetadata(() => value);
|
|
239
|
+
|
|
240
|
+
expect(messageMetadata({ part: { type: "start" } })).toBe(value);
|
|
241
|
+
expect(withAssistantCloudTraceMetadata()({ part: { type: "start" } })).toBe(
|
|
242
|
+
undefined,
|
|
243
|
+
);
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
it("passes through metadata for other parts", () => {
|
|
247
|
+
const value = { usage: { totalTokens: 42 } };
|
|
248
|
+
const messageMetadata = withAssistantCloudTraceMetadata(() => value);
|
|
249
|
+
|
|
250
|
+
expect(messageMetadata({ part: { type: "finish" } })).toBe(value);
|
|
251
|
+
});
|
|
252
|
+
});
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { trace } from "@opentelemetry/api";
|
|
2
|
+
import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-http";
|
|
3
|
+
import {
|
|
4
|
+
BatchSpanProcessor,
|
|
5
|
+
type ReadableSpan,
|
|
6
|
+
type SpanExporter,
|
|
7
|
+
type SpanProcessor,
|
|
8
|
+
} from "@opentelemetry/sdk-trace-base";
|
|
9
|
+
|
|
10
|
+
const DEFAULT_BACKEND_BASE_URL = "https://backend.assistant-api.com";
|
|
11
|
+
const AI_SPAN_PREFIXES = ["gen_ai.", "ai.", "llm."];
|
|
12
|
+
|
|
13
|
+
export type AssistantCloudTraceExportOptions = {
|
|
14
|
+
apiKey: string;
|
|
15
|
+
baseUrl?: string;
|
|
16
|
+
headers?: Record<string, string>;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export type AssistantCloudSpanProcessorOptions = {
|
|
20
|
+
filter?: (span: ReadableSpan) => boolean;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export function assistantCloudTraceExportOptions({
|
|
24
|
+
apiKey,
|
|
25
|
+
baseUrl = DEFAULT_BACKEND_BASE_URL,
|
|
26
|
+
headers,
|
|
27
|
+
}: AssistantCloudTraceExportOptions): {
|
|
28
|
+
url: string;
|
|
29
|
+
headers: Record<string, string>;
|
|
30
|
+
} {
|
|
31
|
+
if (!apiKey) throw new Error("An Assistant Cloud API key is required");
|
|
32
|
+
|
|
33
|
+
return {
|
|
34
|
+
url: `${trimTrailingSlashes(baseUrl)}/v1/traces`,
|
|
35
|
+
headers: {
|
|
36
|
+
Authorization: `Bearer ${apiKey}`,
|
|
37
|
+
...headers,
|
|
38
|
+
},
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function trimTrailingSlashes(url: string): string {
|
|
43
|
+
let end = url.length;
|
|
44
|
+
while (end > 0 && url[end - 1] === "/") end--;
|
|
45
|
+
return url.slice(0, end);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function createAssistantCloudTraceExporter(
|
|
49
|
+
options: AssistantCloudTraceExportOptions,
|
|
50
|
+
): SpanExporter {
|
|
51
|
+
return new OTLPTraceExporter(assistantCloudTraceExportOptions(options));
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function isAssistantCloudSpan(span: ReadableSpan): boolean {
|
|
55
|
+
return (
|
|
56
|
+
typeof span.attributes["gen_ai.operation.name"] === "string" ||
|
|
57
|
+
AI_SPAN_PREFIXES.some((prefix) => span.name.startsWith(prefix))
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function createAssistantCloudSpanProcessor(
|
|
62
|
+
exporter: SpanExporter,
|
|
63
|
+
options: AssistantCloudSpanProcessorOptions = {},
|
|
64
|
+
): SpanProcessor {
|
|
65
|
+
const processor = new BatchSpanProcessor(exporter);
|
|
66
|
+
const filter = options.filter ?? isAssistantCloudSpan;
|
|
67
|
+
|
|
68
|
+
return {
|
|
69
|
+
onStart: (span, parentContext) => processor.onStart(span, parentContext),
|
|
70
|
+
onEnd: (span) => {
|
|
71
|
+
if (filter(span)) processor.onEnd(span);
|
|
72
|
+
},
|
|
73
|
+
forceFlush: () => processor.forceFlush(),
|
|
74
|
+
shutdown: () => processor.shutdown(),
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function assistantCloudTraceMetadata(): { traceId?: string } {
|
|
79
|
+
const spanContext = trace.getActiveSpan()?.spanContext();
|
|
80
|
+
return spanContext && trace.isSpanContextValid(spanContext)
|
|
81
|
+
? { traceId: spanContext.traceId }
|
|
82
|
+
: {};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function withAssistantCloudTraceMetadata<
|
|
86
|
+
Part extends { type: string } = { type: string },
|
|
87
|
+
>(
|
|
88
|
+
messageMetadata?: (options: {
|
|
89
|
+
part: Part;
|
|
90
|
+
}) => Record<string, unknown> | undefined,
|
|
91
|
+
): (options: { part: Part }) => Record<string, unknown> | undefined {
|
|
92
|
+
return (options) => {
|
|
93
|
+
const metadata = messageMetadata?.(options);
|
|
94
|
+
if (options.part.type !== "start") return metadata;
|
|
95
|
+
const trace = assistantCloudTraceMetadata();
|
|
96
|
+
return trace.traceId === undefined ? metadata : { ...metadata, ...trace };
|
|
97
|
+
};
|
|
98
|
+
}
|