@traceai/google-genai-sdk 0.0.0-stage → 0.1.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/package.json CHANGED
@@ -1,6 +1,50 @@
1
1
  {
2
2
  "name": "@traceai/google-genai-sdk",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.1.0",
4
+ "description": "Per-client OpenTelemetry instrumentation for the modern @google/genai Node.js SDK",
5
+ "license": "Apache-2.0",
6
+ "main": "./dist/index.js",
7
+ "types": "./dist/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./dist/index.d.ts",
11
+ "import": "./dist/index.js",
12
+ "require": "./dist/index.js"
13
+ }
14
+ },
15
+ "files": [
16
+ "dist",
17
+ "src",
18
+ "README.md"
19
+ ],
20
+ "engines": {
21
+ "node": ">=20"
22
+ },
23
+ "dependencies": {
24
+ "@opentelemetry/api": "^1.9.0",
25
+ "@opentelemetry/core": "^2.0.1",
26
+ "@traceai/fi-core": "^1.1.0",
27
+ "@traceai/fi-semantic-conventions": "^1.0.0"
28
+ },
29
+ "peerDependencies": {
30
+ "@google/genai": ">=1.33.0 <3.0.0"
31
+ },
32
+ "devDependencies": {
33
+ "typescript": "^5.9.3",
34
+ "@types/node": "^22.15.29",
35
+ "@google/genai": "2.32.0",
36
+ "@opentelemetry/sdk-trace-node": "^2.0.1"
37
+ },
38
+ "repository": {
39
+ "type": "git",
40
+ "url": "git+https://github.com/future-agi/traceAI.git",
41
+ "directory": "typescript/packages/traceai_google_genai_sdk"
42
+ },
43
+ "publishConfig": {
44
+ "access": "public"
45
+ },
46
+ "scripts": {
47
+ "build": "tsc -p tsconfig.json",
48
+ "test": "node --test test/compat.test.cjs test/advanced.test.cjs test/edges.test.cjs test/regressions.test.cjs"
49
+ }
6
50
  }
