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.
Files changed (81) hide show
  1. package/README.md +40 -0
  2. package/dist/AssistantCloud.d.ts +4 -0
  3. package/dist/AssistantCloud.d.ts.map +1 -1
  4. package/dist/AssistantCloud.js +11 -5
  5. package/dist/AssistantCloud.js.map +1 -1
  6. package/dist/AssistantCloudAPI.d.ts +17 -1
  7. package/dist/AssistantCloudAPI.d.ts.map +1 -1
  8. package/dist/AssistantCloudAPI.js +20 -9
  9. package/dist/AssistantCloudAPI.js.map +1 -1
  10. package/dist/AssistantCloudAuthStrategy.d.ts +4 -1
  11. package/dist/AssistantCloudAuthStrategy.d.ts.map +1 -1
  12. package/dist/AssistantCloudAuthStrategy.js +79 -18
  13. package/dist/AssistantCloudAuthStrategy.js.map +1 -1
  14. package/dist/AssistantCloudEvents.d.ts +32 -0
  15. package/dist/AssistantCloudEvents.d.ts.map +1 -0
  16. package/dist/AssistantCloudEvents.js +115 -0
  17. package/dist/AssistantCloudEvents.js.map +1 -0
  18. package/dist/AssistantCloudFiles.d.ts +12 -1
  19. package/dist/AssistantCloudFiles.d.ts.map +1 -1
  20. package/dist/AssistantCloudFiles.js +13 -1
  21. package/dist/AssistantCloudFiles.js.map +1 -1
  22. package/dist/AssistantCloudRuns.d.ts +11 -0
  23. package/dist/AssistantCloudRuns.d.ts.map +1 -1
  24. package/dist/AssistantCloudRuns.js.map +1 -1
  25. package/dist/AssistantCloudScores.d.ts +27 -0
  26. package/dist/AssistantCloudScores.d.ts.map +1 -0
  27. package/dist/AssistantCloudScores.js +30 -0
  28. package/dist/AssistantCloudScores.js.map +1 -0
  29. package/dist/AssistantCloudThreadMessages.d.ts +11 -1
  30. package/dist/AssistantCloudThreadMessages.d.ts.map +1 -1
  31. package/dist/AssistantCloudThreadMessages.js +12 -1
  32. package/dist/AssistantCloudThreadMessages.js.map +1 -1
  33. package/dist/AssistantCloudThreads.d.ts +8 -0
  34. package/dist/AssistantCloudThreads.d.ts.map +1 -1
  35. package/dist/AssistantCloudThreads.js +9 -1
  36. package/dist/AssistantCloudThreads.js.map +1 -1
  37. package/dist/CloudMessagePersistence.d.ts +4 -0
  38. package/dist/CloudMessagePersistence.d.ts.map +1 -1
  39. package/dist/CloudMessagePersistence.js +53 -18
  40. package/dist/CloudMessagePersistence.js.map +1 -1
  41. package/dist/cloudResponse.d.ts +4 -1
  42. package/dist/cloudResponse.d.ts.map +1 -1
  43. package/dist/cloudResponse.js +14 -1
  44. package/dist/cloudResponse.js.map +1 -1
  45. package/dist/index.d.ts +7 -3
  46. package/dist/index.js +5 -2
  47. package/dist/runTelemetry.d.ts +79 -9
  48. package/dist/runTelemetry.d.ts.map +1 -1
  49. package/dist/runTelemetry.js +152 -6
  50. package/dist/runTelemetry.js.map +1 -1
  51. package/dist/telemetry/index.d.ts +32 -0
  52. package/dist/telemetry/index.d.ts.map +1 -0
  53. package/dist/telemetry/index.js +62 -0
  54. package/dist/telemetry/index.js.map +1 -0
  55. package/package.json +28 -4
  56. package/src/AssistantCloud.ts +20 -8
  57. package/src/AssistantCloudAPI.ts +42 -10
  58. package/src/AssistantCloudAuthStrategy.ts +153 -46
  59. package/src/AssistantCloudEvents.test.ts +124 -0
  60. package/src/AssistantCloudEvents.ts +185 -0
  61. package/src/AssistantCloudFiles.test.ts +59 -0
  62. package/src/AssistantCloudFiles.ts +39 -0
  63. package/src/AssistantCloudRuns.ts +12 -1
  64. package/src/AssistantCloudScores.test.ts +113 -0
  65. package/src/AssistantCloudScores.ts +61 -0
  66. package/src/AssistantCloudThreadMessages.test.ts +51 -0
  67. package/src/AssistantCloudThreadMessages.ts +33 -0
  68. package/src/AssistantCloudThreads.test.ts +21 -0
  69. package/src/AssistantCloudThreads.ts +21 -0
  70. package/src/CloudMessagePersistence.ts +65 -29
  71. package/src/cloudResponse.test.ts +34 -1
  72. package/src/cloudResponse.ts +30 -0
  73. package/src/index.ts +24 -1
  74. package/src/runTelemetry.test.ts +366 -0
  75. package/src/runTelemetry.ts +271 -16
  76. package/src/telemetry/index.test.ts +252 -0
  77. package/src/telemetry/index.ts +98 -0
  78. package/src/tests/AssistantCloud.test.ts +22 -0
  79. package/src/tests/AssistantCloudAPI.test.ts +44 -4
  80. package/src/tests/AssistantCloudAuthStrategy.test.ts +354 -11
  81. package/src/tests/CloudMessagePersistence.test.ts +208 -0
@@ -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 either the current or the
112
- * legacy prompt/completion names. Returns undefined when no count is present,
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
- usage.reasoningTokens == null &&
125
- usage.cachedInputTokens == null
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
- ...(usage.reasoningTokens != null
134
- ? { reasoningTokens: usage.reasoningTokens }
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
+ }