package/src/capture.ts ADDED
@@ -0,0 +1,443 @@
1
+ import type { Span } from "@opentelemetry/api";
2
+ import type { TraceConfig } from "@traceai/fi-core";
3
+ import { SemanticConventions as S } from "@traceai/fi-semantic-conventions";
4
+
5
+ type RecordValue = Record<string, unknown>;
6
+ export function record(value: unknown): RecordValue {
7
+ return value !== null && typeof value === "object"
8
+ ? (value as RecordValue)
9
+ : {};
10
+ }
11
+ const list = (value: unknown): unknown[] =>
12
+ Array.isArray(value) ? value : value == null ? [] : [value];
13
+
14
+ /** Capture is deliberately allowlisted: never serialize SDK options, HTTP responses or binary. */
15
+ export class Capture {
16
+ private remaining: number;
17
+ private output: unknown[] = [];
18
+ private firstContent = false;
19
+ private truncated = false;
20
+ private generation = false;
21
+ private embedding = false;
22
+ private embeddingUsageSnapshot = new Map<string, number>();
23
+ private hops = 0;
24
+ private capturedTools = false;
25
+ private usageTotals = new Map<string, number>();
26
+ private usageHops = new Map<string, number>();
27
+ private started = performance.now();
28
+ private allowInput: boolean;
29
+ private allowOutput: boolean;
30
+ constructor(
31
+ private span: Span,
32
+ config: TraceConfig,
33
+ limit: number,
34
+ ) {
35
+ this.remaining = limit;
36
+ // Suppress the whole content surface for text hiding, including tool arguments and schemas.
37
+ this.allowInput = !(
38
+ config.hideInputs ||
39
+ config.hideInputMessages ||
40
+ config.hideInputText
41
+ );
42
+ this.allowOutput = !(
43
+ config.hideOutputs ||
44
+ config.hideOutputMessages ||
45
+ config.hideOutputText
46
+ );
47
+ }
48
+ private bounded(value: unknown, size: number): unknown[] {
49
+ const items = list(value);
50
+ if (items.length > size) this.truncated = true;
51
+ return items.slice(0, size);
52
+ }
53
+ private text(value: unknown): string | undefined {
54
+ if (typeof value !== "string") return undefined;
55
+ const result = value.slice(0, Math.max(0, Math.min(this.remaining, 4096)));
56
+ if (result.length < value.length) this.truncated = true;
57
+ if (this.remaining <= 0) return undefined;
58
+ this.remaining -= result.length + 16;
59
+ return result;
60
+ }
61
+ private json(value: unknown, depth = 0): unknown {
62
+ if (typeof value === "function" || value === undefined) return undefined;
63
+ if (this.remaining <= 0 || depth > 6) {
64
+ this.truncated = true;
65
+ return undefined;
66
+ }
67
+ this.remaining -= 16;
68
+ if (value === null) return null;
69
+ if (typeof value === "string") return this.text(value);
70
+ if (typeof value === "boolean" || typeof value === "number") return value;
71
+ if (Array.isArray(value))
72
+ return this.bounded(value, 32).map((v) => this.json(v, depth + 1));
73
+ const result: RecordValue = {};
74
+ const keys = Object.keys(record(value));
75
+ if (keys.length > 32) this.truncated = true;
76
+ for (const key of keys.slice(0, 32)) {
77
+ // Tool arguments and responses can carry credentials at any nesting level.
78
+ if (
79
+ /data|bytes|base64|headers|api.?key|secret|token|uri|url|auth|cookie|password|passphrase|passwd|pwd|credential|private.?key|access.?key/i.test(
80
+ key,
81
+ )
82
+ )
83
+ continue;
84
+ const boundedKey = this.text(key);
85
+ if (boundedKey === undefined) break;
86
+ result[boundedKey] = this.json(record(value)[key], depth + 1);
87
+ }
88
+ return result;
89
+ }
90
+ private parts(value: unknown): unknown[] {
91
+ const result: unknown[] = [];
92
+ for (const item of this.bounded(value, 64)) {
93
+ if (this.remaining <= 0) break;
94
+ const part = record(item);
95
+ const text = this.text(typeof item === "string" ? item : part.text);
96
+ if (text !== undefined) result.push({ type: "text", text });
97
+ for (const key of ["functionCall", "functionResponse"] as const) {
98
+ if (part[key])
99
+ result.push({ type: key, ...record(this.json(part[key])) });
100
+ }
101
+ if (part.type === "function_call" || part.type === "function_result")
102
+ result.push(this.json(part));
103
+ // Only modality is retained for media, not raw bytes, signed URLs, or file URIs.
104
+ for (const key of ["inlineData", "fileData", "image", "audio", "video"]) {
105
+ if (part[key]) {
106
+ result.push({ type: key });
107
+ this.remaining -= 32;
108
+ }
109
+ }
110
+ }
111
+ return result;
112
+ }
113
+ input(params: RecordValue, operation: string): void {
114
+ this.generation =
115
+ operation === "generateContent" || operation === "generateContentStream";
116
+ this.embedding = operation === "embedContent";
117
+ this.string(S.LLM_MODEL_NAME, params.model);
118
+ if (this.embedding) {
119
+ this.string(S.EMBEDDING_MODEL_NAME, params.model);
120
+ // Text batch semantics. Multimodal embedding models group parts differently;
121
+ // omit a speculative input count for those shapes.
122
+ if (typeof params.contents === "string")
123
+ this.span.setAttribute("embedding.input_count", 1);
124
+ else if (
125
+ Array.isArray(params.contents) &&
126
+ params.contents.every((v) => typeof v === "string") &&
127
+ !String(params.model).includes("gemini-embedding-2")
128
+ )
129
+ this.span.setAttribute("embedding.input_count", params.contents.length);
130
+ }
131
+ this.string(
132
+ "google.genai.previous_interaction_id",
133
+ params.previous_interaction_id,
134
+ );
135
+ if (typeof params.background === "boolean")
136
+ this.span.setAttribute("google.genai.background", params.background);
137
+ const config = record(params.config ?? params.generation_config);
138
+ const numeric: RecordValue = {};
139
+ for (const key of [
140
+ "temperature",
141
+ "topP",
142
+ "topK",
143
+ "maxOutputTokens",
144
+ "candidateCount",
145
+ "seed",
146
+ "top_p",
147
+ "top_k",
148
+ "max_output_tokens",
149
+ ]) {
150
+ if (typeof config[key] === "number") numeric[key] = config[key];
151
+ }
152
+ this.span.setAttribute(
153
+ S.LLM_INVOCATION_PARAMETERS,
154
+ JSON.stringify(numeric),
155
+ );
156
+ if (!this.allowInput) return;
157
+ const messages: unknown[] = [];
158
+ const system = params.system_instruction ?? config.systemInstruction;
159
+ if (system)
160
+ messages.push({
161
+ role: "system",
162
+ content: this.parts(record(system).parts ?? system),
163
+ });
164
+ for (const item of this.bounded(
165
+ params.contents ?? params.input ?? params.prompt,
166
+ 64,
167
+ )) {
168
+ if (this.remaining <= 0) break;
169
+ const content = record(item);
170
+ messages.push({
171
+ role: this.text(content.role) ?? "user",
172
+ content: this.parts(content.parts ?? content.content ?? item),
173
+ });
174
+ }
175
+ if (messages.length)
176
+ this.span.setAttribute(S.LLM_INPUT_MESSAGES, JSON.stringify(messages));
177
+ // Generation tools are observed only after the SDK resolves CallableTools.
178
+ if (!this.generation) this.tools(config.tools ?? params.tools);
179
+ }
180
+ private tools(value: unknown): void {
181
+ if (!this.allowInput || this.capturedTools || value == null) return;
182
+ this.capturedTools = true;
183
+ this.span.setAttribute(
184
+ S.LLM_TOOLS,
185
+ JSON.stringify(this.json(value)) ?? "[]",
186
+ );
187
+ }
188
+ /** A hop gets its own cumulative usage snapshot. Sum independent hops, not SSE chunks. */
189
+ generationHop(params: RecordValue): (value: unknown) => void {
190
+ this.hops++;
191
+ this.tools(record(params.config).tools);
192
+ const previous = new Map<string, number>();
193
+ let promptInput: number | undefined;
194
+ let toolInput: number | undefined;
195
+ return (value) => {
196
+ const usage = record(record(value).usageMetadata);
197
+ const counts = this.usage(usage);
198
+ const prompt = counts.get(S.LLM_TOKEN_COUNT_PROMPT);
199
+ if (prompt !== undefined) promptInput = prompt;
200
+ const tool = usage.toolUsePromptTokenCount;
201
+ if (typeof tool === "number" && Number.isFinite(tool) && tool >= 0)
202
+ toolInput = tool;
203
+ // Built-in tool results are additional input, not generated output. Keep
204
+ // each counter's latest snapshot even when a later chunk omits one, then
205
+ // let the existing hop accounting deduplicate the combined input count.
206
+ if (toolInput !== undefined) {
207
+ const input = (promptInput ?? 0) + toolInput;
208
+ if ((promptInput ?? 0) >= 0 && Number.isFinite(input))
209
+ counts.set(S.LLM_TOKEN_COUNT_PROMPT, input);
210
+ }
211
+ for (const [key, count] of counts) {
212
+ const total =
213
+ (this.usageTotals.get(key) ?? 0) + count - (previous.get(key) ?? 0);
214
+ if (!previous.has(key))
215
+ this.usageHops.set(key, (this.usageHops.get(key) ?? 0) + 1);
216
+ previous.set(key, count);
217
+ this.usageTotals.set(key, total);
218
+ this.span.setAttribute(key, total);
219
+ }
220
+ };
221
+ }
222
+ string(key: string, value: unknown): void {
223
+ if (typeof value === "string") {
224
+ if (value.length > 256) this.truncated = true;
225
+ this.span.setAttribute(key, value.slice(0, 256));
226
+ }
227
+ }
228
+ private usage(usage: RecordValue): Map<string, number> {
229
+ const result = new Map<string, number>();
230
+ for (const [key, names] of [
231
+ [
232
+ S.LLM_TOKEN_COUNT_PROMPT,
233
+ ["promptTokenCount", "total_input_tokens", "input_tokens"],
234
+ ],
235
+ [
236
+ S.LLM_TOKEN_COUNT_COMPLETION,
237
+ ["candidatesTokenCount", "total_output_tokens", "output_tokens"],
238
+ ],
239
+ [S.LLM_TOKEN_COUNT_TOTAL, ["totalTokenCount", "total_tokens"]],
240
+ [
241
+ S.LLM_TOKEN_COUNT_PROMPT_DETAILS_CACHE_READ,
242
+ ["cachedContentTokenCount", "total_cached_tokens"],
243
+ ],
244
+ [
245
+ S.LLM_TOKEN_COUNT_COMPLETION_DETAILS_REASONING,
246
+ [
247
+ "thoughtsTokenCount",
248
+ "total_thought_tokens",
249
+ "total_reasoning_tokens",
250
+ ],
251
+ ],
252
+ ] as const) {
253
+ const count = names
254
+ .map((name) => usage[name])
255
+ .find((v) => typeof v === "number" && Number.isFinite(v));
256
+ if (typeof count === "number") result.set(key, count);
257
+ }
258
+ // These are numeric cost inputs, not media content. Keep them even when all
259
+ // content is hidden. Fixed modality names avoid provider-controlled keys.
260
+ for (const [prefix, details] of [
261
+ // EmbeddingUsageMetadata uses singular Token; generation uses Tokens.
262
+ // These are aliases of one snapshot, not independent counts to add.
263
+ [S.LLM_TOKEN_COUNT_PROMPT, usage.promptTokensDetails ?? usage.promptTokenDetails ?? usage.input_tokens_by_modality],
264
+ [S.LLM_TOKEN_COUNT_COMPLETION, usage.candidatesTokensDetails ?? usage.output_tokens_by_modality],
265
+ [S.LLM_TOKEN_COUNT_PROMPT_DETAILS_CACHE_READ, usage.cacheTokensDetails ?? usage.cached_tokens_by_modality],
266
+ ] as const) {
267
+ for (const value of list(details)) {
268
+ const detail = record(value);
269
+ const modality = typeof detail.modality === "string" ? detail.modality.toLowerCase() : "";
270
+ const count = detail.tokenCount ?? detail.tokens;
271
+ if (
272
+ ["text", "audio", "image", "video", "document"].includes(modality) &&
273
+ typeof count === "number" && Number.isFinite(count) && count >= 0
274
+ ) {
275
+ const key = `${prefix}.${modality}`;
276
+ result.set(key, (result.get(key) ?? 0) + count);
277
+ }
278
+ }
279
+ }
280
+ return result;
281
+ }
282
+ /** Only numeric, allowlisted billing fields from the SDK-owned JSON parse. */
283
+ embeddingUsage(value: unknown): void {
284
+ for (const [key, count] of this.usage(record(record(value).usageMetadata))) {
285
+ this.embeddingUsageSnapshot.set(key, count);
286
+ this.span.setAttribute(key, count);
287
+ }
288
+ }
289
+ /** Vertex exposes billing counts on the converted embeddings, not usageMetadata.
290
+ * Missing or invalid statistics must not turn a partial sum into a call total.
291
+ */
292
+ private embeddingTokens(embeddings: unknown): number | undefined {
293
+ if (!Array.isArray(embeddings) || embeddings.length === 0) return undefined;
294
+ let total = 0;
295
+ for (const embedding of embeddings) {
296
+ const count = record(record(embedding).statistics).tokenCount;
297
+ if (typeof count !== "number" || !Number.isFinite(count) || count < 0)
298
+ return undefined;
299
+ total += count;
300
+ }
301
+ return Number.isFinite(total) ? total : undefined;
302
+ }
303
+ response(value: unknown): void {
304
+ const response = record(value);
305
+ const interaction = record(response.interaction);
306
+ this.string(
307
+ S.GEN_AI_RESPONSE_ID,
308
+ response.responseId ?? interaction.id ?? response.id,
309
+ );
310
+ this.string(
311
+ S.GEN_AI_RESPONSE_MODEL,
312
+ response.modelVersion ?? interaction.model ?? response.model,
313
+ );
314
+ this.string("google.genai.status", interaction.status ?? response.status);
315
+ this.string("google.genai.operation_id", response.name);
316
+ if (typeof response.done === "boolean")
317
+ this.span.setAttribute("google.genai.operation_done", response.done);
318
+ if (Array.isArray(response.embeddings)) {
319
+ this.span.setAttribute("embedding.count", response.embeddings.length);
320
+ const values = record(response.embeddings[0]).values;
321
+ if (Array.isArray(values))
322
+ this.span.setAttribute("embedding.dimensions", values.length);
323
+ }
324
+ if (!this.generation) {
325
+ const usage = record(
326
+ response.usageMetadata ??
327
+ interaction.usage ??
328
+ response.usage ??
329
+ record(response.metadata).total_usage,
330
+ );
331
+ const counts = this.usage(usage);
332
+ if (this.embedding) {
333
+ // The parse hook and public result describe the same response. Preserve
334
+ // aggregate fields stripped by SDK conversion, without adding snapshots.
335
+ for (const [key, count] of this.embeddingUsageSnapshot)
336
+ if (!counts.has(key)) counts.set(key, count);
337
+ if (!counts.has(S.LLM_TOKEN_COUNT_PROMPT)) {
338
+ const input = this.embeddingTokens(response.embeddings);
339
+ if (input !== undefined) counts.set(S.LLM_TOKEN_COUNT_PROMPT, input);
340
+ }
341
+ const input = counts.get(S.LLM_TOKEN_COUNT_PROMPT);
342
+ if (input !== undefined && !counts.has(S.LLM_TOKEN_COUNT_TOTAL))
343
+ counts.set(S.LLM_TOKEN_COUNT_TOTAL, input);
344
+ }
345
+ for (const [key, count] of counts) this.span.setAttribute(key, count);
346
+ }
347
+ const candidates = list(response.candidates);
348
+ const reasons = candidates
349
+ .map((c) => record(c).finishReason)
350
+ .filter((x): x is string => typeof x === "string");
351
+ if (reasons.length > 16 || reasons.some((reason) => reason.length > 128))
352
+ this.truncated = true;
353
+ if (reasons.length)
354
+ this.span.setAttribute(
355
+ S.GEN_AI_RESPONSE_FINISH_REASONS,
356
+ reasons.slice(0, 16).map((reason) => reason.slice(0, 128)),
357
+ );
358
+ if (
359
+ !this.firstContent &&
360
+ (candidates
361
+ .slice(0, 16)
362
+ .some(
363
+ (candidate) =>
364
+ list(record(record(candidate).content).parts).length > 0,
365
+ ) ||
366
+ response.event_type === "content.delta" ||
367
+ response.event_type === "step.delta" ||
368
+ list(
369
+ response.steps ??
370
+ interaction.steps ??
371
+ response.outputs ??
372
+ interaction.outputs,
373
+ ).length > 0)
374
+ ) {
375
+ this.firstContent = true;
376
+ this.span.setAttribute(
377
+ "gen_ai.response.time_to_first_token",
378
+ (performance.now() - this.started) / 1000,
379
+ );
380
+ }
381
+ if (!this.allowOutput || this.remaining <= 0) return;
382
+ for (const candidate of this.bounded(candidates, 16)) {
383
+ const content = record(record(candidate).content);
384
+ this.append({
385
+ role: this.text(content.role) ?? "model",
386
+ content: this.parts(content.parts),
387
+ });
388
+ }
389
+ // Released Interactions use model_output steps; older releases use outputs.
390
+ const steps = response.steps ?? interaction.steps;
391
+ if (steps !== undefined) {
392
+ this.output = [];
393
+ for (const step of this.bounded(steps, 64)) {
394
+ if (record(step).type === "model_output")
395
+ this.append({
396
+ role: "model",
397
+ content: this.parts(record(step).content),
398
+ });
399
+ }
400
+ } else if (response.outputs ?? interaction.outputs) {
401
+ this.output = [];
402
+ this.append({
403
+ role: "model",
404
+ content: this.parts(response.outputs ?? interaction.outputs),
405
+ });
406
+ } else if (
407
+ response.event_type === "content.delta" ||
408
+ response.event_type === "step.delta"
409
+ ) {
410
+ this.append({ role: "model", content: this.parts(response.delta) });
411
+ } else if (
412
+ response.event_type === "step.start" &&
413
+ record(response.step).type === "model_output"
414
+ ) {
415
+ this.append({
416
+ role: "model",
417
+ content: this.parts(record(response.step).content),
418
+ });
419
+ }
420
+ }
421
+
422
+ private append(message: unknown): void {
423
+ if (!list(record(message).content).length) return;
424
+ this.output.push(message);
425
+ }
426
+ finish(): void {
427
+ if (this.output.length)
428
+ this.span.setAttribute(
429
+ S.LLM_OUTPUT_MESSAGES,
430
+ JSON.stringify(this.output),
431
+ );
432
+ if (this.truncated || this.remaining <= 0)
433
+ this.span.setAttribute("google.genai.capture_truncated", true);
434
+ if (this.generation && !this.hops)
435
+ this.span.setAttribute("google.genai.usage_capture", "unavailable");
436
+ else if (
437
+ this.generation &&
438
+ (!this.usageHops.size ||
439
+ [...this.usageHops.values()].some((count) => count < this.hops))
440
+ )
441
+ this.span.setAttribute("google.genai.usage_capture", "partial");
442
+ }
443
+ }