From 17737a5ad6aaa48e4dc25ae38dfe41cc8d2efe07 Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Thu, 23 Apr 2026 16:45:42 +0200 Subject: [PATCH 01/31] chore(ai): add @opentelemetry/api as optional peer + devDep --- packages/typescript/ai/package.json | 9 +++++ pnpm-lock.yaml | 57 ++++++++++++++++++----------- 2 files changed, 44 insertions(+), 22 deletions(-) diff --git a/packages/typescript/ai/package.json b/packages/typescript/ai/package.json index 68b209cf8..213f84bf9 100644 --- a/packages/typescript/ai/package.json +++ b/packages/typescript/ai/package.json @@ -65,7 +65,16 @@ "@tanstack/ai-event-client": "workspace:*", "partial-json": "^0.1.7" }, + "peerDependencies": { + "@opentelemetry/api": ">=1.9.0" + }, + "peerDependenciesMeta": { + "@opentelemetry/api": { + "optional": true + } + }, "devDependencies": { + "@opentelemetry/api": "^1.9.0", "@standard-schema/spec": "^1.1.0", "@vitest/coverage-v8": "4.0.14", "zod": "^4.2.0" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 14f57fcde..0b074ffe1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -84,7 +84,7 @@ importers: version: 7.2.7(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) vitest: specifier: ^4.0.14 - version: 4.0.15(@types/node@24.10.3)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@27.3.0(postcss@8.5.9))(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) + version: 4.0.15(@opentelemetry/api@1.9.1)(@types/node@24.10.3)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@27.3.0(postcss@8.5.9))(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) examples/php-slim: devDependencies: @@ -317,7 +317,7 @@ importers: version: 7.2.7(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) vitest: specifier: ^4.0.14 - version: 4.0.15(@types/node@24.10.3)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@27.3.0(postcss@8.5.9))(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) + version: 4.0.15(@opentelemetry/api@1.9.1)(@types/node@24.10.3)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@27.3.0(postcss@8.5.9))(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) web-vitals: specifier: ^5.1.0 version: 5.1.0 @@ -465,7 +465,7 @@ importers: version: 7.2.7(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) vitest: specifier: ^4.0.14 - version: 4.0.15(@types/node@24.10.3)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@27.3.0(postcss@8.5.9))(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) + version: 4.0.15(@opentelemetry/api@1.9.1)(@types/node@24.10.3)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@27.3.0(postcss@8.5.9))(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) web-vitals: specifier: ^5.1.0 version: 5.1.0 @@ -644,7 +644,7 @@ importers: version: 2.11.10(solid-js@1.9.10)(vite@7.2.7(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) vitest: specifier: ^4.0.14 - version: 4.0.15(@types/node@24.10.3)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@27.3.0(postcss@8.5.9))(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) + version: 4.0.15(@opentelemetry/api@1.9.1)(@types/node@24.10.3)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@27.3.0(postcss@8.5.9))(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) web-vitals: specifier: ^5.1.0 version: 5.1.0 @@ -690,10 +690,10 @@ importers: devDependencies: '@sveltejs/adapter-auto': specifier: ^3.3.1 - version: 3.3.1(@sveltejs/kit@2.49.2(@sveltejs/vite-plugin-svelte@5.1.1(svelte@5.45.10)(vite@7.2.7(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.45.10)(vite@7.2.7(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))) + version: 3.3.1(@sveltejs/kit@2.49.2(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@5.1.1(svelte@5.45.10)(vite@7.2.7(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.45.10)(vite@7.2.7(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))) '@sveltejs/kit': specifier: ^2.15.10 - version: 2.49.2(@sveltejs/vite-plugin-svelte@5.1.1(svelte@5.45.10)(vite@7.2.7(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.45.10)(vite@7.2.7(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) + version: 2.49.2(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@5.1.1(svelte@5.45.10)(vite@7.2.7(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.45.10)(vite@7.2.7(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) '@sveltejs/vite-plugin-svelte': specifier: ^5.1.1 version: 5.1.1(svelte@5.45.10)(vite@7.2.7(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) @@ -820,6 +820,9 @@ importers: specifier: ^0.1.7 version: 0.1.7 devDependencies: + '@opentelemetry/api': + specifier: ^1.9.0 + version: 1.9.1 '@standard-schema/spec': specifier: ^1.1.0 version: 1.1.0 @@ -1318,7 +1321,7 @@ importers: version: 24.10.3 '@vitest/coverage-v8': specifier: 4.0.14 - version: 4.0.14(vitest@4.0.15(@types/node@24.10.3)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@27.3.0(postcss@8.5.9))(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) + version: 4.0.14(vitest@4.0.15(@opentelemetry/api@1.9.1)(@types/node@24.10.3)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@27.3.0(postcss@8.5.9))(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) jsdom: specifier: ^27.2.0 version: 27.3.0(postcss@8.5.9) @@ -1333,7 +1336,7 @@ importers: version: 5.9.3 vitest: specifier: ^4.0.14 - version: 4.0.15(@types/node@24.10.3)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@27.3.0(postcss@8.5.9))(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) + version: 4.0.15(@opentelemetry/api@1.9.1)(@types/node@24.10.3)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@27.3.0(postcss@8.5.9))(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) packages/typescript/ai-solid-ui: dependencies: @@ -1420,7 +1423,7 @@ importers: version: 24.10.3 '@vitest/coverage-v8': specifier: 4.0.14 - version: 4.0.14(vitest@4.0.15(@types/node@24.10.3)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@27.3.0(postcss@8.5.9))(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) + version: 4.0.14(vitest@4.0.15(@opentelemetry/api@1.9.1)(@types/node@24.10.3)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@27.3.0(postcss@8.5.9))(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) '@vue/test-utils': specifier: ^2.4.6 version: 2.4.6 @@ -1435,7 +1438,7 @@ importers: version: 5.9.3 vitest: specifier: ^4.0.14 - version: 4.0.15(@types/node@24.10.3)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@27.3.0(postcss@8.5.9))(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) + version: 4.0.15(@opentelemetry/api@1.9.1)(@types/node@24.10.3)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@27.3.0(postcss@8.5.9))(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) vue: specifier: ^3.5.25 version: 3.5.25(typescript@5.9.3) @@ -3346,6 +3349,10 @@ packages: '@openrouter/sdk@0.12.14': resolution: {integrity: sha512-G32CZ1IkmtsGfQF7/mzcvt7W0Lmd6HUHFGjDWv5knBvL6sJcMmX6i3VPSIpHQYSgEqRQSxFuDROP6iErTu7XcA==} + '@opentelemetry/api@1.9.1': + resolution: {integrity: sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==} + engines: {node: '>=8.0.0'} + '@oxc-minify/binding-android-arm-eabi@0.110.0': resolution: {integrity: sha512-43fMTO8/5bMlqfOiNSZNKUzIqeLIYuB9Hr1Ohyf58B1wU11S2dPGibTXOGNaWsfgHy99eeZ1bSgeIHy/fEYqbw==} engines: {node: ^20.19.0 || >=22.12.0} @@ -12243,6 +12250,8 @@ snapshots: dependencies: zod: 4.3.6 + '@opentelemetry/api@1.9.1': {} + '@oxc-minify/binding-android-arm-eabi@0.110.0': optional: true @@ -13220,12 +13229,12 @@ snapshots: dependencies: acorn: 8.15.0 - '@sveltejs/adapter-auto@3.3.1(@sveltejs/kit@2.49.2(@sveltejs/vite-plugin-svelte@5.1.1(svelte@5.45.10)(vite@7.2.7(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.45.10)(vite@7.2.7(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))': + '@sveltejs/adapter-auto@3.3.1(@sveltejs/kit@2.49.2(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@5.1.1(svelte@5.45.10)(vite@7.2.7(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.45.10)(vite@7.2.7(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))': dependencies: - '@sveltejs/kit': 2.49.2(@sveltejs/vite-plugin-svelte@5.1.1(svelte@5.45.10)(vite@7.2.7(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.45.10)(vite@7.2.7(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) + '@sveltejs/kit': 2.49.2(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@5.1.1(svelte@5.45.10)(vite@7.2.7(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.45.10)(vite@7.2.7(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) import-meta-resolve: 4.2.0 - '@sveltejs/kit@2.49.2(@sveltejs/vite-plugin-svelte@5.1.1(svelte@5.45.10)(vite@7.2.7(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.45.10)(vite@7.2.7(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))': + '@sveltejs/kit@2.49.2(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@5.1.1(svelte@5.45.10)(vite@7.2.7(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.45.10)(vite@7.2.7(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))': dependencies: '@standard-schema/spec': 1.0.0 '@sveltejs/acorn-typescript': 1.0.8(acorn@8.15.0) @@ -13243,6 +13252,8 @@ snapshots: sirv: 3.0.2 svelte: 5.45.10 vite: 7.2.7(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) + optionalDependencies: + '@opentelemetry/api': 1.9.1 '@sveltejs/package@2.5.7(svelte@5.45.10)(typescript@5.9.3)': dependencies: @@ -15235,7 +15246,7 @@ snapshots: vite: 7.2.7(@types/node@25.0.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) vue: 3.5.25(typescript@5.9.3) - '@vitest/coverage-v8@4.0.14(vitest@4.0.15(@types/node@24.10.3)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@27.3.0(postcss@8.5.9))(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))': + '@vitest/coverage-v8@4.0.14(vitest@4.0.15(@opentelemetry/api@1.9.1)(@types/node@24.10.3)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@27.3.0(postcss@8.5.9))(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))': dependencies: '@bcoe/v8-coverage': 1.0.2 '@vitest/utils': 4.0.14 @@ -15248,7 +15259,7 @@ snapshots: obug: 2.1.1 std-env: 3.10.0 tinyrainbow: 3.1.0 - vitest: 4.0.15(@types/node@24.10.3)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@27.3.0(postcss@8.5.9))(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) + vitest: 4.0.15(@opentelemetry/api@1.9.1)(@types/node@24.10.3)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@27.3.0(postcss@8.5.9))(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) transitivePeerDependencies: - supports-color @@ -15265,7 +15276,7 @@ snapshots: obug: 2.1.1 std-env: 3.10.0 tinyrainbow: 3.1.0 - vitest: 4.1.4(@types/node@25.0.1)(@vitest/coverage-v8@4.0.14)(happy-dom@20.0.11)(jsdom@27.3.0(postcss@8.5.9))(vite@7.2.7(@types/node@25.0.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) + vitest: 4.1.4(@opentelemetry/api@1.9.1)(@types/node@25.0.1)(@vitest/coverage-v8@4.0.14)(happy-dom@20.0.11)(jsdom@27.3.0(postcss@8.5.9))(vite@7.3.1(@types/node@25.0.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) transitivePeerDependencies: - supports-color @@ -15295,13 +15306,13 @@ snapshots: optionalDependencies: vite: 7.3.1(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) - '@vitest/mocker@4.1.4(vite@7.2.7(@types/node@25.0.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))': + '@vitest/mocker@4.1.4(vite@7.3.1(@types/node@25.0.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))': dependencies: '@vitest/spy': 4.1.4 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 7.2.7(@types/node@25.0.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) + vite: 7.3.1(@types/node@25.0.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) '@vitest/pretty-format@4.0.14': dependencies: @@ -21262,7 +21273,7 @@ snapshots: optionalDependencies: vite: 7.3.1(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) - vitest@4.0.15(@types/node@24.10.3)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@27.3.0(postcss@8.5.9))(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2): + vitest@4.0.15(@opentelemetry/api@1.9.1)(@types/node@24.10.3)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@27.3.0(postcss@8.5.9))(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2): dependencies: '@vitest/expect': 4.0.15 '@vitest/mocker': 4.0.15(vite@7.3.1(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) @@ -21285,6 +21296,7 @@ snapshots: vite: 7.3.1(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) why-is-node-running: 2.3.0 optionalDependencies: + '@opentelemetry/api': 1.9.1 '@types/node': 24.10.3 happy-dom: 20.0.11 jsdom: 27.3.0(postcss@8.5.9) @@ -21301,10 +21313,10 @@ snapshots: - tsx - yaml - vitest@4.1.4(@types/node@25.0.1)(@vitest/coverage-v8@4.0.14)(happy-dom@20.0.11)(jsdom@27.3.0(postcss@8.5.9))(vite@7.2.7(@types/node@25.0.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)): + vitest@4.1.4(@opentelemetry/api@1.9.1)(@types/node@25.0.1)(@vitest/coverage-v8@4.0.14)(happy-dom@20.0.11)(jsdom@27.3.0(postcss@8.5.9))(vite@7.3.1(@types/node@25.0.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)): dependencies: '@vitest/expect': 4.1.4 - '@vitest/mocker': 4.1.4(vite@7.2.7(@types/node@25.0.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) + '@vitest/mocker': 4.1.4(vite@7.3.1(@types/node@25.0.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) '@vitest/pretty-format': 4.1.4 '@vitest/runner': 4.1.4 '@vitest/snapshot': 4.1.4 @@ -21321,9 +21333,10 @@ snapshots: tinyexec: 1.1.1 tinyglobby: 0.2.16 tinyrainbow: 3.1.0 - vite: 7.2.7(@types/node@25.0.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) + vite: 7.3.1(@types/node@25.0.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) why-is-node-running: 2.3.0 optionalDependencies: + '@opentelemetry/api': 1.9.1 '@types/node': 25.0.1 '@vitest/coverage-v8': 4.0.14(vitest@4.1.4) happy-dom: 20.0.11 From b4fcde5c05033abc7e37f162405c346640826db5 Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Thu, 23 Apr 2026 16:49:13 +0200 Subject: [PATCH 02/31] feat(ai): scaffold otel middleware types and factory --- .../typescript/ai/src/middlewares/index.ts | 7 ++ .../typescript/ai/src/middlewares/otel.ts | 102 ++++++++++++++++++ 2 files changed, 109 insertions(+) create mode 100644 packages/typescript/ai/src/middlewares/otel.ts diff --git a/packages/typescript/ai/src/middlewares/index.ts b/packages/typescript/ai/src/middlewares/index.ts index 6ffacafcd..7d329b067 100644 --- a/packages/typescript/ai/src/middlewares/index.ts +++ b/packages/typescript/ai/src/middlewares/index.ts @@ -11,3 +11,10 @@ export { type ContentGuardRule, type ContentFilteredInfo, } from './content-guard' + +export { + otelMiddleware, + type OtelMiddlewareOptions, + type OtelSpanInfo, + type OtelSpanKind, +} from './otel' diff --git a/packages/typescript/ai/src/middlewares/otel.ts b/packages/typescript/ai/src/middlewares/otel.ts new file mode 100644 index 000000000..3228a5121 --- /dev/null +++ b/packages/typescript/ai/src/middlewares/otel.ts @@ -0,0 +1,102 @@ +import type { + AttributeValue, + Meter, + Span, + SpanOptions, + Tracer, +} from '@opentelemetry/api' +import type { + ChatMiddleware, + ChatMiddlewareContext, +} from '../activities/chat/middleware/types' + +export type OtelSpanKind = 'chat' | 'iteration' | 'tool' + +export interface OtelSpanInfo { + kind: K + ctx: ChatMiddlewareContext + toolName?: string + toolCallId?: string + iteration?: number +} + +export interface OtelMiddlewareOptions { + tracer: Tracer + meter?: Meter + captureContent?: boolean + redact?: (text: string) => string + serviceName?: string + spanNameFormatter?: (info: OtelSpanInfo) => string + attributeEnricher?: (info: OtelSpanInfo) => Record + onBeforeSpanStart?: (info: OtelSpanInfo, options: SpanOptions) => SpanOptions + onSpanEnd?: (info: OtelSpanInfo, span: Span) => void +} + +interface RequestState { + rootSpan: Span + currentIterationSpan: Span | null + toolSpans: Map + iterationCount: number + assistantTextBuffer: string + startTime: number +} + +const stateByCtx = new WeakMap() + +function safeCall(label: string, fn: () => T): T | undefined { + try { + return fn() + } catch (err) { + void err + void label + return undefined + } +} + +export function otelMiddleware( + options: OtelMiddlewareOptions, +): ChatMiddleware { + const { + tracer, + meter, + captureContent = false, + redact = (s) => s, + serviceName = 'tanstack-ai', + spanNameFormatter, + attributeEnricher, + onBeforeSpanStart, + onSpanEnd, + } = options + + const durationHistogram = meter?.createHistogram( + 'gen_ai.client.operation.duration', + { + description: 'GenAI client operation duration', + unit: 's', + }, + ) + const tokenHistogram = meter?.createHistogram( + 'gen_ai.client.token.usage', + { + description: 'GenAI client token usage', + unit: '{token}', + }, + ) + + void captureContent + void redact + void serviceName + void spanNameFormatter + void attributeEnricher + void onBeforeSpanStart + void onSpanEnd + void tracer + void durationHistogram + void tokenHistogram + void stateByCtx + void safeCall + + return { + name: 'otel', + } +} From 650c46f9d0dabd01eff57f34b164bb0c295c170d Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Thu, 23 Apr 2026 16:52:38 +0200 Subject: [PATCH 03/31] refactor(ai): clean up scaffold voids per CR --- .../typescript/ai/src/middlewares/otel.ts | 33 ++++++------------- 1 file changed, 10 insertions(+), 23 deletions(-) diff --git a/packages/typescript/ai/src/middlewares/otel.ts b/packages/typescript/ai/src/middlewares/otel.ts index 3228a5121..d81b36aa0 100644 --- a/packages/typescript/ai/src/middlewares/otel.ts +++ b/packages/typescript/ai/src/middlewares/otel.ts @@ -57,25 +57,25 @@ export function otelMiddleware( options: OtelMiddlewareOptions, ): ChatMiddleware { const { - tracer, + tracer: _tracer, meter, - captureContent = false, - redact = (s) => s, - serviceName = 'tanstack-ai', - spanNameFormatter, - attributeEnricher, - onBeforeSpanStart, - onSpanEnd, + captureContent: _captureContent = false, + redact: _redact = (s) => s, + serviceName: _serviceName = 'tanstack-ai', + spanNameFormatter: _spanNameFormatter, + attributeEnricher: _attributeEnricher, + onBeforeSpanStart: _onBeforeSpanStart, + onSpanEnd: _onSpanEnd, } = options - const durationHistogram = meter?.createHistogram( + const _durationHistogram = meter?.createHistogram( 'gen_ai.client.operation.duration', { description: 'GenAI client operation duration', unit: 's', }, ) - const tokenHistogram = meter?.createHistogram( + const _tokenHistogram = meter?.createHistogram( 'gen_ai.client.token.usage', { description: 'GenAI client token usage', @@ -83,19 +83,6 @@ export function otelMiddleware( }, ) - void captureContent - void redact - void serviceName - void spanNameFormatter - void attributeEnricher - void onBeforeSpanStart - void onSpanEnd - void tracer - void durationHistogram - void tokenHistogram - void stateByCtx - void safeCall - return { name: 'otel', } From 71224cc5213ac94126b7ef6a2dbdcad12495b35d Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Thu, 23 Apr 2026 16:56:13 +0200 Subject: [PATCH 04/31] test(ai): add fake Tracer/Meter helpers for otel middleware tests --- .../ai/tests/middlewares/fake-otel.ts | 201 ++++++++++++++++++ 1 file changed, 201 insertions(+) create mode 100644 packages/typescript/ai/tests/middlewares/fake-otel.ts diff --git a/packages/typescript/ai/tests/middlewares/fake-otel.ts b/packages/typescript/ai/tests/middlewares/fake-otel.ts new file mode 100644 index 000000000..47b56d258 --- /dev/null +++ b/packages/typescript/ai/tests/middlewares/fake-otel.ts @@ -0,0 +1,201 @@ +import type { + Attributes, + AttributeValue, + Context, + Meter, + Span, + SpanContext, + SpanOptions, + SpanStatus, + TimeInput, + Tracer, + Histogram, +} from '@opentelemetry/api' +import { SpanStatusCode } from '@opentelemetry/api' + +export interface RecordedEvent { + name: string + attributes?: Attributes +} + +export interface RecordedException { + exception: unknown + attributes?: Attributes +} + +export interface FakeSpan extends Span { + name: string + kind?: number + parent?: FakeSpan | null + startTimeMs: number + endTimeMs: number | null + attributes: Record + events: RecordedEvent[] + exceptions: RecordedException[] + status: SpanStatus + ended: boolean +} + +export interface HistogramRecord { + name: string + value: number + attributes?: Attributes +} + +export interface FakeMeter { + meter: Meter + records: HistogramRecord[] +} + +export interface FakeTracer { + tracer: Tracer + spans: FakeSpan[] + activeStack: FakeSpan[] +} + +function makeSpan(name: string, options: SpanOptions, parent: FakeSpan | null): FakeSpan { + const span: FakeSpan = { + name, + kind: options.kind, + parent, + startTimeMs: Date.now(), + endTimeMs: null, + attributes: Object.fromEntries( + Object.entries(options.attributes ?? {}).filter(([, v]) => v !== undefined), + ) as Record, + events: [], + exceptions: [], + status: { code: SpanStatusCode.UNSET }, + ended: false, + spanContext(): SpanContext { + return { + traceId: 'fake-trace', + spanId: `fake-span-${Math.random().toString(36).slice(2, 10)}`, + traceFlags: 1, + } + }, + setAttribute(key, value) { + this.attributes[key] = value as AttributeValue + return this + }, + setAttributes(attrs) { + for (const [k, v] of Object.entries(attrs)) { + this.attributes[k] = v as AttributeValue + } + return this + }, + addEvent(name, attrs) { + this.events.push({ name, attributes: attrs as Attributes | undefined }) + return this + }, + addLink() { return this }, + addLinks() { return this }, + setStatus(status) { + this.status = status + return this + }, + updateName(n) { + this.name = n + return this + }, + end(_endTime?: TimeInput) { + this.endTimeMs = Date.now() + this.ended = true + }, + isRecording() { + return !this.ended + }, + recordException(exception, attrs) { + this.exceptions.push({ exception, attributes: attrs as Attributes | undefined }) + }, + } + return span +} + +export function createFakeTracer(): FakeTracer { + const spans: FakeSpan[] = [] + const activeStack: FakeSpan[] = [] + + const tracer: Tracer = { + startSpan(name, options = {}, _ctx?: Context) { + const parent = activeStack[activeStack.length - 1] ?? null + const span = makeSpan(name, options, parent) + spans.push(span) + return span + }, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + startActiveSpan(...args: any[]) { + const name = args[0] as string + const fn = args[args.length - 1] as (span: Span) => unknown + const options = (typeof args[1] === 'object' && args[1] !== null && !('traceId' in args[1]) + ? args[1] + : {}) as SpanOptions + const parent = activeStack[activeStack.length - 1] ?? null + const span = makeSpan(name, options, parent) + spans.push(span) + activeStack.push(span) + try { + return fn(span) + } finally { + activeStack.pop() + } + }, + } + + return { tracer, spans, activeStack } +} + +export function createFakeMeter(): FakeMeter { + const records: HistogramRecord[] = [] + + const meter: Meter = { + createHistogram(name: string): Histogram { + return { + record(value: number, attributes?: Attributes) { + records.push({ name, value, attributes }) + }, + } + }, + createCounter() { throw new Error('not implemented in fake') }, + createUpDownCounter() { throw new Error('not implemented in fake') }, + createObservableGauge() { throw new Error('not implemented in fake') }, + createObservableCounter() { throw new Error('not implemented in fake') }, + createObservableUpDownCounter() { throw new Error('not implemented in fake') }, + createGauge() { throw new Error('not implemented in fake') }, + addBatchObservableCallback() {}, + removeBatchObservableCallback() {}, + } as Meter + + return { meter, records } +} + +/** + * Build a minimal ChatMiddlewareContext for unit tests. Only fields the + * otel middleware reads need realistic values; others can be placeholders. + */ +export function makeCtx(overrides: Partial = {}) { + const base = { + requestId: 'req-1', + streamId: 'stream-1', + phase: 'init' as const, + iteration: 0, + chunkIndex: 0, + abort: () => {}, + context: undefined, + defer: () => {}, + provider: 'openai', + model: 'gpt-4o', + source: 'server' as const, + streaming: true, + systemPrompts: [], + options: {}, + modelOptions: {}, + messageCount: 1, + hasTools: false, + currentMessageId: null, + accumulatedContent: '', + messages: [], + createId: (prefix: string) => `${prefix}-1`, + } + return { ...base, ...overrides } as import('../../src/activities/chat/middleware/types').ChatMiddlewareContext +} From 5eddb147d9b854e3da14cee590fad2821c4a24ec Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Thu, 23 Apr 2026 16:59:43 +0200 Subject: [PATCH 05/31] fix(ai): restore void markers for scaffold locals tsc flags as unused TS's noUnusedLocals does not exempt _-prefixed plain const/function declarations, only destructured locals (TS 4.4+). Re-add explicit void markers for stateByCtx, safeCall, and the two histogram consts until later tasks wire them in. --- packages/typescript/ai/src/middlewares/otel.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/typescript/ai/src/middlewares/otel.ts b/packages/typescript/ai/src/middlewares/otel.ts index d81b36aa0..91cf1452f 100644 --- a/packages/typescript/ai/src/middlewares/otel.ts +++ b/packages/typescript/ai/src/middlewares/otel.ts @@ -42,6 +42,7 @@ interface RequestState { } const stateByCtx = new WeakMap() +void stateByCtx function safeCall(label: string, fn: () => T): T | undefined { try { @@ -52,6 +53,7 @@ function safeCall(label: string, fn: () => T): T | undefined { return undefined } } +void safeCall export function otelMiddleware( options: OtelMiddlewareOptions, @@ -82,6 +84,7 @@ export function otelMiddleware( unit: '{token}', }, ) + void _durationHistogram; void _tokenHistogram return { name: 'otel', From 226550296eb4f158297bfc898eef8e1dbfb69867 Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Thu, 23 Apr 2026 17:03:43 +0200 Subject: [PATCH 06/31] feat(ai): otel middleware emits root chat span --- .../typescript/ai/src/middlewares/otel.ts | 50 ++++++++++++++++--- .../ai/tests/middlewares/otel.test.ts | 24 +++++++++ 2 files changed, 67 insertions(+), 7 deletions(-) create mode 100644 packages/typescript/ai/tests/middlewares/otel.test.ts diff --git a/packages/typescript/ai/src/middlewares/otel.ts b/packages/typescript/ai/src/middlewares/otel.ts index 91cf1452f..502528bdd 100644 --- a/packages/typescript/ai/src/middlewares/otel.ts +++ b/packages/typescript/ai/src/middlewares/otel.ts @@ -42,7 +42,6 @@ interface RequestState { } const stateByCtx = new WeakMap() -void stateByCtx function safeCall(label: string, fn: () => T): T | undefined { try { @@ -53,21 +52,20 @@ function safeCall(label: string, fn: () => T): T | undefined { return undefined } } -void safeCall export function otelMiddleware( options: OtelMiddlewareOptions, ): ChatMiddleware { const { - tracer: _tracer, + tracer, meter, captureContent: _captureContent = false, redact: _redact = (s) => s, serviceName: _serviceName = 'tanstack-ai', - spanNameFormatter: _spanNameFormatter, - attributeEnricher: _attributeEnricher, - onBeforeSpanStart: _onBeforeSpanStart, - onSpanEnd: _onSpanEnd, + spanNameFormatter, + attributeEnricher, + onBeforeSpanStart, + onSpanEnd, } = options const _durationHistogram = meter?.createHistogram( @@ -88,5 +86,43 @@ export function otelMiddleware( return { name: 'otel', + + onStart(ctx) { + safeCall('otel.onStart', () => { + const info: OtelSpanInfo<'chat'> = { kind: 'chat', ctx } + const name = safeCall('otel.spanNameFormatter', () => spanNameFormatter?.(info)) ?? `chat ${ctx.model}` + const baseOptions: SpanOptions = { + attributes: { + 'gen_ai.system': ctx.provider, + 'gen_ai.operation.name': 'chat', + 'gen_ai.request.model': ctx.model, + }, + } + const spanOptions = safeCall('otel.onBeforeSpanStart', () => onBeforeSpanStart?.(info, baseOptions)) ?? baseOptions + const rootSpan = tracer.startSpan(name, spanOptions) + + const enriched = safeCall('otel.attributeEnricher', () => attributeEnricher?.(info)) + if (enriched) rootSpan.setAttributes(enriched) + + stateByCtx.set(ctx, { + rootSpan, + currentIterationSpan: null, + toolSpans: new Map(), + iterationCount: 0, + assistantTextBuffer: '', + startTime: Date.now(), + }) + }) + }, + + onFinish(ctx, _info) { + safeCall('otel.onFinish', () => { + const state = stateByCtx.get(ctx) + if (!state) return + safeCall('otel.onSpanEnd', () => onSpanEnd?.({ kind: 'chat', ctx }, state.rootSpan)) + state.rootSpan.end() + stateByCtx.delete(ctx) + }) + }, } } diff --git a/packages/typescript/ai/tests/middlewares/otel.test.ts b/packages/typescript/ai/tests/middlewares/otel.test.ts new file mode 100644 index 000000000..7c9dde44b --- /dev/null +++ b/packages/typescript/ai/tests/middlewares/otel.test.ts @@ -0,0 +1,24 @@ +import { describe, it, expect } from 'vitest' +import { SpanStatusCode } from '@opentelemetry/api' +import { otelMiddleware } from '../../src/middlewares/otel' +import { createFakeTracer, makeCtx } from './fake-otel' + +describe('otelMiddleware — root span lifecycle', () => { + it('creates a root span on onStart and closes it on onFinish', async () => { + const { tracer, spans } = createFakeTracer() + const mw = otelMiddleware({ tracer }) + const ctx = makeCtx() + + await mw.onStart?.(ctx) + expect(spans).toHaveLength(1) + expect(spans[0]!.name).toBe('chat gpt-4o') + expect(spans[0]!.ended).toBe(false) + expect(spans[0]!.attributes['gen_ai.system']).toBe('openai') + expect(spans[0]!.attributes['gen_ai.operation.name']).toBe('chat') + expect(spans[0]!.attributes['gen_ai.request.model']).toBe('gpt-4o') + + await mw.onFinish?.(ctx, { finishReason: 'stop', duration: 10, content: '' }) + expect(spans[0]!.ended).toBe(true) + expect(spans[0]!.status.code).toBe(SpanStatusCode.UNSET) + }) +}) From 6d649e134e45c3639f74be7d98c42a2a53a03c47 Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Thu, 23 Apr 2026 17:10:12 +0200 Subject: [PATCH 07/31] feat(ai): otel middleware emits per-iteration spans --- .../typescript/ai/src/middlewares/otel.ts | 71 +++++++++++++++++++ .../ai/tests/middlewares/otel.test.ts | 66 +++++++++++++++++ 2 files changed, 137 insertions(+) diff --git a/packages/typescript/ai/src/middlewares/otel.ts b/packages/typescript/ai/src/middlewares/otel.ts index 502528bdd..65ecfbc9c 100644 --- a/packages/typescript/ai/src/middlewares/otel.ts +++ b/packages/typescript/ai/src/middlewares/otel.ts @@ -5,6 +5,7 @@ import type { SpanOptions, Tracer, } from '@opentelemetry/api' +import { context as otelContext, trace as otelTrace } from '@opentelemetry/api' import type { ChatMiddleware, ChatMiddlewareContext, @@ -115,6 +116,76 @@ export function otelMiddleware( }) }, + onConfig(ctx, config) { + if (ctx.phase !== 'beforeModel') return + safeCall('otel.onConfig', () => { + const state = stateByCtx.get(ctx) + if (!state) return + + // Close any previously open iteration span (defensive — shouldn't normally be open + // here because onChunk(RUN_FINISHED) closes it, but guard against adapter quirks). + if (state.currentIterationSpan) { + safeCall('otel.onSpanEnd', () => + onSpanEnd?.({ kind: 'iteration', ctx, iteration: state.iterationCount - 1 }, state.currentIterationSpan!), + ) + state.currentIterationSpan.end() + state.currentIterationSpan = null + } + + const info: OtelSpanInfo<'iteration'> = { kind: 'iteration', ctx, iteration: ctx.iteration } + const name = safeCall('otel.spanNameFormatter', () => spanNameFormatter?.(info)) ?? `chat ${ctx.model}` + + const baseAttrs: Record = { + 'gen_ai.system': ctx.provider, + 'gen_ai.operation.name': 'chat', + 'gen_ai.request.model': ctx.model, + 'tanstack.ai.iteration': ctx.iteration, + } + if (config.temperature !== undefined) baseAttrs['gen_ai.request.temperature'] = config.temperature + if (config.topP !== undefined) baseAttrs['gen_ai.request.top_p'] = config.topP + if (config.maxTokens !== undefined) baseAttrs['gen_ai.request.max_tokens'] = config.maxTokens + + const baseOptions: SpanOptions = { attributes: baseAttrs } + const spanOptions = safeCall('otel.onBeforeSpanStart', () => onBeforeSpanStart?.(info, baseOptions)) ?? baseOptions + + let iterSpan!: Span + otelContext.with(otelTrace.setSpan(otelContext.active(), state.rootSpan), () => { + iterSpan = tracer.startSpan(name, spanOptions) + }) + // Fake-tracer test visibility: explicit parent pointer. In real OTel this is a + // no-op field write; the actual parent-child relationship is established via the + // active context above. + ;(iterSpan as unknown as { parent?: Span }).parent = state.rootSpan + + const enriched = safeCall('otel.attributeEnricher', () => attributeEnricher?.(info)) + if (enriched) iterSpan.setAttributes(enriched) + + state.currentIterationSpan = iterSpan + state.iterationCount += 1 + }) + return undefined + }, + + onChunk(ctx, chunk) { + safeCall('otel.onChunk', () => { + if (chunk.type !== 'RUN_FINISHED') return + const state = stateByCtx.get(ctx) + if (!state || !state.currentIterationSpan) return + const span = state.currentIterationSpan + if (chunk.finishReason) { + span.setAttribute('gen_ai.response.finish_reasons', [chunk.finishReason]) + } + if (chunk.model) span.setAttribute('gen_ai.response.model', chunk.model) + + safeCall('otel.onSpanEnd', () => + onSpanEnd?.({ kind: 'iteration', ctx, iteration: state.iterationCount - 1 }, span), + ) + span.end() + state.currentIterationSpan = null + }) + return undefined + }, + onFinish(ctx, _info) { safeCall('otel.onFinish', () => { const state = stateByCtx.get(ctx) diff --git a/packages/typescript/ai/tests/middlewares/otel.test.ts b/packages/typescript/ai/tests/middlewares/otel.test.ts index 7c9dde44b..47d14f4bd 100644 --- a/packages/typescript/ai/tests/middlewares/otel.test.ts +++ b/packages/typescript/ai/tests/middlewares/otel.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect } from 'vitest' import { SpanStatusCode } from '@opentelemetry/api' import { otelMiddleware } from '../../src/middlewares/otel' +import { EventType } from '../../src/types' import { createFakeTracer, makeCtx } from './fake-otel' describe('otelMiddleware — root span lifecycle', () => { @@ -22,3 +23,68 @@ describe('otelMiddleware — root span lifecycle', () => { expect(spans[0]!.status.code).toBe(SpanStatusCode.UNSET) }) }) + +describe('otelMiddleware — iteration span lifecycle', () => { + it('opens an iteration span on onConfig(beforeModel) and closes it on RUN_FINISHED chunk', async () => { + const { tracer, spans } = createFakeTracer() + const mw = otelMiddleware({ tracer }) + const ctx = makeCtx() + ctx.phase = 'init' + + await mw.onStart?.(ctx) + ctx.phase = 'beforeModel' + + await mw.onConfig?.(ctx, { + messages: [{ role: 'user', content: 'hi' }], + systemPrompts: [], + tools: [], + temperature: 0.7, + topP: 0.9, + maxTokens: 512, + }) + + const [rootSpan, iterSpan] = spans + expect(spans).toHaveLength(2) + expect(iterSpan!.parent).toBe(rootSpan) + expect(iterSpan!.name).toBe('chat gpt-4o') + expect(iterSpan!.ended).toBe(false) + + await mw.onChunk?.(ctx, { + type: EventType.RUN_FINISHED, + threadId: 't-1', + runId: 'r-1', + model: 'gpt-4o', + timestamp: Date.now(), + finishReason: 'stop', + }) + expect(iterSpan!.ended).toBe(true) + expect(iterSpan!.attributes['gen_ai.response.finish_reasons']).toEqual(['stop']) + + await mw.onFinish?.(ctx, { finishReason: 'stop', duration: 10, content: '' }) + expect(rootSpan!.ended).toBe(true) + }) + + it('opens a fresh iteration span for each onConfig(beforeModel)', async () => { + const { tracer, spans } = createFakeTracer() + const mw = otelMiddleware({ tracer }) + const ctx = makeCtx() + + await mw.onStart?.(ctx) + ctx.phase = 'beforeModel' + await mw.onConfig?.(ctx, { messages: [], systemPrompts: [], tools: [] }) + await mw.onChunk?.(ctx, { + type: EventType.RUN_FINISHED, threadId: 't-1', runId: 'r-1', model: 'gpt-4o', timestamp: 0, finishReason: 'tool_calls', + }) + ctx.iteration = 1 + await mw.onConfig?.(ctx, { messages: [], systemPrompts: [], tools: [] }) + await mw.onChunk?.(ctx, { + type: EventType.RUN_FINISHED, threadId: 't-1', runId: 'r-2', model: 'gpt-4o', timestamp: 0, finishReason: 'stop', + }) + await mw.onFinish?.(ctx, { finishReason: 'stop', duration: 10, content: '' }) + + // 1 root + 2 iteration spans + expect(spans).toHaveLength(3) + expect(spans[1]!.ended).toBe(true) + expect(spans[2]!.ended).toBe(true) + }) +}) From c40eb8fe63b8160091287e7a436b59951dad8752 Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Thu, 23 Apr 2026 17:14:58 +0200 Subject: [PATCH 08/31] feat(ai): otel middleware records token histogram and usage attrs --- .../typescript/ai/src/middlewares/otel.ts | 26 +++++++++- .../ai/tests/middlewares/otel.test.ts | 52 ++++++++++++++++++- 2 files changed, 75 insertions(+), 3 deletions(-) diff --git a/packages/typescript/ai/src/middlewares/otel.ts b/packages/typescript/ai/src/middlewares/otel.ts index 65ecfbc9c..4c8e519ad 100644 --- a/packages/typescript/ai/src/middlewares/otel.ts +++ b/packages/typescript/ai/src/middlewares/otel.ts @@ -76,14 +76,14 @@ export function otelMiddleware( unit: 's', }, ) - const _tokenHistogram = meter?.createHistogram( + const tokenHistogram = meter?.createHistogram( 'gen_ai.client.token.usage', { description: 'GenAI client token usage', unit: '{token}', }, ) - void _durationHistogram; void _tokenHistogram + void _durationHistogram return { name: 'otel', @@ -186,6 +186,28 @@ export function otelMiddleware( return undefined }, + onUsage(ctx, usage) { + safeCall('otel.onUsage', () => { + const state = stateByCtx.get(ctx) + if (!state || !state.currentIterationSpan) return + + state.currentIterationSpan.setAttributes({ + 'gen_ai.usage.input_tokens': usage.promptTokens, + 'gen_ai.usage.output_tokens': usage.completionTokens, + }) + + if (tokenHistogram) { + const metricAttrs = { + 'gen_ai.system': ctx.provider, + 'gen_ai.operation.name': 'chat', + 'gen_ai.request.model': ctx.model, + } + tokenHistogram.record(usage.promptTokens, { ...metricAttrs, 'gen_ai.token.type': 'input' }) + tokenHistogram.record(usage.completionTokens, { ...metricAttrs, 'gen_ai.token.type': 'output' }) + } + }) + }, + onFinish(ctx, _info) { safeCall('otel.onFinish', () => { const state = stateByCtx.get(ctx) diff --git a/packages/typescript/ai/tests/middlewares/otel.test.ts b/packages/typescript/ai/tests/middlewares/otel.test.ts index 47d14f4bd..335fe2396 100644 --- a/packages/typescript/ai/tests/middlewares/otel.test.ts +++ b/packages/typescript/ai/tests/middlewares/otel.test.ts @@ -2,7 +2,7 @@ import { describe, it, expect } from 'vitest' import { SpanStatusCode } from '@opentelemetry/api' import { otelMiddleware } from '../../src/middlewares/otel' import { EventType } from '../../src/types' -import { createFakeTracer, makeCtx } from './fake-otel' +import { createFakeTracer, createFakeMeter, makeCtx } from './fake-otel' describe('otelMiddleware — root span lifecycle', () => { it('creates a root span on onStart and closes it on onFinish', async () => { @@ -88,3 +88,53 @@ describe('otelMiddleware — iteration span lifecycle', () => { expect(spans[2]!.ended).toBe(true) }) }) + +describe('otelMiddleware — token histogram', () => { + it('records input and output token histograms on onUsage', async () => { + const { tracer } = createFakeTracer() + const { meter, records } = createFakeMeter() + const mw = otelMiddleware({ tracer, meter }) + const ctx = makeCtx() + + await mw.onStart?.(ctx) + ctx.phase = 'beforeModel' + await mw.onConfig?.(ctx, { messages: [], systemPrompts: [], tools: [] }) + await mw.onUsage?.(ctx, { promptTokens: 100, completionTokens: 50, totalTokens: 150 }) + + const tokenRecords = records.filter((r) => r.name === 'gen_ai.client.token.usage') + expect(tokenRecords).toHaveLength(2) + expect(tokenRecords.find((r) => r.attributes!['gen_ai.token.type'] === 'input')!.value).toBe(100) + expect(tokenRecords.find((r) => r.attributes!['gen_ai.token.type'] === 'output')!.value).toBe(50) + + // Cardinality guard: response.id must NOT appear on metric attributes. + for (const r of tokenRecords) { + expect(r.attributes!['gen_ai.response.id']).toBeUndefined() + } + }) + + it('sets gen_ai.usage.* attributes on the iteration span', async () => { + const { tracer, spans } = createFakeTracer() + const mw = otelMiddleware({ tracer }) + const ctx = makeCtx() + + await mw.onStart?.(ctx) + ctx.phase = 'beforeModel' + await mw.onConfig?.(ctx, { messages: [], systemPrompts: [], tools: [] }) + await mw.onUsage?.(ctx, { promptTokens: 100, completionTokens: 50, totalTokens: 150 }) + + expect(spans[1]!.attributes['gen_ai.usage.input_tokens']).toBe(100) + expect(spans[1]!.attributes['gen_ai.usage.output_tokens']).toBe(50) + }) + + it('skips metrics when meter is not provided', async () => { + const { tracer } = createFakeTracer() + const mw = otelMiddleware({ tracer }) + const ctx = makeCtx() + + await mw.onStart?.(ctx) + ctx.phase = 'beforeModel' + await mw.onConfig?.(ctx, { messages: [], systemPrompts: [], tools: [] }) + // Should not throw: + await mw.onUsage?.(ctx, { promptTokens: 100, completionTokens: 50, totalTokens: 150 }) + }) +}) From 34276f6d21b2275f1f4cbaa336500c4efd446377 Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Thu, 23 Apr 2026 17:18:15 +0200 Subject: [PATCH 09/31] feat(ai): otel middleware emits tool spans --- .../typescript/ai/src/middlewares/otel.ts | 64 +++++++++++++++- .../ai/tests/middlewares/otel.test.ts | 74 +++++++++++++++++++ 2 files changed, 137 insertions(+), 1 deletion(-) diff --git a/packages/typescript/ai/src/middlewares/otel.ts b/packages/typescript/ai/src/middlewares/otel.ts index 4c8e519ad..ef877651e 100644 --- a/packages/typescript/ai/src/middlewares/otel.ts +++ b/packages/typescript/ai/src/middlewares/otel.ts @@ -5,7 +5,7 @@ import type { SpanOptions, Tracer, } from '@opentelemetry/api' -import { context as otelContext, trace as otelTrace } from '@opentelemetry/api' +import { context as otelContext, trace as otelTrace, SpanStatusCode } from '@opentelemetry/api' import type { ChatMiddleware, ChatMiddlewareContext, @@ -208,6 +208,68 @@ export function otelMiddleware( }) }, + onBeforeToolCall(ctx, hookCtx) { + safeCall('otel.onBeforeToolCall', () => { + const state = stateByCtx.get(ctx) + if (!state || !state.currentIterationSpan) return + + const info: OtelSpanInfo<'tool'> = { + kind: 'tool', + ctx, + toolName: hookCtx.toolName, + toolCallId: hookCtx.toolCallId, + iteration: state.iterationCount - 1, + } + const name = safeCall('otel.spanNameFormatter', () => spanNameFormatter?.(info)) ?? `execute_tool ${hookCtx.toolName}` + + const baseAttrs: Record = { + 'gen_ai.tool.name': hookCtx.toolName, + 'gen_ai.tool.call.id': hookCtx.toolCallId, + 'gen_ai.tool.type': 'function', + } + const baseOptions: SpanOptions = { attributes: baseAttrs } + const spanOptions = safeCall('otel.onBeforeSpanStart', () => onBeforeSpanStart?.(info, baseOptions)) ?? baseOptions + + let toolSpan!: Span + otelContext.with(otelTrace.setSpan(otelContext.active(), state.currentIterationSpan), () => { + toolSpan = tracer.startSpan(name, spanOptions) + }) + ;(toolSpan as unknown as { parent?: Span }).parent = state.currentIterationSpan + + const enriched = safeCall('otel.attributeEnricher', () => attributeEnricher?.(info)) + if (enriched) toolSpan.setAttributes(enriched) + + state.toolSpans.set(hookCtx.toolCallId, toolSpan) + }) + return undefined + }, + + onAfterToolCall(ctx, info) { + safeCall('otel.onAfterToolCall', () => { + const state = stateByCtx.get(ctx) + if (!state) return + const toolSpan = state.toolSpans.get(info.toolCallId) + if (!toolSpan) return + + const outcome = info.ok ? 'success' : 'error' + toolSpan.setAttribute('tanstack.ai.tool.outcome', outcome) + + if (!info.ok && info.error !== undefined) { + toolSpan.recordException(info.error as Error) + toolSpan.setStatus({ code: SpanStatusCode.ERROR, message: (info.error as Error)?.message }) + } + + safeCall('otel.onSpanEnd', () => + onSpanEnd?.( + { kind: 'tool', ctx, toolName: info.toolName, toolCallId: info.toolCallId, iteration: state.iterationCount - 1 }, + toolSpan, + ), + ) + toolSpan.end() + state.toolSpans.delete(info.toolCallId) + }) + }, + onFinish(ctx, _info) { safeCall('otel.onFinish', () => { const state = stateByCtx.get(ctx) diff --git a/packages/typescript/ai/tests/middlewares/otel.test.ts b/packages/typescript/ai/tests/middlewares/otel.test.ts index 335fe2396..640ad7ffe 100644 --- a/packages/typescript/ai/tests/middlewares/otel.test.ts +++ b/packages/typescript/ai/tests/middlewares/otel.test.ts @@ -138,3 +138,77 @@ describe('otelMiddleware — token histogram', () => { await mw.onUsage?.(ctx, { promptTokens: 100, completionTokens: 50, totalTokens: 150 }) }) }) + +describe('otelMiddleware — tool spans', () => { + it('creates a tool span as child of the iteration span', async () => { + const { tracer, spans } = createFakeTracer() + const mw = otelMiddleware({ tracer }) + const ctx = makeCtx({ hasTools: true, toolNames: ['get_weather'] }) + + await mw.onStart?.(ctx) + ctx.phase = 'beforeModel' + await mw.onConfig?.(ctx, { messages: [], systemPrompts: [], tools: [] }) + + const iterSpan = spans[1]! + await mw.onBeforeToolCall?.(ctx, { + toolCall: { id: 'tc-1', type: 'function', function: { name: 'get_weather', arguments: '{}' } } as any, + tool: undefined, + args: { city: 'NYC' }, + toolName: 'get_weather', + toolCallId: 'tc-1', + }) + + const toolSpan = spans[2]! + expect(toolSpan.name).toBe('execute_tool get_weather') + expect(toolSpan.parent).toBe(iterSpan) + expect(toolSpan.attributes['gen_ai.tool.name']).toBe('get_weather') + expect(toolSpan.attributes['gen_ai.tool.call.id']).toBe('tc-1') + expect(toolSpan.attributes['gen_ai.tool.type']).toBe('function') + expect(toolSpan.ended).toBe(false) + + await mw.onAfterToolCall?.(ctx, { + toolCall: { id: 'tc-1' } as any, + tool: undefined, + toolName: 'get_weather', + toolCallId: 'tc-1', + ok: true, + duration: 42, + result: { temp: 72 }, + }) + + expect(toolSpan.ended).toBe(true) + expect(toolSpan.attributes['tanstack.ai.tool.outcome']).toBe('success') + }) + + it('records exception and error outcome on tool failure', async () => { + const { tracer, spans } = createFakeTracer() + const mw = otelMiddleware({ tracer }) + const ctx = makeCtx({ hasTools: true }) + + await mw.onStart?.(ctx) + ctx.phase = 'beforeModel' + await mw.onConfig?.(ctx, { messages: [], systemPrompts: [], tools: [] }) + await mw.onBeforeToolCall?.(ctx, { + toolCall: { id: 'tc-2', type: 'function', function: { name: 'broken', arguments: '{}' } } as any, + tool: undefined, + args: {}, + toolName: 'broken', + toolCallId: 'tc-2', + }) + const toolSpan = spans[2]! + await mw.onAfterToolCall?.(ctx, { + toolCall: { id: 'tc-2' } as any, + tool: undefined, + toolName: 'broken', + toolCallId: 'tc-2', + ok: false, + duration: 5, + error: new Error('boom'), + }) + + expect(toolSpan.attributes['tanstack.ai.tool.outcome']).toBe('error') + expect(toolSpan.exceptions).toHaveLength(1) + expect((toolSpan.exceptions[0]!.exception as Error).message).toBe('boom') + expect(toolSpan.status.code).toBe(SpanStatusCode.ERROR) + }) +}) From 4e74a8ca390c0c0d943c3dbd2c96c62237b85b5b Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Thu, 23 Apr 2026 17:21:05 +0200 Subject: [PATCH 10/31] feat(ai): otel middleware records duration histogram + root rollup --- .../typescript/ai/src/middlewares/otel.ts | 36 +++++++++++++++-- .../ai/tests/middlewares/otel.test.ts | 40 +++++++++++++++++++ 2 files changed, 72 insertions(+), 4 deletions(-) diff --git a/packages/typescript/ai/src/middlewares/otel.ts b/packages/typescript/ai/src/middlewares/otel.ts index ef877651e..0ffc6c729 100644 --- a/packages/typescript/ai/src/middlewares/otel.ts +++ b/packages/typescript/ai/src/middlewares/otel.ts @@ -69,7 +69,7 @@ export function otelMiddleware( onSpanEnd, } = options - const _durationHistogram = meter?.createHistogram( + const durationHistogram = meter?.createHistogram( 'gen_ai.client.operation.duration', { description: 'GenAI client operation duration', @@ -83,8 +83,6 @@ export function otelMiddleware( unit: '{token}', }, ) - void _durationHistogram - return { name: 'otel', @@ -270,10 +268,40 @@ export function otelMiddleware( }) }, - onFinish(ctx, _info) { + onFinish(ctx, info) { safeCall('otel.onFinish', () => { const state = stateByCtx.get(ctx) if (!state) return + + // Close a dangling iteration span if RUN_FINISHED never arrived (defensive). + if (state.currentIterationSpan) { + safeCall('otel.onSpanEnd', () => + onSpanEnd?.({ kind: 'iteration', ctx, iteration: state.iterationCount - 1 }, state.currentIterationSpan!), + ) + state.currentIterationSpan.end() + state.currentIterationSpan = null + } + + if (durationHistogram) { + durationHistogram.record(info.duration / 1000, { + 'gen_ai.system': ctx.provider, + 'gen_ai.operation.name': 'chat', + 'gen_ai.request.model': ctx.model, + 'gen_ai.response.model': ctx.model, + }) + } + + if (info.usage) { + state.rootSpan.setAttributes({ + 'gen_ai.usage.input_tokens': info.usage.promptTokens, + 'gen_ai.usage.output_tokens': info.usage.completionTokens, + }) + } + if (info.finishReason) { + state.rootSpan.setAttribute('gen_ai.response.finish_reasons', [info.finishReason]) + } + state.rootSpan.setAttribute('tanstack.ai.iterations', state.iterationCount) + safeCall('otel.onSpanEnd', () => onSpanEnd?.({ kind: 'chat', ctx }, state.rootSpan)) state.rootSpan.end() stateByCtx.delete(ctx) diff --git a/packages/typescript/ai/tests/middlewares/otel.test.ts b/packages/typescript/ai/tests/middlewares/otel.test.ts index 640ad7ffe..e72d8ef09 100644 --- a/packages/typescript/ai/tests/middlewares/otel.test.ts +++ b/packages/typescript/ai/tests/middlewares/otel.test.ts @@ -139,6 +139,46 @@ describe('otelMiddleware — token histogram', () => { }) }) +describe('otelMiddleware — duration histogram and rollup', () => { + it('records duration histogram on onFinish and rolls up tokens onto root', async () => { + const { tracer, spans } = createFakeTracer() + const { meter, records } = createFakeMeter() + const mw = otelMiddleware({ tracer, meter }) + const ctx = makeCtx() + + await mw.onStart?.(ctx) + ctx.phase = 'beforeModel' + await mw.onConfig?.(ctx, { messages: [], systemPrompts: [], tools: [] }) + await mw.onUsage?.(ctx, { promptTokens: 100, completionTokens: 50, totalTokens: 150 }) + await mw.onChunk?.(ctx, { + type: EventType.RUN_FINISHED, + threadId: 't-1', + runId: 'r', + model: 'gpt-4o', + timestamp: 0, + finishReason: 'stop', + }) + await mw.onFinish?.(ctx, { + finishReason: 'stop', + duration: 1250, + content: '', + usage: { promptTokens: 100, completionTokens: 50, totalTokens: 150 }, + }) + + const durationRecords = records.filter((r) => r.name === 'gen_ai.client.operation.duration') + expect(durationRecords).toHaveLength(1) + expect(durationRecords[0]!.value).toBe(1.25) + expect(durationRecords[0]!.attributes!['gen_ai.response.model']).toBe('gpt-4o') + expect(durationRecords[0]!.attributes!['error.type']).toBeUndefined() + + const root = spans[0]! + expect(root.attributes['gen_ai.usage.input_tokens']).toBe(100) + expect(root.attributes['gen_ai.usage.output_tokens']).toBe(50) + expect(root.attributes['tanstack.ai.iterations']).toBe(1) + expect(root.ended).toBe(true) + }) +}) + describe('otelMiddleware — tool spans', () => { it('creates a tool span as child of the iteration span', async () => { const { tracer, spans } = createFakeTracer() From 4e8e60735fd4ecd891e1352c19cfe2f6cd70b7f8 Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Thu, 23 Apr 2026 17:24:49 +0200 Subject: [PATCH 11/31] feat(ai): otel middleware emits error and cancelled spans --- .../typescript/ai/src/middlewares/otel.ts | 76 +++++++++++++++++++ .../ai/tests/middlewares/otel.test.ts | 43 +++++++++++ 2 files changed, 119 insertions(+) diff --git a/packages/typescript/ai/src/middlewares/otel.ts b/packages/typescript/ai/src/middlewares/otel.ts index 0ffc6c729..e5188be70 100644 --- a/packages/typescript/ai/src/middlewares/otel.ts +++ b/packages/typescript/ai/src/middlewares/otel.ts @@ -268,6 +268,82 @@ export function otelMiddleware( }) }, + onError(ctx, info) { + safeCall('otel.onError', () => { + const state = stateByCtx.get(ctx) + if (!state) return + + const errType = (info.error as { name?: string } | undefined)?.name ?? 'Error' + const message = (info.error as { message?: string } | undefined)?.message + + // Close iteration span (if open) with ERROR. + if (state.currentIterationSpan) { + state.currentIterationSpan.recordException(info.error as Error) + state.currentIterationSpan.setStatus({ code: SpanStatusCode.ERROR, message }) + safeCall('otel.onSpanEnd', () => + onSpanEnd?.({ kind: 'iteration', ctx, iteration: state.iterationCount - 1 }, state.currentIterationSpan!), + ) + state.currentIterationSpan.end() + state.currentIterationSpan = null + } + + // Close any open tool spans as errored. + for (const [id, span] of state.toolSpans) { + span.recordException(info.error as Error) + span.setStatus({ code: SpanStatusCode.ERROR, message }) + span.end() + state.toolSpans.delete(id) + } + + state.rootSpan.recordException(info.error as Error) + state.rootSpan.setStatus({ code: SpanStatusCode.ERROR, message }) + + if (durationHistogram) { + durationHistogram.record(info.duration / 1000, { + 'gen_ai.system': ctx.provider, + 'gen_ai.operation.name': 'chat', + 'gen_ai.request.model': ctx.model, + 'gen_ai.response.model': ctx.model, + 'error.type': errType, + }) + } + + safeCall('otel.onSpanEnd', () => onSpanEnd?.({ kind: 'chat', ctx }, state.rootSpan)) + state.rootSpan.end() + stateByCtx.delete(ctx) + }) + }, + + onAbort(ctx, _info) { + safeCall('otel.onAbort', () => { + const state = stateByCtx.get(ctx) + if (!state) return + + const closeCancelled = (span: Span) => { + span.setAttribute('gen_ai.completion.reason', 'cancelled') + span.setStatus({ code: SpanStatusCode.ERROR, message: 'cancelled' }) + } + + if (state.currentIterationSpan) { + closeCancelled(state.currentIterationSpan) + safeCall('otel.onSpanEnd', () => + onSpanEnd?.({ kind: 'iteration', ctx, iteration: state.iterationCount - 1 }, state.currentIterationSpan!), + ) + state.currentIterationSpan.end() + state.currentIterationSpan = null + } + for (const [id, span] of state.toolSpans) { + closeCancelled(span) + span.end() + state.toolSpans.delete(id) + } + closeCancelled(state.rootSpan) + safeCall('otel.onSpanEnd', () => onSpanEnd?.({ kind: 'chat', ctx }, state.rootSpan)) + state.rootSpan.end() + stateByCtx.delete(ctx) + }) + }, + onFinish(ctx, info) { safeCall('otel.onFinish', () => { const state = stateByCtx.get(ctx) diff --git a/packages/typescript/ai/tests/middlewares/otel.test.ts b/packages/typescript/ai/tests/middlewares/otel.test.ts index e72d8ef09..d2d9f3825 100644 --- a/packages/typescript/ai/tests/middlewares/otel.test.ts +++ b/packages/typescript/ai/tests/middlewares/otel.test.ts @@ -252,3 +252,46 @@ describe('otelMiddleware — tool spans', () => { expect(toolSpan.status.code).toBe(SpanStatusCode.ERROR) }) }) + +describe('otelMiddleware — error and abort paths', () => { + it('onError sets ERROR status, records exception, adds error.type to duration histogram', async () => { + const { tracer, spans } = createFakeTracer() + const { meter, records } = createFakeMeter() + const mw = otelMiddleware({ tracer, meter }) + const ctx = makeCtx() + + await mw.onStart?.(ctx) + ctx.phase = 'beforeModel' + await mw.onConfig?.(ctx, { messages: [], systemPrompts: [], tools: [] }) + const err = new Error('rate limited') + ;(err as any).name = 'RateLimitError' + await mw.onError?.(ctx, { error: err, duration: 200 }) + + const root = spans[0]! + expect(root.status.code).toBe(SpanStatusCode.ERROR) + expect(root.exceptions).toHaveLength(1) + expect(root.ended).toBe(true) + + const iter = spans[1]! + expect(iter.status.code).toBe(SpanStatusCode.ERROR) + expect(iter.ended).toBe(true) + + const durationRecords = records.filter((r) => r.name === 'gen_ai.client.operation.duration') + expect(durationRecords[0]!.attributes!['error.type']).toBe('RateLimitError') + }) + + it('onAbort sets ERROR status and cancelled reason', async () => { + const { tracer, spans } = createFakeTracer() + const mw = otelMiddleware({ tracer }) + const ctx = makeCtx() + + await mw.onStart?.(ctx) + ctx.phase = 'beforeModel' + await mw.onConfig?.(ctx, { messages: [], systemPrompts: [], tools: [] }) + await mw.onAbort?.(ctx, { reason: 'user stop', duration: 80 }) + + expect(spans[0]!.status.code).toBe(SpanStatusCode.ERROR) + expect(spans[0]!.attributes['gen_ai.completion.reason']).toBe('cancelled') + expect(spans[0]!.ended).toBe(true) + }) +}) From 4b96fcb23121b226dc76847373c5b0064fac6672 Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Thu, 23 Apr 2026 17:26:43 +0200 Subject: [PATCH 12/31] test(ai): assert iteration span exception is recorded on onError --- packages/typescript/ai/tests/middlewares/otel.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/typescript/ai/tests/middlewares/otel.test.ts b/packages/typescript/ai/tests/middlewares/otel.test.ts index d2d9f3825..e0b84b917 100644 --- a/packages/typescript/ai/tests/middlewares/otel.test.ts +++ b/packages/typescript/ai/tests/middlewares/otel.test.ts @@ -275,6 +275,7 @@ describe('otelMiddleware — error and abort paths', () => { const iter = spans[1]! expect(iter.status.code).toBe(SpanStatusCode.ERROR) expect(iter.ended).toBe(true) + expect(iter.exceptions).toHaveLength(1) const durationRecords = records.filter((r) => r.name === 'gen_ai.client.operation.duration') expect(durationRecords[0]!.attributes!['error.type']).toBe('RateLimitError') From 17d2297b2d4bbc48f74b24d6051cee536c55fb1d Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Thu, 23 Apr 2026 17:29:12 +0200 Subject: [PATCH 13/31] feat(ai): otel middleware captures gen_ai.* message events --- .../typescript/ai/src/middlewares/otel.ts | 60 ++++++++++++++- .../ai/tests/middlewares/otel.test.ts | 73 +++++++++++++++++++ 2 files changed, 131 insertions(+), 2 deletions(-) diff --git a/packages/typescript/ai/src/middlewares/otel.ts b/packages/typescript/ai/src/middlewares/otel.ts index e5188be70..bb16a7fec 100644 --- a/packages/typescript/ai/src/middlewares/otel.ts +++ b/packages/typescript/ai/src/middlewares/otel.ts @@ -44,6 +44,46 @@ interface RequestState { const stateByCtx = new WeakMap() +function serializeContent(content: unknown): string { + if (typeof content === 'string') return content + if (!Array.isArray(content)) return '' + const parts: string[] = [] + for (const part of content) { + if (!part || typeof part !== 'object') continue + const type = (part as { type?: string }).type + switch (type) { + case 'text': + parts.push(((part as { text?: string }).text ?? (part as { content?: string }).content ?? '').toString()) + break + case 'image': + parts.push('[image]') + break + case 'audio': + parts.push('[audio]') + break + case 'video': + parts.push('[video]') + break + case 'document': + parts.push('[document]') + break + default: + parts.push(`[${type ?? 'unknown'}]`) + } + } + return parts.join(' ') +} + +function messageEventName(role: string): string { + switch (role) { + case 'user': return 'gen_ai.user.message' + case 'assistant': return 'gen_ai.assistant.message' + case 'tool': return 'gen_ai.tool.message' + case 'system': return 'gen_ai.system.message' + default: return `gen_ai.${role}.message` + } +} + function safeCall(label: string, fn: () => T): T | undefined { try { return fn() @@ -60,8 +100,8 @@ export function otelMiddleware( const { tracer, meter, - captureContent: _captureContent = false, - redact: _redact = (s) => s, + captureContent = false, + redact = (s) => s, serviceName: _serviceName = 'tanstack-ai', spanNameFormatter, attributeEnricher, @@ -159,6 +199,22 @@ export function otelMiddleware( if (enriched) iterSpan.setAttributes(enriched) state.currentIterationSpan = iterSpan + + if (captureContent) { + for (const sys of config.systemPrompts ?? []) { + iterSpan.addEvent('gen_ai.system.message', { + content: safeCall('otel.redact', () => redact(sys)) ?? sys, + }) + } + for (const m of config.messages ?? []) { + const body = serializeContent(m.content) + if (body.length === 0) continue + iterSpan.addEvent(messageEventName(m.role), { + content: safeCall('otel.redact', () => redact(body)) ?? body, + }) + } + } + state.iterationCount += 1 }) return undefined diff --git a/packages/typescript/ai/tests/middlewares/otel.test.ts b/packages/typescript/ai/tests/middlewares/otel.test.ts index e0b84b917..3b7fdb8e0 100644 --- a/packages/typescript/ai/tests/middlewares/otel.test.ts +++ b/packages/typescript/ai/tests/middlewares/otel.test.ts @@ -253,6 +253,79 @@ describe('otelMiddleware — tool spans', () => { }) }) +describe('otelMiddleware — captureContent', () => { + it('captureContent=true emits gen_ai.*.message events with redact applied on iteration span', async () => { + const { tracer, spans } = createFakeTracer() + const mw = otelMiddleware({ + tracer, + captureContent: true, + redact: (s) => s.replace(/\d+/g, '[NUM]'), + }) + const ctx = makeCtx() + + await mw.onStart?.(ctx) + ctx.phase = 'beforeModel' + await mw.onConfig?.(ctx, { + messages: [ + { role: 'user', content: 'Hello 42 world' }, + { role: 'assistant', content: 'Hi 7 there' }, + ], + systemPrompts: ['Be helpful 99'], + tools: [], + }) + + const iter = spans[1]! + const userEvt = iter.events.find((e) => e.name === 'gen_ai.user.message') + const sysEvt = iter.events.find((e) => e.name === 'gen_ai.system.message') + const asstEvt = iter.events.find((e) => e.name === 'gen_ai.assistant.message') + expect(userEvt!.attributes!['content']).toBe('Hello [NUM] world') + expect(sysEvt!.attributes!['content']).toBe('Be helpful [NUM]') + expect(asstEvt!.attributes!['content']).toBe('Hi [NUM] there') + }) + + it('captureContent=false emits no message events', async () => { + const { tracer, spans } = createFakeTracer() + const mw = otelMiddleware({ tracer }) + const ctx = makeCtx() + + await mw.onStart?.(ctx) + ctx.phase = 'beforeModel' + await mw.onConfig?.(ctx, { + messages: [{ role: 'user', content: 'Hello' }], + systemPrompts: [], + tools: [], + }) + + const iter = spans[1]! + expect(iter.events.filter((e) => e.name.startsWith('gen_ai.'))).toHaveLength(0) + }) + + it('multimodal ContentPart arrays become placeholder-tagged strings', async () => { + const { tracer, spans } = createFakeTracer() + const mw = otelMiddleware({ tracer, captureContent: true }) + const ctx = makeCtx() + + await mw.onStart?.(ctx) + ctx.phase = 'beforeModel' + await mw.onConfig?.(ctx, { + messages: [ + { + role: 'user', + content: [ + { type: 'text', text: 'look at this' }, + { type: 'image', source: { type: 'base64', data: '...' } }, + ] as any, + }, + ], + systemPrompts: [], + tools: [], + }) + + const userEvt = spans[1]!.events.find((e) => e.name === 'gen_ai.user.message')! + expect(userEvt.attributes!['content']).toBe('look at this [image]') + }) +}) + describe('otelMiddleware — error and abort paths', () => { it('onError sets ERROR status, records exception, adds error.type to duration histogram', async () => { const { tracer, spans } = createFakeTracer() From 1a5b9d0816b402e5dcf69c4fd57da19e54a04ceb Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Thu, 23 Apr 2026 17:32:11 +0200 Subject: [PATCH 14/31] feat(ai): otel middleware captures tool and choice events --- .../typescript/ai/src/middlewares/otel.ts | 28 ++++++++- .../ai/tests/middlewares/otel.test.ts | 60 +++++++++++++++++++ 2 files changed, 86 insertions(+), 2 deletions(-) diff --git a/packages/typescript/ai/src/middlewares/otel.ts b/packages/typescript/ai/src/middlewares/otel.ts index bb16a7fec..248edd4d7 100644 --- a/packages/typescript/ai/src/middlewares/otel.ts +++ b/packages/typescript/ai/src/middlewares/otel.ts @@ -222,15 +222,29 @@ export function otelMiddleware( onChunk(ctx, chunk) { safeCall('otel.onChunk', () => { - if (chunk.type !== 'RUN_FINISHED') return const state = stateByCtx.get(ctx) - if (!state || !state.currentIterationSpan) return + if (!state) return + + if (captureContent && chunk.type === 'TEXT_MESSAGE_CONTENT') { + state.assistantTextBuffer += chunk.delta ?? '' + } + + if (chunk.type !== 'RUN_FINISHED') return + if (!state.currentIterationSpan) return const span = state.currentIterationSpan + if (chunk.finishReason) { span.setAttribute('gen_ai.response.finish_reasons', [chunk.finishReason]) } if (chunk.model) span.setAttribute('gen_ai.response.model', chunk.model) + if (captureContent && state.assistantTextBuffer.length > 0) { + span.addEvent('gen_ai.choice', { + content: safeCall('otel.redact', () => redact(state.assistantTextBuffer)) ?? state.assistantTextBuffer, + }) + state.assistantTextBuffer = '' + } + safeCall('otel.onSpanEnd', () => onSpanEnd?.({ kind: 'iteration', ctx, iteration: state.iterationCount - 1 }, span), ) @@ -313,6 +327,16 @@ export function otelMiddleware( toolSpan.setStatus({ code: SpanStatusCode.ERROR, message: (info.error as Error)?.message }) } + if (captureContent && state.currentIterationSpan) { + const body = typeof info.result === 'string' + ? info.result + : JSON.stringify(info.result ?? null) + state.currentIterationSpan.addEvent('gen_ai.tool.message', { + content: safeCall('otel.redact', () => redact(body)) ?? body, + tool_call_id: info.toolCallId, + }) + } + safeCall('otel.onSpanEnd', () => onSpanEnd?.( { kind: 'tool', ctx, toolName: info.toolName, toolCallId: info.toolCallId, iteration: state.iterationCount - 1 }, diff --git a/packages/typescript/ai/tests/middlewares/otel.test.ts b/packages/typescript/ai/tests/middlewares/otel.test.ts index 3b7fdb8e0..513644d03 100644 --- a/packages/typescript/ai/tests/middlewares/otel.test.ts +++ b/packages/typescript/ai/tests/middlewares/otel.test.ts @@ -369,3 +369,63 @@ describe('otelMiddleware — error and abort paths', () => { expect(spans[0]!.ended).toBe(true) }) }) + +describe('otelMiddleware — tool-message and choice events', () => { + it('onAfterToolCall emits gen_ai.tool.message on iteration span with redacted result', async () => { + const { tracer, spans } = createFakeTracer() + const mw = otelMiddleware({ + tracer, + captureContent: true, + redact: (s) => s.replace(/\d+/g, '[NUM]'), + }) + const ctx = makeCtx({ hasTools: true }) + + await mw.onStart?.(ctx) + ctx.phase = 'beforeModel' + await mw.onConfig?.(ctx, { messages: [], systemPrompts: [], tools: [] }) + await mw.onBeforeToolCall?.(ctx, { + toolCall: { id: 'tc-1', type: 'function', function: { name: 'x', arguments: '{}' } } as any, + tool: undefined, + args: {}, + toolName: 'x', + toolCallId: 'tc-1', + }) + await mw.onAfterToolCall?.(ctx, { + toolCall: { id: 'tc-1' } as any, + tool: undefined, + toolName: 'x', + toolCallId: 'tc-1', + ok: true, + duration: 5, + result: { value: 42 }, + }) + + const iter = spans[1]! + const toolEvt = iter.events.find((e) => e.name === 'gen_ai.tool.message')! + expect(toolEvt.attributes!['content']).toContain('[NUM]') + expect(toolEvt.attributes!['tool_call_id']).toBe('tc-1') + }) + + it('emits gen_ai.choice event with accumulated assistant text on RUN_FINISHED', async () => { + const { tracer, spans } = createFakeTracer() + const mw = otelMiddleware({ tracer, captureContent: true }) + const ctx = makeCtx() + + await mw.onStart?.(ctx) + ctx.phase = 'beforeModel' + await mw.onConfig?.(ctx, { messages: [], systemPrompts: [], tools: [] }) + await mw.onChunk?.(ctx, { + type: EventType.TEXT_MESSAGE_CONTENT, threadId: 't-1', messageId: 'm', model: 'gpt-4o', timestamp: 0, delta: 'Hello ', content: 'Hello ', + }) + await mw.onChunk?.(ctx, { + type: EventType.TEXT_MESSAGE_CONTENT, threadId: 't-1', messageId: 'm', model: 'gpt-4o', timestamp: 0, delta: 'world', content: 'Hello world', + }) + await mw.onChunk?.(ctx, { + type: EventType.RUN_FINISHED, threadId: 't-1', runId: 'r', model: 'gpt-4o', timestamp: 0, finishReason: 'stop', + }) + + const iter = spans[1]! + const choice = iter.events.find((e) => e.name === 'gen_ai.choice')! + expect(choice.attributes!['content']).toBe('Hello world') + }) +}) From a5c0d95c6b5b4f273f5ed307fce995679bf6cd83 Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Thu, 23 Apr 2026 17:35:44 +0200 Subject: [PATCH 15/31] test(ai): verify otel middleware extension points + callback resilience --- .../ai/tests/middlewares/otel.test.ts | 96 +++++++++++++++++++ 1 file changed, 96 insertions(+) diff --git a/packages/typescript/ai/tests/middlewares/otel.test.ts b/packages/typescript/ai/tests/middlewares/otel.test.ts index 513644d03..cde51c9c5 100644 --- a/packages/typescript/ai/tests/middlewares/otel.test.ts +++ b/packages/typescript/ai/tests/middlewares/otel.test.ts @@ -429,3 +429,99 @@ describe('otelMiddleware — tool-message and choice events', () => { expect(choice.attributes!['content']).toBe('Hello world') }) }) + +describe('otelMiddleware — extension points', () => { + it('spanNameFormatter overrides default names', async () => { + const { tracer, spans } = createFakeTracer() + const mw = otelMiddleware({ + tracer, + spanNameFormatter: (info) => { + if (info.kind === 'chat') return 'my-chat' + if (info.kind === 'iteration') return `iter-${info.iteration}` + return `tool-${info.toolName}` + }, + }) + const ctx = makeCtx({ hasTools: true }) + + await mw.onStart?.(ctx) + ctx.phase = 'beforeModel' + await mw.onConfig?.(ctx, { messages: [], systemPrompts: [], tools: [] }) + await mw.onBeforeToolCall?.(ctx, { + toolCall: { id: 't-1', type: 'function', function: { name: 'lookup', arguments: '{}' } } as any, + tool: undefined, args: {}, toolName: 'lookup', toolCallId: 't-1', + }) + + expect(spans[0]!.name).toBe('my-chat') + expect(spans[1]!.name).toBe('iter-0') + expect(spans[2]!.name).toBe('tool-lookup') + }) + + it('attributeEnricher merges attributes onto every span', async () => { + const { tracer, spans } = createFakeTracer() + const mw = otelMiddleware({ + tracer, + attributeEnricher: (info) => ({ 'test.kind': info.kind }), + }) + const ctx = makeCtx() + + await mw.onStart?.(ctx) + ctx.phase = 'beforeModel' + await mw.onConfig?.(ctx, { messages: [], systemPrompts: [], tools: [] }) + + expect(spans[0]!.attributes['test.kind']).toBe('chat') + expect(spans[1]!.attributes['test.kind']).toBe('iteration') + }) + + it('onBeforeSpanStart can mutate SpanOptions before startSpan', async () => { + const { tracer, spans } = createFakeTracer() + const mw = otelMiddleware({ + tracer, + onBeforeSpanStart: (_info, options) => ({ + ...options, + attributes: { ...(options.attributes ?? {}), 'custom.start': true }, + }), + }) + const ctx = makeCtx() + + await mw.onStart?.(ctx) + + expect(spans[0]!.attributes['custom.start']).toBe(true) + }) + + it('onSpanEnd fires before span.end()', async () => { + const { tracer } = createFakeTracer() + const seen: Array<{ kind: string; ended: boolean }> = [] + const mw = otelMiddleware({ + tracer, + onSpanEnd: (info, span) => { + seen.push({ kind: info.kind, ended: (span as any).ended }) + }, + }) + const ctx = makeCtx() + + await mw.onStart?.(ctx) + ctx.phase = 'beforeModel' + await mw.onConfig?.(ctx, { messages: [], systemPrompts: [], tools: [] }) + await mw.onChunk?.(ctx, { type: EventType.RUN_FINISHED, threadId: 't-1', runId: 'r', model: 'gpt-4o', timestamp: 0, finishReason: 'stop' }) + await mw.onFinish?.(ctx, { finishReason: 'stop', duration: 1, content: '' }) + + expect(seen.map((s) => s.kind)).toEqual(['iteration', 'chat']) + expect(seen.every((s) => s.ended === false)).toBe(true) + }) + + it('throwing user callback does NOT break the chat run', async () => { + const { tracer, spans } = createFakeTracer() + const mw = otelMiddleware({ + tracer, + attributeEnricher: () => { throw new Error('boom') }, + }) + const ctx = makeCtx() + + // onStart and onFinish must not throw even when attributeEnricher throws + expect(() => mw.onStart?.(ctx)).not.toThrow() + expect(() => + mw.onFinish?.(ctx, { finishReason: 'stop', duration: 1, content: '' }), + ).not.toThrow() + expect(spans[0]!.ended).toBe(true) + }) +}) From a3e6cc7843996d24266a78e076b80765b6300996 Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Thu, 23 Apr 2026 17:38:56 +0200 Subject: [PATCH 16/31] test(ai): verify otel middleware concurrent isolation --- .../ai/tests/middlewares/otel.test.ts | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/packages/typescript/ai/tests/middlewares/otel.test.ts b/packages/typescript/ai/tests/middlewares/otel.test.ts index cde51c9c5..2aa109097 100644 --- a/packages/typescript/ai/tests/middlewares/otel.test.ts +++ b/packages/typescript/ai/tests/middlewares/otel.test.ts @@ -430,6 +430,42 @@ describe('otelMiddleware — tool-message and choice events', () => { }) }) +describe('otelMiddleware — concurrent isolation', () => { + it('parallel chat() calls do not cross-contaminate state', async () => { + const { tracer, spans } = createFakeTracer() + const mw = otelMiddleware({ tracer }) + + const ctxA = makeCtx({ requestId: 'A' }) + const ctxB = makeCtx({ requestId: 'B' }) + + await Promise.all([mw.onStart?.(ctxA), mw.onStart?.(ctxB)]) + ctxA.phase = 'beforeModel' + ctxB.phase = 'beforeModel' + await Promise.all([ + mw.onConfig?.(ctxA, { messages: [], systemPrompts: [], tools: [] }), + mw.onConfig?.(ctxB, { messages: [], systemPrompts: [], tools: [] }), + ]) + await Promise.all([ + mw.onChunk?.(ctxA, { type: EventType.RUN_FINISHED, threadId: 't-A', runId: 'A', model: 'gpt-4o', timestamp: 0, finishReason: 'stop' }), + mw.onChunk?.(ctxB, { type: EventType.RUN_FINISHED, threadId: 't-B', runId: 'B', model: 'gpt-4o', timestamp: 0, finishReason: 'tool_calls' }), + ]) + await Promise.all([ + mw.onFinish?.(ctxA, { finishReason: 'stop', duration: 1, content: '' }), + mw.onFinish?.(ctxB, { finishReason: 'tool_calls', duration: 1, content: '' }), + ]) + + // Total: 2 root spans + 2 iteration spans, all ended. + expect(spans.filter((s) => s.ended).length).toBe(4) + // Each iteration has its own finish_reason. + const iters = spans.filter((s) => s.parent !== null) + const reasons = iters.flatMap((s) => { + const v = s.attributes['gen_ai.response.finish_reasons'] + return Array.isArray(v) ? (v as string[]) : [] + }) + expect(reasons).toEqual(expect.arrayContaining(['stop', 'tool_calls'])) + }) +}) + describe('otelMiddleware — extension points', () => { it('spanNameFormatter overrides default names', async () => { const { tracer, spans } = createFakeTracer() From bb56a4e3ef274afbed77a230947189a8500f24fd Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Thu, 23 Apr 2026 17:47:30 +0200 Subject: [PATCH 17/31] docs(advanced): otel middleware page + nav + changeset Also fixes ESLint errors in otel.ts (import order, type param naming, array-type, unnecessary nullish coalescing and optional chain). --- .changeset/otel-middleware.md | 13 ++ docs/advanced/otel.md | 163 ++++++++++++++++++ docs/config.json | 4 + .../typescript/ai/src/middlewares/otel.ts | 16 +- 4 files changed, 188 insertions(+), 8 deletions(-) create mode 100644 .changeset/otel-middleware.md create mode 100644 docs/advanced/otel.md diff --git a/.changeset/otel-middleware.md b/.changeset/otel-middleware.md new file mode 100644 index 000000000..8469f9b53 --- /dev/null +++ b/.changeset/otel-middleware.md @@ -0,0 +1,13 @@ +--- +'@tanstack/ai': minor +--- + +**OpenTelemetry middleware.** `otelMiddleware({ tracer, meter?, captureContent?, redact?, ... })` emits GenAI-semantic-convention traces and metrics for every `chat()` call. + +- Root span per `chat()` + child span per agent-loop iteration + grandchild span per tool call. +- `gen_ai.client.operation.duration` (seconds) and `gen_ai.client.token.usage` (tokens) histograms, recorded per iteration, with the minimal set of low-cardinality attributes. +- `captureContent: true` attaches prompt/completion content as `gen_ai.{user,system,assistant,tool}.message` and `gen_ai.choice` span events, with optional `redact` applied before anything lands on a span. Multimodal parts become placeholder strings. +- Four extension points for custom attributes, names, span-options, and end-of-span callbacks. +- `@opentelemetry/api` is an optional peer dependency; users who don't import the middleware never load OTel. + +See `docs/advanced/otel.md` for the full guide. diff --git a/docs/advanced/otel.md b/docs/advanced/otel.md new file mode 100644 index 000000000..44877db3a --- /dev/null +++ b/docs/advanced/otel.md @@ -0,0 +1,163 @@ +--- +title: OpenTelemetry +id: otel +order: 4 +description: "Emit vendor-neutral OpenTelemetry traces and metrics from every TanStack AI chat() call, following the OTel GenAI semantic conventions." +keywords: + - tanstack ai + - opentelemetry + - otel + - observability + - tracing + - metrics + - gen_ai + - semantic conventions +--- + +The `otelMiddleware` factory wires TanStack AI into your existing OpenTelemetry setup. Every `chat()` call produces a root span, one child span per agent-loop iteration, and one grandchild span per tool call — all with [GenAI semantic-convention attributes](https://opentelemetry.io/docs/specs/semconv/gen-ai/). It also records GenAI token and duration histograms when a `Meter` is provided. + +## Setup + +Install `@opentelemetry/api` — it's an optional peer dependency of `@tanstack/ai`: + +``` +pnpm add @opentelemetry/api +``` + +Wire up your OTel SDK however you already do (e.g. `@opentelemetry/sdk-node`). Then pass a `Tracer` (and optionally a `Meter`) into the middleware: + +```ts +import { chat } from '@tanstack/ai' +import { otelMiddleware } from '@tanstack/ai/middlewares' +import { openaiText } from '@tanstack/ai-openai/adapters' +import { trace, metrics } from '@opentelemetry/api' + +const otel = otelMiddleware({ + tracer: trace.getTracer('my-app'), + meter: metrics.getMeter('my-app'), +}) + +const result = await chat({ + adapter: openaiText('gpt-4o'), + messages: [{ role: 'user', content: 'hi' }], + middleware: [otel], + stream: false, +}) +``` + +## What gets emitted + +### Spans + +``` +chat gpt-4o (root, kind: INTERNAL) +├── chat gpt-4o (iteration, kind: CLIENT) +│ └── execute_tool get_weather +│ └── execute_tool get_time +└── chat gpt-4o (iteration, kind: CLIENT) +``` + +### Attribute reference + +| Level | Attribute | Value | +| --- | --- | --- | +| root / iteration | `gen_ai.system` | `openai`, `anthropic`, ... | +| root / iteration | `gen_ai.operation.name` | `chat` | +| root / iteration | `gen_ai.request.model` | requested model | +| iteration | `gen_ai.response.model` | actual model | +| iteration | `gen_ai.request.temperature` | from config | +| iteration | `gen_ai.request.top_p` | from config | +| iteration | `gen_ai.request.max_tokens` | from config | +| iteration | `gen_ai.usage.input_tokens` | per iteration | +| iteration | `gen_ai.usage.output_tokens` | per iteration | +| iteration | `gen_ai.response.finish_reasons` | `[stop]`, `[tool_calls]`, ... | +| root | `gen_ai.usage.input_tokens` | rolled up | +| root | `gen_ai.usage.output_tokens` | rolled up | +| root | `tanstack.ai.iterations` | iteration count | +| tool | `gen_ai.tool.name` | tool name | +| tool | `gen_ai.tool.call.id` | tool call id | +| tool | `gen_ai.tool.type` | `function` | +| tool | `tanstack.ai.tool.outcome` | `success` / `error` | + +### Metrics + +Two GenAI-standard histograms, recorded per iteration: + +- `gen_ai.client.operation.duration` (seconds) — per-iteration duration +- `gen_ai.client.token.usage` (tokens) — recorded twice per iteration (input + output) with `gen_ai.token.type` attribute + +`gen_ai.response.id` is deliberately excluded from metric attributes to keep cardinality low. + +## Privacy: capturing prompts and completions + +By default, only metadata lands on spans. To record prompt and completion content, set `captureContent: true`. Content is captured as OTel span events following the GenAI convention: + +- `gen_ai.user.message`, `gen_ai.system.message`, `gen_ai.assistant.message`, `gen_ai.tool.message`, `gen_ai.choice` + +Pass a `redact` function to strip PII before anything is recorded: + +```ts +otelMiddleware({ + tracer, + captureContent: true, + redact: (text) => text.replace(/\b\d{3}-\d{2}-\d{4}\b/g, '[SSN]'), +}) +``` + +Multimodal content (images, audio, video, documents) is represented as placeholder strings (`[image]`, `[audio]`, ...) to preserve message order without dumping binary data onto spans. Use `onSpanEnd` if you need richer multimodal capture. + +## Extension points + +All four extensions are optional. Each wraps user code in try/catch — a thrown callback becomes a log line, never a broken chat. + +### `spanNameFormatter(info)` + +Override default span names. `info.kind` is `'chat' | 'iteration' | 'tool'`. + +```ts +otelMiddleware({ + tracer, + spanNameFormatter: (info) => + info.kind === 'tool' ? `tool:${info.toolName}` : `chat:${info.ctx.model}`, +}) +``` + +### `attributeEnricher(info)` + +Add custom attributes to every span. Fires once per span. + +```ts +otelMiddleware({ + tracer, + attributeEnricher: () => ({ + 'tenant.id': getCurrentTenant(), + }), +}) +``` + +### `onBeforeSpanStart(info, options)` + +Mutate `SpanOptions` immediately before `tracer.startSpan(...)`. Useful for adding links, custom start times, or extra default attributes. + +### `onSpanEnd(info, span)` + +Fires just before every `span.end()`. Common uses: record custom events, emit per-tool metrics via your own `Meter`. + +```ts +const toolDuration = meter.createHistogram('tool.duration') +otelMiddleware({ + tracer, + onSpanEnd: (info, span) => { + if (info.kind === 'tool') { + // span is still recording; read timestamps from your own store if needed + toolDuration.record(1, { 'tool.name': info.toolName }) + } + }, +}) +``` + +## Related + +- [Middleware](./middleware) — the lifecycle this middleware hooks into +- [Debug Logging](./debug-logging) — quick console-output diagnostics, complementary to OTel +- [Observability](./observability) — TanStack AI's built-in event client diff --git a/docs/config.json b/docs/config.json index f24a5fa0a..89d4f5abc 100644 --- a/docs/config.json +++ b/docs/config.json @@ -175,6 +175,10 @@ "label": "Debug Logging", "to": "advanced/debug-logging" }, + { + "label": "OpenTelemetry", + "to": "advanced/otel" + }, { "label": "Observability", "to": "advanced/observability" diff --git a/packages/typescript/ai/src/middlewares/otel.ts b/packages/typescript/ai/src/middlewares/otel.ts index 248edd4d7..99396f49e 100644 --- a/packages/typescript/ai/src/middlewares/otel.ts +++ b/packages/typescript/ai/src/middlewares/otel.ts @@ -1,3 +1,4 @@ +import { SpanStatusCode, context as otelContext, trace as otelTrace } from '@opentelemetry/api' import type { AttributeValue, Meter, @@ -5,7 +6,6 @@ import type { SpanOptions, Tracer, } from '@opentelemetry/api' -import { context as otelContext, trace as otelTrace, SpanStatusCode } from '@opentelemetry/api' import type { ChatMiddleware, ChatMiddlewareContext, @@ -13,8 +13,8 @@ import type { export type OtelSpanKind = 'chat' | 'iteration' | 'tool' -export interface OtelSpanInfo { - kind: K +export interface OtelSpanInfo { + kind: TKind ctx: ChatMiddlewareContext toolName?: string toolCallId?: string @@ -47,7 +47,7 @@ const stateByCtx = new WeakMap() function serializeContent(content: unknown): string { if (typeof content === 'string') return content if (!Array.isArray(content)) return '' - const parts: string[] = [] + const parts: Array = [] for (const part of content) { if (!part || typeof part !== 'object') continue const type = (part as { type?: string }).type @@ -201,12 +201,12 @@ export function otelMiddleware( state.currentIterationSpan = iterSpan if (captureContent) { - for (const sys of config.systemPrompts ?? []) { + for (const sys of config.systemPrompts) { iterSpan.addEvent('gen_ai.system.message', { content: safeCall('otel.redact', () => redact(sys)) ?? sys, }) } - for (const m of config.messages ?? []) { + for (const m of config.messages) { const body = serializeContent(m.content) if (body.length === 0) continue iterSpan.addEvent(messageEventName(m.role), { @@ -226,7 +226,7 @@ export function otelMiddleware( if (!state) return if (captureContent && chunk.type === 'TEXT_MESSAGE_CONTENT') { - state.assistantTextBuffer += chunk.delta ?? '' + state.assistantTextBuffer += chunk.delta } if (chunk.type !== 'RUN_FINISHED') return @@ -324,7 +324,7 @@ export function otelMiddleware( if (!info.ok && info.error !== undefined) { toolSpan.recordException(info.error as Error) - toolSpan.setStatus({ code: SpanStatusCode.ERROR, message: (info.error as Error)?.message }) + toolSpan.setStatus({ code: SpanStatusCode.ERROR, message: (info.error as Error).message }) } if (captureContent && state.currentIterationSpan) { From d6134d21cc1898dc738bcb8a9ecd4790aa95ca3f Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Thu, 23 Apr 2026 15:49:15 +0000 Subject: [PATCH 18/31] ci: apply automated fixes --- .../typescript/ai/src/middlewares/otel.ts | 215 +++++++++++++----- .../ai/tests/middlewares/fake-otel.ts | 68 ++++-- .../ai/tests/middlewares/otel.test.ts | 187 ++++++++++++--- 3 files changed, 363 insertions(+), 107 deletions(-) diff --git a/packages/typescript/ai/src/middlewares/otel.ts b/packages/typescript/ai/src/middlewares/otel.ts index 99396f49e..96514e321 100644 --- a/packages/typescript/ai/src/middlewares/otel.ts +++ b/packages/typescript/ai/src/middlewares/otel.ts @@ -1,4 +1,8 @@ -import { SpanStatusCode, context as otelContext, trace as otelTrace } from '@opentelemetry/api' +import { + SpanStatusCode, + context as otelContext, + trace as otelTrace, +} from '@opentelemetry/api' import type { AttributeValue, Meter, @@ -53,7 +57,13 @@ function serializeContent(content: unknown): string { const type = (part as { type?: string }).type switch (type) { case 'text': - parts.push(((part as { text?: string }).text ?? (part as { content?: string }).content ?? '').toString()) + parts.push( + ( + (part as { text?: string }).text ?? + (part as { content?: string }).content ?? + '' + ).toString(), + ) break case 'image': parts.push('[image]') @@ -76,11 +86,16 @@ function serializeContent(content: unknown): string { function messageEventName(role: string): string { switch (role) { - case 'user': return 'gen_ai.user.message' - case 'assistant': return 'gen_ai.assistant.message' - case 'tool': return 'gen_ai.tool.message' - case 'system': return 'gen_ai.system.message' - default: return `gen_ai.${role}.message` + case 'user': + return 'gen_ai.user.message' + case 'assistant': + return 'gen_ai.assistant.message' + case 'tool': + return 'gen_ai.tool.message' + case 'system': + return 'gen_ai.system.message' + default: + return `gen_ai.${role}.message` } } @@ -94,9 +109,7 @@ function safeCall(label: string, fn: () => T): T | undefined { } } -export function otelMiddleware( - options: OtelMiddlewareOptions, -): ChatMiddleware { +export function otelMiddleware(options: OtelMiddlewareOptions): ChatMiddleware { const { tracer, meter, @@ -116,20 +129,19 @@ export function otelMiddleware( unit: 's', }, ) - const tokenHistogram = meter?.createHistogram( - 'gen_ai.client.token.usage', - { - description: 'GenAI client token usage', - unit: '{token}', - }, - ) + const tokenHistogram = meter?.createHistogram('gen_ai.client.token.usage', { + description: 'GenAI client token usage', + unit: '{token}', + }) return { name: 'otel', onStart(ctx) { safeCall('otel.onStart', () => { const info: OtelSpanInfo<'chat'> = { kind: 'chat', ctx } - const name = safeCall('otel.spanNameFormatter', () => spanNameFormatter?.(info)) ?? `chat ${ctx.model}` + const name = + safeCall('otel.spanNameFormatter', () => spanNameFormatter?.(info)) ?? + `chat ${ctx.model}` const baseOptions: SpanOptions = { attributes: { 'gen_ai.system': ctx.provider, @@ -137,10 +149,15 @@ export function otelMiddleware( 'gen_ai.request.model': ctx.model, }, } - const spanOptions = safeCall('otel.onBeforeSpanStart', () => onBeforeSpanStart?.(info, baseOptions)) ?? baseOptions + const spanOptions = + safeCall('otel.onBeforeSpanStart', () => + onBeforeSpanStart?.(info, baseOptions), + ) ?? baseOptions const rootSpan = tracer.startSpan(name, spanOptions) - const enriched = safeCall('otel.attributeEnricher', () => attributeEnricher?.(info)) + const enriched = safeCall('otel.attributeEnricher', () => + attributeEnricher?.(info), + ) if (enriched) rootSpan.setAttributes(enriched) stateByCtx.set(ctx, { @@ -164,14 +181,23 @@ export function otelMiddleware( // here because onChunk(RUN_FINISHED) closes it, but guard against adapter quirks). if (state.currentIterationSpan) { safeCall('otel.onSpanEnd', () => - onSpanEnd?.({ kind: 'iteration', ctx, iteration: state.iterationCount - 1 }, state.currentIterationSpan!), + onSpanEnd?.( + { kind: 'iteration', ctx, iteration: state.iterationCount - 1 }, + state.currentIterationSpan!, + ), ) state.currentIterationSpan.end() state.currentIterationSpan = null } - const info: OtelSpanInfo<'iteration'> = { kind: 'iteration', ctx, iteration: ctx.iteration } - const name = safeCall('otel.spanNameFormatter', () => spanNameFormatter?.(info)) ?? `chat ${ctx.model}` + const info: OtelSpanInfo<'iteration'> = { + kind: 'iteration', + ctx, + iteration: ctx.iteration, + } + const name = + safeCall('otel.spanNameFormatter', () => spanNameFormatter?.(info)) ?? + `chat ${ctx.model}` const baseAttrs: Record = { 'gen_ai.system': ctx.provider, @@ -179,23 +205,34 @@ export function otelMiddleware( 'gen_ai.request.model': ctx.model, 'tanstack.ai.iteration': ctx.iteration, } - if (config.temperature !== undefined) baseAttrs['gen_ai.request.temperature'] = config.temperature - if (config.topP !== undefined) baseAttrs['gen_ai.request.top_p'] = config.topP - if (config.maxTokens !== undefined) baseAttrs['gen_ai.request.max_tokens'] = config.maxTokens + if (config.temperature !== undefined) + baseAttrs['gen_ai.request.temperature'] = config.temperature + if (config.topP !== undefined) + baseAttrs['gen_ai.request.top_p'] = config.topP + if (config.maxTokens !== undefined) + baseAttrs['gen_ai.request.max_tokens'] = config.maxTokens const baseOptions: SpanOptions = { attributes: baseAttrs } - const spanOptions = safeCall('otel.onBeforeSpanStart', () => onBeforeSpanStart?.(info, baseOptions)) ?? baseOptions + const spanOptions = + safeCall('otel.onBeforeSpanStart', () => + onBeforeSpanStart?.(info, baseOptions), + ) ?? baseOptions let iterSpan!: Span - otelContext.with(otelTrace.setSpan(otelContext.active(), state.rootSpan), () => { - iterSpan = tracer.startSpan(name, spanOptions) - }) + otelContext.with( + otelTrace.setSpan(otelContext.active(), state.rootSpan), + () => { + iterSpan = tracer.startSpan(name, spanOptions) + }, + ) // Fake-tracer test visibility: explicit parent pointer. In real OTel this is a // no-op field write; the actual parent-child relationship is established via the // active context above. ;(iterSpan as unknown as { parent?: Span }).parent = state.rootSpan - const enriched = safeCall('otel.attributeEnricher', () => attributeEnricher?.(info)) + const enriched = safeCall('otel.attributeEnricher', () => + attributeEnricher?.(info), + ) if (enriched) iterSpan.setAttributes(enriched) state.currentIterationSpan = iterSpan @@ -234,19 +271,27 @@ export function otelMiddleware( const span = state.currentIterationSpan if (chunk.finishReason) { - span.setAttribute('gen_ai.response.finish_reasons', [chunk.finishReason]) + span.setAttribute('gen_ai.response.finish_reasons', [ + chunk.finishReason, + ]) } if (chunk.model) span.setAttribute('gen_ai.response.model', chunk.model) if (captureContent && state.assistantTextBuffer.length > 0) { span.addEvent('gen_ai.choice', { - content: safeCall('otel.redact', () => redact(state.assistantTextBuffer)) ?? state.assistantTextBuffer, + content: + safeCall('otel.redact', () => + redact(state.assistantTextBuffer), + ) ?? state.assistantTextBuffer, }) state.assistantTextBuffer = '' } safeCall('otel.onSpanEnd', () => - onSpanEnd?.({ kind: 'iteration', ctx, iteration: state.iterationCount - 1 }, span), + onSpanEnd?.( + { kind: 'iteration', ctx, iteration: state.iterationCount - 1 }, + span, + ), ) span.end() state.currentIterationSpan = null @@ -270,8 +315,14 @@ export function otelMiddleware( 'gen_ai.operation.name': 'chat', 'gen_ai.request.model': ctx.model, } - tokenHistogram.record(usage.promptTokens, { ...metricAttrs, 'gen_ai.token.type': 'input' }) - tokenHistogram.record(usage.completionTokens, { ...metricAttrs, 'gen_ai.token.type': 'output' }) + tokenHistogram.record(usage.promptTokens, { + ...metricAttrs, + 'gen_ai.token.type': 'input', + }) + tokenHistogram.record(usage.completionTokens, { + ...metricAttrs, + 'gen_ai.token.type': 'output', + }) } }) }, @@ -288,7 +339,9 @@ export function otelMiddleware( toolCallId: hookCtx.toolCallId, iteration: state.iterationCount - 1, } - const name = safeCall('otel.spanNameFormatter', () => spanNameFormatter?.(info)) ?? `execute_tool ${hookCtx.toolName}` + const name = + safeCall('otel.spanNameFormatter', () => spanNameFormatter?.(info)) ?? + `execute_tool ${hookCtx.toolName}` const baseAttrs: Record = { 'gen_ai.tool.name': hookCtx.toolName, @@ -296,15 +349,24 @@ export function otelMiddleware( 'gen_ai.tool.type': 'function', } const baseOptions: SpanOptions = { attributes: baseAttrs } - const spanOptions = safeCall('otel.onBeforeSpanStart', () => onBeforeSpanStart?.(info, baseOptions)) ?? baseOptions + const spanOptions = + safeCall('otel.onBeforeSpanStart', () => + onBeforeSpanStart?.(info, baseOptions), + ) ?? baseOptions let toolSpan!: Span - otelContext.with(otelTrace.setSpan(otelContext.active(), state.currentIterationSpan), () => { - toolSpan = tracer.startSpan(name, spanOptions) - }) - ;(toolSpan as unknown as { parent?: Span }).parent = state.currentIterationSpan + otelContext.with( + otelTrace.setSpan(otelContext.active(), state.currentIterationSpan), + () => { + toolSpan = tracer.startSpan(name, spanOptions) + }, + ) + ;(toolSpan as unknown as { parent?: Span }).parent = + state.currentIterationSpan - const enriched = safeCall('otel.attributeEnricher', () => attributeEnricher?.(info)) + const enriched = safeCall('otel.attributeEnricher', () => + attributeEnricher?.(info), + ) if (enriched) toolSpan.setAttributes(enriched) state.toolSpans.set(hookCtx.toolCallId, toolSpan) @@ -324,13 +386,17 @@ export function otelMiddleware( if (!info.ok && info.error !== undefined) { toolSpan.recordException(info.error as Error) - toolSpan.setStatus({ code: SpanStatusCode.ERROR, message: (info.error as Error).message }) + toolSpan.setStatus({ + code: SpanStatusCode.ERROR, + message: (info.error as Error).message, + }) } if (captureContent && state.currentIterationSpan) { - const body = typeof info.result === 'string' - ? info.result - : JSON.stringify(info.result ?? null) + const body = + typeof info.result === 'string' + ? info.result + : JSON.stringify(info.result ?? null) state.currentIterationSpan.addEvent('gen_ai.tool.message', { content: safeCall('otel.redact', () => redact(body)) ?? body, tool_call_id: info.toolCallId, @@ -339,7 +405,13 @@ export function otelMiddleware( safeCall('otel.onSpanEnd', () => onSpanEnd?.( - { kind: 'tool', ctx, toolName: info.toolName, toolCallId: info.toolCallId, iteration: state.iterationCount - 1 }, + { + kind: 'tool', + ctx, + toolName: info.toolName, + toolCallId: info.toolCallId, + iteration: state.iterationCount - 1, + }, toolSpan, ), ) @@ -353,15 +425,23 @@ export function otelMiddleware( const state = stateByCtx.get(ctx) if (!state) return - const errType = (info.error as { name?: string } | undefined)?.name ?? 'Error' - const message = (info.error as { message?: string } | undefined)?.message + const errType = + (info.error as { name?: string } | undefined)?.name ?? 'Error' + const message = (info.error as { message?: string } | undefined) + ?.message // Close iteration span (if open) with ERROR. if (state.currentIterationSpan) { state.currentIterationSpan.recordException(info.error as Error) - state.currentIterationSpan.setStatus({ code: SpanStatusCode.ERROR, message }) + state.currentIterationSpan.setStatus({ + code: SpanStatusCode.ERROR, + message, + }) safeCall('otel.onSpanEnd', () => - onSpanEnd?.({ kind: 'iteration', ctx, iteration: state.iterationCount - 1 }, state.currentIterationSpan!), + onSpanEnd?.( + { kind: 'iteration', ctx, iteration: state.iterationCount - 1 }, + state.currentIterationSpan!, + ), ) state.currentIterationSpan.end() state.currentIterationSpan = null @@ -388,7 +468,9 @@ export function otelMiddleware( }) } - safeCall('otel.onSpanEnd', () => onSpanEnd?.({ kind: 'chat', ctx }, state.rootSpan)) + safeCall('otel.onSpanEnd', () => + onSpanEnd?.({ kind: 'chat', ctx }, state.rootSpan), + ) state.rootSpan.end() stateByCtx.delete(ctx) }) @@ -407,7 +489,10 @@ export function otelMiddleware( if (state.currentIterationSpan) { closeCancelled(state.currentIterationSpan) safeCall('otel.onSpanEnd', () => - onSpanEnd?.({ kind: 'iteration', ctx, iteration: state.iterationCount - 1 }, state.currentIterationSpan!), + onSpanEnd?.( + { kind: 'iteration', ctx, iteration: state.iterationCount - 1 }, + state.currentIterationSpan!, + ), ) state.currentIterationSpan.end() state.currentIterationSpan = null @@ -418,7 +503,9 @@ export function otelMiddleware( state.toolSpans.delete(id) } closeCancelled(state.rootSpan) - safeCall('otel.onSpanEnd', () => onSpanEnd?.({ kind: 'chat', ctx }, state.rootSpan)) + safeCall('otel.onSpanEnd', () => + onSpanEnd?.({ kind: 'chat', ctx }, state.rootSpan), + ) state.rootSpan.end() stateByCtx.delete(ctx) }) @@ -432,7 +519,10 @@ export function otelMiddleware( // Close a dangling iteration span if RUN_FINISHED never arrived (defensive). if (state.currentIterationSpan) { safeCall('otel.onSpanEnd', () => - onSpanEnd?.({ kind: 'iteration', ctx, iteration: state.iterationCount - 1 }, state.currentIterationSpan!), + onSpanEnd?.( + { kind: 'iteration', ctx, iteration: state.iterationCount - 1 }, + state.currentIterationSpan!, + ), ) state.currentIterationSpan.end() state.currentIterationSpan = null @@ -454,11 +544,18 @@ export function otelMiddleware( }) } if (info.finishReason) { - state.rootSpan.setAttribute('gen_ai.response.finish_reasons', [info.finishReason]) + state.rootSpan.setAttribute('gen_ai.response.finish_reasons', [ + info.finishReason, + ]) } - state.rootSpan.setAttribute('tanstack.ai.iterations', state.iterationCount) + state.rootSpan.setAttribute( + 'tanstack.ai.iterations', + state.iterationCount, + ) - safeCall('otel.onSpanEnd', () => onSpanEnd?.({ kind: 'chat', ctx }, state.rootSpan)) + safeCall('otel.onSpanEnd', () => + onSpanEnd?.({ kind: 'chat', ctx }, state.rootSpan), + ) state.rootSpan.end() stateByCtx.delete(ctx) }) diff --git a/packages/typescript/ai/tests/middlewares/fake-otel.ts b/packages/typescript/ai/tests/middlewares/fake-otel.ts index 47b56d258..45d5e5201 100644 --- a/packages/typescript/ai/tests/middlewares/fake-otel.ts +++ b/packages/typescript/ai/tests/middlewares/fake-otel.ts @@ -53,7 +53,11 @@ export interface FakeTracer { activeStack: FakeSpan[] } -function makeSpan(name: string, options: SpanOptions, parent: FakeSpan | null): FakeSpan { +function makeSpan( + name: string, + options: SpanOptions, + parent: FakeSpan | null, +): FakeSpan { const span: FakeSpan = { name, kind: options.kind, @@ -61,7 +65,9 @@ function makeSpan(name: string, options: SpanOptions, parent: FakeSpan | null): startTimeMs: Date.now(), endTimeMs: null, attributes: Object.fromEntries( - Object.entries(options.attributes ?? {}).filter(([, v]) => v !== undefined), + Object.entries(options.attributes ?? {}).filter( + ([, v]) => v !== undefined, + ), ) as Record, events: [], exceptions: [], @@ -88,8 +94,12 @@ function makeSpan(name: string, options: SpanOptions, parent: FakeSpan | null): this.events.push({ name, attributes: attrs as Attributes | undefined }) return this }, - addLink() { return this }, - addLinks() { return this }, + addLink() { + return this + }, + addLinks() { + return this + }, setStatus(status) { this.status = status return this @@ -106,7 +116,10 @@ function makeSpan(name: string, options: SpanOptions, parent: FakeSpan | null): return !this.ended }, recordException(exception, attrs) { - this.exceptions.push({ exception, attributes: attrs as Attributes | undefined }) + this.exceptions.push({ + exception, + attributes: attrs as Attributes | undefined, + }) }, } return span @@ -127,9 +140,13 @@ export function createFakeTracer(): FakeTracer { startActiveSpan(...args: any[]) { const name = args[0] as string const fn = args[args.length - 1] as (span: Span) => unknown - const options = (typeof args[1] === 'object' && args[1] !== null && !('traceId' in args[1]) - ? args[1] - : {}) as SpanOptions + const options = ( + typeof args[1] === 'object' && + args[1] !== null && + !('traceId' in args[1]) + ? args[1] + : {} + ) as SpanOptions const parent = activeStack[activeStack.length - 1] ?? null const span = makeSpan(name, options, parent) spans.push(span) @@ -156,12 +173,24 @@ export function createFakeMeter(): FakeMeter { }, } }, - createCounter() { throw new Error('not implemented in fake') }, - createUpDownCounter() { throw new Error('not implemented in fake') }, - createObservableGauge() { throw new Error('not implemented in fake') }, - createObservableCounter() { throw new Error('not implemented in fake') }, - createObservableUpDownCounter() { throw new Error('not implemented in fake') }, - createGauge() { throw new Error('not implemented in fake') }, + createCounter() { + throw new Error('not implemented in fake') + }, + createUpDownCounter() { + throw new Error('not implemented in fake') + }, + createObservableGauge() { + throw new Error('not implemented in fake') + }, + createObservableCounter() { + throw new Error('not implemented in fake') + }, + createObservableUpDownCounter() { + throw new Error('not implemented in fake') + }, + createGauge() { + throw new Error('not implemented in fake') + }, addBatchObservableCallback() {}, removeBatchObservableCallback() {}, } as Meter @@ -173,7 +202,11 @@ export function createFakeMeter(): FakeMeter { * Build a minimal ChatMiddlewareContext for unit tests. Only fields the * otel middleware reads need realistic values; others can be placeholders. */ -export function makeCtx(overrides: Partial = {}) { +export function makeCtx( + overrides: Partial< + import('../../src/activities/chat/middleware/types').ChatMiddlewareContext + > = {}, +) { const base = { requestId: 'req-1', streamId: 'stream-1', @@ -197,5 +230,8 @@ export function makeCtx(overrides: Partial `${prefix}-1`, } - return { ...base, ...overrides } as import('../../src/activities/chat/middleware/types').ChatMiddlewareContext + return { + ...base, + ...overrides, + } as import('../../src/activities/chat/middleware/types').ChatMiddlewareContext } diff --git a/packages/typescript/ai/tests/middlewares/otel.test.ts b/packages/typescript/ai/tests/middlewares/otel.test.ts index 2aa109097..551601547 100644 --- a/packages/typescript/ai/tests/middlewares/otel.test.ts +++ b/packages/typescript/ai/tests/middlewares/otel.test.ts @@ -18,7 +18,11 @@ describe('otelMiddleware — root span lifecycle', () => { expect(spans[0]!.attributes['gen_ai.operation.name']).toBe('chat') expect(spans[0]!.attributes['gen_ai.request.model']).toBe('gpt-4o') - await mw.onFinish?.(ctx, { finishReason: 'stop', duration: 10, content: '' }) + await mw.onFinish?.(ctx, { + finishReason: 'stop', + duration: 10, + content: '', + }) expect(spans[0]!.ended).toBe(true) expect(spans[0]!.status.code).toBe(SpanStatusCode.UNSET) }) @@ -58,9 +62,15 @@ describe('otelMiddleware — iteration span lifecycle', () => { finishReason: 'stop', }) expect(iterSpan!.ended).toBe(true) - expect(iterSpan!.attributes['gen_ai.response.finish_reasons']).toEqual(['stop']) + expect(iterSpan!.attributes['gen_ai.response.finish_reasons']).toEqual([ + 'stop', + ]) - await mw.onFinish?.(ctx, { finishReason: 'stop', duration: 10, content: '' }) + await mw.onFinish?.(ctx, { + finishReason: 'stop', + duration: 10, + content: '', + }) expect(rootSpan!.ended).toBe(true) }) @@ -73,14 +83,28 @@ describe('otelMiddleware — iteration span lifecycle', () => { ctx.phase = 'beforeModel' await mw.onConfig?.(ctx, { messages: [], systemPrompts: [], tools: [] }) await mw.onChunk?.(ctx, { - type: EventType.RUN_FINISHED, threadId: 't-1', runId: 'r-1', model: 'gpt-4o', timestamp: 0, finishReason: 'tool_calls', + type: EventType.RUN_FINISHED, + threadId: 't-1', + runId: 'r-1', + model: 'gpt-4o', + timestamp: 0, + finishReason: 'tool_calls', }) ctx.iteration = 1 await mw.onConfig?.(ctx, { messages: [], systemPrompts: [], tools: [] }) await mw.onChunk?.(ctx, { - type: EventType.RUN_FINISHED, threadId: 't-1', runId: 'r-2', model: 'gpt-4o', timestamp: 0, finishReason: 'stop', + type: EventType.RUN_FINISHED, + threadId: 't-1', + runId: 'r-2', + model: 'gpt-4o', + timestamp: 0, + finishReason: 'stop', + }) + await mw.onFinish?.(ctx, { + finishReason: 'stop', + duration: 10, + content: '', }) - await mw.onFinish?.(ctx, { finishReason: 'stop', duration: 10, content: '' }) // 1 root + 2 iteration spans expect(spans).toHaveLength(3) @@ -99,12 +123,24 @@ describe('otelMiddleware — token histogram', () => { await mw.onStart?.(ctx) ctx.phase = 'beforeModel' await mw.onConfig?.(ctx, { messages: [], systemPrompts: [], tools: [] }) - await mw.onUsage?.(ctx, { promptTokens: 100, completionTokens: 50, totalTokens: 150 }) + await mw.onUsage?.(ctx, { + promptTokens: 100, + completionTokens: 50, + totalTokens: 150, + }) - const tokenRecords = records.filter((r) => r.name === 'gen_ai.client.token.usage') + const tokenRecords = records.filter( + (r) => r.name === 'gen_ai.client.token.usage', + ) expect(tokenRecords).toHaveLength(2) - expect(tokenRecords.find((r) => r.attributes!['gen_ai.token.type'] === 'input')!.value).toBe(100) - expect(tokenRecords.find((r) => r.attributes!['gen_ai.token.type'] === 'output')!.value).toBe(50) + expect( + tokenRecords.find((r) => r.attributes!['gen_ai.token.type'] === 'input')! + .value, + ).toBe(100) + expect( + tokenRecords.find((r) => r.attributes!['gen_ai.token.type'] === 'output')! + .value, + ).toBe(50) // Cardinality guard: response.id must NOT appear on metric attributes. for (const r of tokenRecords) { @@ -120,7 +156,11 @@ describe('otelMiddleware — token histogram', () => { await mw.onStart?.(ctx) ctx.phase = 'beforeModel' await mw.onConfig?.(ctx, { messages: [], systemPrompts: [], tools: [] }) - await mw.onUsage?.(ctx, { promptTokens: 100, completionTokens: 50, totalTokens: 150 }) + await mw.onUsage?.(ctx, { + promptTokens: 100, + completionTokens: 50, + totalTokens: 150, + }) expect(spans[1]!.attributes['gen_ai.usage.input_tokens']).toBe(100) expect(spans[1]!.attributes['gen_ai.usage.output_tokens']).toBe(50) @@ -135,7 +175,11 @@ describe('otelMiddleware — token histogram', () => { ctx.phase = 'beforeModel' await mw.onConfig?.(ctx, { messages: [], systemPrompts: [], tools: [] }) // Should not throw: - await mw.onUsage?.(ctx, { promptTokens: 100, completionTokens: 50, totalTokens: 150 }) + await mw.onUsage?.(ctx, { + promptTokens: 100, + completionTokens: 50, + totalTokens: 150, + }) }) }) @@ -149,7 +193,11 @@ describe('otelMiddleware — duration histogram and rollup', () => { await mw.onStart?.(ctx) ctx.phase = 'beforeModel' await mw.onConfig?.(ctx, { messages: [], systemPrompts: [], tools: [] }) - await mw.onUsage?.(ctx, { promptTokens: 100, completionTokens: 50, totalTokens: 150 }) + await mw.onUsage?.(ctx, { + promptTokens: 100, + completionTokens: 50, + totalTokens: 150, + }) await mw.onChunk?.(ctx, { type: EventType.RUN_FINISHED, threadId: 't-1', @@ -165,10 +213,14 @@ describe('otelMiddleware — duration histogram and rollup', () => { usage: { promptTokens: 100, completionTokens: 50, totalTokens: 150 }, }) - const durationRecords = records.filter((r) => r.name === 'gen_ai.client.operation.duration') + const durationRecords = records.filter( + (r) => r.name === 'gen_ai.client.operation.duration', + ) expect(durationRecords).toHaveLength(1) expect(durationRecords[0]!.value).toBe(1.25) - expect(durationRecords[0]!.attributes!['gen_ai.response.model']).toBe('gpt-4o') + expect(durationRecords[0]!.attributes!['gen_ai.response.model']).toBe( + 'gpt-4o', + ) expect(durationRecords[0]!.attributes!['error.type']).toBeUndefined() const root = spans[0]! @@ -191,7 +243,11 @@ describe('otelMiddleware — tool spans', () => { const iterSpan = spans[1]! await mw.onBeforeToolCall?.(ctx, { - toolCall: { id: 'tc-1', type: 'function', function: { name: 'get_weather', arguments: '{}' } } as any, + toolCall: { + id: 'tc-1', + type: 'function', + function: { name: 'get_weather', arguments: '{}' }, + } as any, tool: undefined, args: { city: 'NYC' }, toolName: 'get_weather', @@ -229,7 +285,11 @@ describe('otelMiddleware — tool spans', () => { ctx.phase = 'beforeModel' await mw.onConfig?.(ctx, { messages: [], systemPrompts: [], tools: [] }) await mw.onBeforeToolCall?.(ctx, { - toolCall: { id: 'tc-2', type: 'function', function: { name: 'broken', arguments: '{}' } } as any, + toolCall: { + id: 'tc-2', + type: 'function', + function: { name: 'broken', arguments: '{}' }, + } as any, tool: undefined, args: {}, toolName: 'broken', @@ -277,7 +337,9 @@ describe('otelMiddleware — captureContent', () => { const iter = spans[1]! const userEvt = iter.events.find((e) => e.name === 'gen_ai.user.message') const sysEvt = iter.events.find((e) => e.name === 'gen_ai.system.message') - const asstEvt = iter.events.find((e) => e.name === 'gen_ai.assistant.message') + const asstEvt = iter.events.find( + (e) => e.name === 'gen_ai.assistant.message', + ) expect(userEvt!.attributes!['content']).toBe('Hello [NUM] world') expect(sysEvt!.attributes!['content']).toBe('Be helpful [NUM]') expect(asstEvt!.attributes!['content']).toBe('Hi [NUM] there') @@ -297,7 +359,9 @@ describe('otelMiddleware — captureContent', () => { }) const iter = spans[1]! - expect(iter.events.filter((e) => e.name.startsWith('gen_ai.'))).toHaveLength(0) + expect( + iter.events.filter((e) => e.name.startsWith('gen_ai.')), + ).toHaveLength(0) }) it('multimodal ContentPart arrays become placeholder-tagged strings', async () => { @@ -321,7 +385,9 @@ describe('otelMiddleware — captureContent', () => { tools: [], }) - const userEvt = spans[1]!.events.find((e) => e.name === 'gen_ai.user.message')! + const userEvt = spans[1]!.events.find( + (e) => e.name === 'gen_ai.user.message', + )! expect(userEvt.attributes!['content']).toBe('look at this [image]') }) }) @@ -350,7 +416,9 @@ describe('otelMiddleware — error and abort paths', () => { expect(iter.ended).toBe(true) expect(iter.exceptions).toHaveLength(1) - const durationRecords = records.filter((r) => r.name === 'gen_ai.client.operation.duration') + const durationRecords = records.filter( + (r) => r.name === 'gen_ai.client.operation.duration', + ) expect(durationRecords[0]!.attributes!['error.type']).toBe('RateLimitError') }) @@ -384,7 +452,11 @@ describe('otelMiddleware — tool-message and choice events', () => { ctx.phase = 'beforeModel' await mw.onConfig?.(ctx, { messages: [], systemPrompts: [], tools: [] }) await mw.onBeforeToolCall?.(ctx, { - toolCall: { id: 'tc-1', type: 'function', function: { name: 'x', arguments: '{}' } } as any, + toolCall: { + id: 'tc-1', + type: 'function', + function: { name: 'x', arguments: '{}' }, + } as any, tool: undefined, args: {}, toolName: 'x', @@ -415,13 +487,30 @@ describe('otelMiddleware — tool-message and choice events', () => { ctx.phase = 'beforeModel' await mw.onConfig?.(ctx, { messages: [], systemPrompts: [], tools: [] }) await mw.onChunk?.(ctx, { - type: EventType.TEXT_MESSAGE_CONTENT, threadId: 't-1', messageId: 'm', model: 'gpt-4o', timestamp: 0, delta: 'Hello ', content: 'Hello ', + type: EventType.TEXT_MESSAGE_CONTENT, + threadId: 't-1', + messageId: 'm', + model: 'gpt-4o', + timestamp: 0, + delta: 'Hello ', + content: 'Hello ', }) await mw.onChunk?.(ctx, { - type: EventType.TEXT_MESSAGE_CONTENT, threadId: 't-1', messageId: 'm', model: 'gpt-4o', timestamp: 0, delta: 'world', content: 'Hello world', + type: EventType.TEXT_MESSAGE_CONTENT, + threadId: 't-1', + messageId: 'm', + model: 'gpt-4o', + timestamp: 0, + delta: 'world', + content: 'Hello world', }) await mw.onChunk?.(ctx, { - type: EventType.RUN_FINISHED, threadId: 't-1', runId: 'r', model: 'gpt-4o', timestamp: 0, finishReason: 'stop', + type: EventType.RUN_FINISHED, + threadId: 't-1', + runId: 'r', + model: 'gpt-4o', + timestamp: 0, + finishReason: 'stop', }) const iter = spans[1]! @@ -446,12 +535,30 @@ describe('otelMiddleware — concurrent isolation', () => { mw.onConfig?.(ctxB, { messages: [], systemPrompts: [], tools: [] }), ]) await Promise.all([ - mw.onChunk?.(ctxA, { type: EventType.RUN_FINISHED, threadId: 't-A', runId: 'A', model: 'gpt-4o', timestamp: 0, finishReason: 'stop' }), - mw.onChunk?.(ctxB, { type: EventType.RUN_FINISHED, threadId: 't-B', runId: 'B', model: 'gpt-4o', timestamp: 0, finishReason: 'tool_calls' }), + mw.onChunk?.(ctxA, { + type: EventType.RUN_FINISHED, + threadId: 't-A', + runId: 'A', + model: 'gpt-4o', + timestamp: 0, + finishReason: 'stop', + }), + mw.onChunk?.(ctxB, { + type: EventType.RUN_FINISHED, + threadId: 't-B', + runId: 'B', + model: 'gpt-4o', + timestamp: 0, + finishReason: 'tool_calls', + }), ]) await Promise.all([ mw.onFinish?.(ctxA, { finishReason: 'stop', duration: 1, content: '' }), - mw.onFinish?.(ctxB, { finishReason: 'tool_calls', duration: 1, content: '' }), + mw.onFinish?.(ctxB, { + finishReason: 'tool_calls', + duration: 1, + content: '', + }), ]) // Total: 2 root spans + 2 iteration spans, all ended. @@ -483,8 +590,15 @@ describe('otelMiddleware — extension points', () => { ctx.phase = 'beforeModel' await mw.onConfig?.(ctx, { messages: [], systemPrompts: [], tools: [] }) await mw.onBeforeToolCall?.(ctx, { - toolCall: { id: 't-1', type: 'function', function: { name: 'lookup', arguments: '{}' } } as any, - tool: undefined, args: {}, toolName: 'lookup', toolCallId: 't-1', + toolCall: { + id: 't-1', + type: 'function', + function: { name: 'lookup', arguments: '{}' }, + } as any, + tool: undefined, + args: {}, + toolName: 'lookup', + toolCallId: 't-1', }) expect(spans[0]!.name).toBe('my-chat') @@ -538,7 +652,14 @@ describe('otelMiddleware — extension points', () => { await mw.onStart?.(ctx) ctx.phase = 'beforeModel' await mw.onConfig?.(ctx, { messages: [], systemPrompts: [], tools: [] }) - await mw.onChunk?.(ctx, { type: EventType.RUN_FINISHED, threadId: 't-1', runId: 'r', model: 'gpt-4o', timestamp: 0, finishReason: 'stop' }) + await mw.onChunk?.(ctx, { + type: EventType.RUN_FINISHED, + threadId: 't-1', + runId: 'r', + model: 'gpt-4o', + timestamp: 0, + finishReason: 'stop', + }) await mw.onFinish?.(ctx, { finishReason: 'stop', duration: 1, content: '' }) expect(seen.map((s) => s.kind)).toEqual(['iteration', 'chat']) @@ -549,7 +670,9 @@ describe('otelMiddleware — extension points', () => { const { tracer, spans } = createFakeTracer() const mw = otelMiddleware({ tracer, - attributeEnricher: () => { throw new Error('boom') }, + attributeEnricher: () => { + throw new Error('boom') + }, }) const ctx = makeCtx() From 08c4d72a75f3d33fc8d59dc1a3adedc2e5a3e02d Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Thu, 23 Apr 2026 17:57:43 +0200 Subject: [PATCH 19/31] =?UTF-8?q?fix(ai):=20finalize=20otel=20middleware?= =?UTF-8?q?=20=E2=80=94=20span=20kinds,=20error-path=20onSpanEnd,=20docs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Set SpanKind.INTERNAL on root and tool spans, SpanKind.CLIENT on iteration spans so traces match the OTel GenAI convention and render correctly in backends that filter by kind. - Fire onSpanEnd for tool spans in onError and onAbort drain loops (was previously only called from onAfterToolCall). - Preserve toolName on tool-span state so error/abort onSpanEnd callbacks receive complete OtelSpanInfo. - Remove unused serviceName option from OtelMiddlewareOptions (never wired). - Correct duration-histogram scope in docs: chat() operation, not per-iteration. --- docs/advanced/otel.md | 2 +- .../typescript/ai/src/middlewares/otel.ts | 35 +++++++--- .../ai/tests/middlewares/otel.test.ts | 69 ++++++++++++++++++- 3 files changed, 94 insertions(+), 12 deletions(-) diff --git a/docs/advanced/otel.md b/docs/advanced/otel.md index 44877db3a..360a832a0 100644 --- a/docs/advanced/otel.md +++ b/docs/advanced/otel.md @@ -83,7 +83,7 @@ chat gpt-4o (root, kind: INTERNAL) Two GenAI-standard histograms, recorded per iteration: -- `gen_ai.client.operation.duration` (seconds) — per-iteration duration +- `gen_ai.client.operation.duration` (seconds) — duration of the `chat()` operation, including all agent-loop iterations and tool execution - `gen_ai.client.token.usage` (tokens) — recorded twice per iteration (input + output) with `gen_ai.token.type` attribute `gen_ai.response.id` is deliberately excluded from metric attributes to keep cardinality low. diff --git a/packages/typescript/ai/src/middlewares/otel.ts b/packages/typescript/ai/src/middlewares/otel.ts index 96514e321..f43b8e109 100644 --- a/packages/typescript/ai/src/middlewares/otel.ts +++ b/packages/typescript/ai/src/middlewares/otel.ts @@ -1,4 +1,5 @@ import { + SpanKind, SpanStatusCode, context as otelContext, trace as otelTrace, @@ -30,7 +31,6 @@ export interface OtelMiddlewareOptions { meter?: Meter captureContent?: boolean redact?: (text: string) => string - serviceName?: string spanNameFormatter?: (info: OtelSpanInfo) => string attributeEnricher?: (info: OtelSpanInfo) => Record onBeforeSpanStart?: (info: OtelSpanInfo, options: SpanOptions) => SpanOptions @@ -40,7 +40,7 @@ export interface OtelMiddlewareOptions { interface RequestState { rootSpan: Span currentIterationSpan: Span | null - toolSpans: Map + toolSpans: Map iterationCount: number assistantTextBuffer: string startTime: number @@ -115,7 +115,6 @@ export function otelMiddleware(options: OtelMiddlewareOptions): ChatMiddleware { meter, captureContent = false, redact = (s) => s, - serviceName: _serviceName = 'tanstack-ai', spanNameFormatter, attributeEnricher, onBeforeSpanStart, @@ -143,6 +142,7 @@ export function otelMiddleware(options: OtelMiddlewareOptions): ChatMiddleware { safeCall('otel.spanNameFormatter', () => spanNameFormatter?.(info)) ?? `chat ${ctx.model}` const baseOptions: SpanOptions = { + kind: SpanKind.INTERNAL, attributes: { 'gen_ai.system': ctx.provider, 'gen_ai.operation.name': 'chat', @@ -212,7 +212,7 @@ export function otelMiddleware(options: OtelMiddlewareOptions): ChatMiddleware { if (config.maxTokens !== undefined) baseAttrs['gen_ai.request.max_tokens'] = config.maxTokens - const baseOptions: SpanOptions = { attributes: baseAttrs } + const baseOptions: SpanOptions = { kind: SpanKind.CLIENT, attributes: baseAttrs } const spanOptions = safeCall('otel.onBeforeSpanStart', () => onBeforeSpanStart?.(info, baseOptions), @@ -348,7 +348,7 @@ export function otelMiddleware(options: OtelMiddlewareOptions): ChatMiddleware { 'gen_ai.tool.call.id': hookCtx.toolCallId, 'gen_ai.tool.type': 'function', } - const baseOptions: SpanOptions = { attributes: baseAttrs } + const baseOptions: SpanOptions = { kind: SpanKind.INTERNAL, attributes: baseAttrs } const spanOptions = safeCall('otel.onBeforeSpanStart', () => onBeforeSpanStart?.(info, baseOptions), @@ -369,7 +369,7 @@ export function otelMiddleware(options: OtelMiddlewareOptions): ChatMiddleware { ) if (enriched) toolSpan.setAttributes(enriched) - state.toolSpans.set(hookCtx.toolCallId, toolSpan) + state.toolSpans.set(hookCtx.toolCallId, { span: toolSpan, toolName: hookCtx.toolName }) }) return undefined }, @@ -378,8 +378,9 @@ export function otelMiddleware(options: OtelMiddlewareOptions): ChatMiddleware { safeCall('otel.onAfterToolCall', () => { const state = stateByCtx.get(ctx) if (!state) return - const toolSpan = state.toolSpans.get(info.toolCallId) - if (!toolSpan) return + const entry = state.toolSpans.get(info.toolCallId) + if (!entry) return + const { span: toolSpan } = entry const outcome = info.ok ? 'success' : 'error' toolSpan.setAttribute('tanstack.ai.tool.outcome', outcome) @@ -448,9 +449,16 @@ export function otelMiddleware(options: OtelMiddlewareOptions): ChatMiddleware { } // Close any open tool spans as errored. - for (const [id, span] of state.toolSpans) { + for (const [id, entry] of state.toolSpans) { + const { span, toolName } = entry span.recordException(info.error as Error) span.setStatus({ code: SpanStatusCode.ERROR, message }) + safeCall('otel.onSpanEnd', () => + onSpanEnd?.( + { kind: 'tool', ctx, toolCallId: id, toolName, iteration: state.iterationCount - 1 } as OtelSpanInfo<'tool'>, + span, + ), + ) span.end() state.toolSpans.delete(id) } @@ -497,8 +505,15 @@ export function otelMiddleware(options: OtelMiddlewareOptions): ChatMiddleware { state.currentIterationSpan.end() state.currentIterationSpan = null } - for (const [id, span] of state.toolSpans) { + for (const [id, entry] of state.toolSpans) { + const { span, toolName } = entry closeCancelled(span) + safeCall('otel.onSpanEnd', () => + onSpanEnd?.( + { kind: 'tool', ctx, toolCallId: id, toolName, iteration: state.iterationCount - 1 } as OtelSpanInfo<'tool'>, + span, + ), + ) span.end() state.toolSpans.delete(id) } diff --git a/packages/typescript/ai/tests/middlewares/otel.test.ts b/packages/typescript/ai/tests/middlewares/otel.test.ts index 551601547..160331c9a 100644 --- a/packages/typescript/ai/tests/middlewares/otel.test.ts +++ b/packages/typescript/ai/tests/middlewares/otel.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest' -import { SpanStatusCode } from '@opentelemetry/api' +import { SpanKind, SpanStatusCode } from '@opentelemetry/api' import { otelMiddleware } from '../../src/middlewares/otel' import { EventType } from '../../src/types' import { createFakeTracer, createFakeMeter, makeCtx } from './fake-otel' @@ -14,6 +14,7 @@ describe('otelMiddleware — root span lifecycle', () => { expect(spans).toHaveLength(1) expect(spans[0]!.name).toBe('chat gpt-4o') expect(spans[0]!.ended).toBe(false) + expect(spans[0]!.kind).toBe(SpanKind.INTERNAL) expect(spans[0]!.attributes['gen_ai.system']).toBe('openai') expect(spans[0]!.attributes['gen_ai.operation.name']).toBe('chat') expect(spans[0]!.attributes['gen_ai.request.model']).toBe('gpt-4o') @@ -51,6 +52,7 @@ describe('otelMiddleware — iteration span lifecycle', () => { expect(spans).toHaveLength(2) expect(iterSpan!.parent).toBe(rootSpan) expect(iterSpan!.name).toBe('chat gpt-4o') + expect(iterSpan!.kind).toBe(SpanKind.CLIENT) expect(iterSpan!.ended).toBe(false) await mw.onChunk?.(ctx, { @@ -257,6 +259,7 @@ describe('otelMiddleware — tool spans', () => { const toolSpan = spans[2]! expect(toolSpan.name).toBe('execute_tool get_weather') expect(toolSpan.parent).toBe(iterSpan) + expect(toolSpan.kind).toBe(SpanKind.INTERNAL) expect(toolSpan.attributes['gen_ai.tool.name']).toBe('get_weather') expect(toolSpan.attributes['gen_ai.tool.call.id']).toBe('tc-1') expect(toolSpan.attributes['gen_ai.tool.type']).toBe('function') @@ -436,6 +439,70 @@ describe('otelMiddleware — error and abort paths', () => { expect(spans[0]!.attributes['gen_ai.completion.reason']).toBe('cancelled') expect(spans[0]!.ended).toBe(true) }) + + it('onError fires onSpanEnd for open tool spans before ending them', async () => { + const { tracer } = createFakeTracer() + const seen: Array<{ kind: string; toolName?: string; toolCallId?: string; ended: boolean }> = [] + const mw = otelMiddleware({ + tracer, + onSpanEnd: (info, span) => { + seen.push({ kind: info.kind, toolName: info.toolName, toolCallId: info.toolCallId, ended: (span as any).ended }) + }, + }) + const ctx = makeCtx({ hasTools: true }) + + await mw.onStart?.(ctx) + ctx.phase = 'beforeModel' + await mw.onConfig?.(ctx, { messages: [], systemPrompts: [], tools: [] }) + await mw.onBeforeToolCall?.(ctx, { + toolCall: { id: 'tc-err', type: 'function', function: { name: 'my_tool', arguments: '{}' } } as any, + tool: undefined, + args: {}, + toolName: 'my_tool', + toolCallId: 'tc-err', + }) + + const err = new Error('fatal') + await mw.onError?.(ctx, { error: err, duration: 100 }) + + // onSpanEnd should have been called for: iteration, tool, root (in that order) + const toolCall = seen.find((s) => s.kind === 'tool') + expect(toolCall).toBeDefined() + expect(toolCall!.toolName).toBe('my_tool') + expect(toolCall!.toolCallId).toBe('tc-err') + expect(toolCall!.ended).toBe(false) // fired before span.end() + }) + + it('onAbort fires onSpanEnd for open tool spans before ending them', async () => { + const { tracer } = createFakeTracer() + const seen: Array<{ kind: string; toolName?: string; toolCallId?: string; ended: boolean }> = [] + const mw = otelMiddleware({ + tracer, + onSpanEnd: (info, span) => { + seen.push({ kind: info.kind, toolName: info.toolName, toolCallId: info.toolCallId, ended: (span as any).ended }) + }, + }) + const ctx = makeCtx({ hasTools: true }) + + await mw.onStart?.(ctx) + ctx.phase = 'beforeModel' + await mw.onConfig?.(ctx, { messages: [], systemPrompts: [], tools: [] }) + await mw.onBeforeToolCall?.(ctx, { + toolCall: { id: 'tc-abort', type: 'function', function: { name: 'slow_tool', arguments: '{}' } } as any, + tool: undefined, + args: {}, + toolName: 'slow_tool', + toolCallId: 'tc-abort', + }) + + await mw.onAbort?.(ctx, { reason: 'user stop', duration: 50 }) + + const toolCall = seen.find((s) => s.kind === 'tool') + expect(toolCall).toBeDefined() + expect(toolCall!.toolName).toBe('slow_tool') + expect(toolCall!.toolCallId).toBe('tc-abort') + expect(toolCall!.ended).toBe(false) // fired before span.end() + }) }) describe('otelMiddleware — tool-message and choice events', () => { From 1eee480b2cf1bb6845f65d51cc9bdf776db4c664 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Thu, 23 Apr 2026 16:00:24 +0000 Subject: [PATCH 20/31] ci: apply automated fixes --- .../typescript/ai/src/middlewares/otel.ts | 31 +++++++++++--- .../ai/tests/middlewares/otel.test.ts | 40 ++++++++++++++++--- 2 files changed, 60 insertions(+), 11 deletions(-) diff --git a/packages/typescript/ai/src/middlewares/otel.ts b/packages/typescript/ai/src/middlewares/otel.ts index f43b8e109..24d6bd800 100644 --- a/packages/typescript/ai/src/middlewares/otel.ts +++ b/packages/typescript/ai/src/middlewares/otel.ts @@ -212,7 +212,10 @@ export function otelMiddleware(options: OtelMiddlewareOptions): ChatMiddleware { if (config.maxTokens !== undefined) baseAttrs['gen_ai.request.max_tokens'] = config.maxTokens - const baseOptions: SpanOptions = { kind: SpanKind.CLIENT, attributes: baseAttrs } + const baseOptions: SpanOptions = { + kind: SpanKind.CLIENT, + attributes: baseAttrs, + } const spanOptions = safeCall('otel.onBeforeSpanStart', () => onBeforeSpanStart?.(info, baseOptions), @@ -348,7 +351,10 @@ export function otelMiddleware(options: OtelMiddlewareOptions): ChatMiddleware { 'gen_ai.tool.call.id': hookCtx.toolCallId, 'gen_ai.tool.type': 'function', } - const baseOptions: SpanOptions = { kind: SpanKind.INTERNAL, attributes: baseAttrs } + const baseOptions: SpanOptions = { + kind: SpanKind.INTERNAL, + attributes: baseAttrs, + } const spanOptions = safeCall('otel.onBeforeSpanStart', () => onBeforeSpanStart?.(info, baseOptions), @@ -369,7 +375,10 @@ export function otelMiddleware(options: OtelMiddlewareOptions): ChatMiddleware { ) if (enriched) toolSpan.setAttributes(enriched) - state.toolSpans.set(hookCtx.toolCallId, { span: toolSpan, toolName: hookCtx.toolName }) + state.toolSpans.set(hookCtx.toolCallId, { + span: toolSpan, + toolName: hookCtx.toolName, + }) }) return undefined }, @@ -455,7 +464,13 @@ export function otelMiddleware(options: OtelMiddlewareOptions): ChatMiddleware { span.setStatus({ code: SpanStatusCode.ERROR, message }) safeCall('otel.onSpanEnd', () => onSpanEnd?.( - { kind: 'tool', ctx, toolCallId: id, toolName, iteration: state.iterationCount - 1 } as OtelSpanInfo<'tool'>, + { + kind: 'tool', + ctx, + toolCallId: id, + toolName, + iteration: state.iterationCount - 1, + } as OtelSpanInfo<'tool'>, span, ), ) @@ -510,7 +525,13 @@ export function otelMiddleware(options: OtelMiddlewareOptions): ChatMiddleware { closeCancelled(span) safeCall('otel.onSpanEnd', () => onSpanEnd?.( - { kind: 'tool', ctx, toolCallId: id, toolName, iteration: state.iterationCount - 1 } as OtelSpanInfo<'tool'>, + { + kind: 'tool', + ctx, + toolCallId: id, + toolName, + iteration: state.iterationCount - 1, + } as OtelSpanInfo<'tool'>, span, ), ) diff --git a/packages/typescript/ai/tests/middlewares/otel.test.ts b/packages/typescript/ai/tests/middlewares/otel.test.ts index 160331c9a..33f4cef9b 100644 --- a/packages/typescript/ai/tests/middlewares/otel.test.ts +++ b/packages/typescript/ai/tests/middlewares/otel.test.ts @@ -442,11 +442,21 @@ describe('otelMiddleware — error and abort paths', () => { it('onError fires onSpanEnd for open tool spans before ending them', async () => { const { tracer } = createFakeTracer() - const seen: Array<{ kind: string; toolName?: string; toolCallId?: string; ended: boolean }> = [] + const seen: Array<{ + kind: string + toolName?: string + toolCallId?: string + ended: boolean + }> = [] const mw = otelMiddleware({ tracer, onSpanEnd: (info, span) => { - seen.push({ kind: info.kind, toolName: info.toolName, toolCallId: info.toolCallId, ended: (span as any).ended }) + seen.push({ + kind: info.kind, + toolName: info.toolName, + toolCallId: info.toolCallId, + ended: (span as any).ended, + }) }, }) const ctx = makeCtx({ hasTools: true }) @@ -455,7 +465,11 @@ describe('otelMiddleware — error and abort paths', () => { ctx.phase = 'beforeModel' await mw.onConfig?.(ctx, { messages: [], systemPrompts: [], tools: [] }) await mw.onBeforeToolCall?.(ctx, { - toolCall: { id: 'tc-err', type: 'function', function: { name: 'my_tool', arguments: '{}' } } as any, + toolCall: { + id: 'tc-err', + type: 'function', + function: { name: 'my_tool', arguments: '{}' }, + } as any, tool: undefined, args: {}, toolName: 'my_tool', @@ -475,11 +489,21 @@ describe('otelMiddleware — error and abort paths', () => { it('onAbort fires onSpanEnd for open tool spans before ending them', async () => { const { tracer } = createFakeTracer() - const seen: Array<{ kind: string; toolName?: string; toolCallId?: string; ended: boolean }> = [] + const seen: Array<{ + kind: string + toolName?: string + toolCallId?: string + ended: boolean + }> = [] const mw = otelMiddleware({ tracer, onSpanEnd: (info, span) => { - seen.push({ kind: info.kind, toolName: info.toolName, toolCallId: info.toolCallId, ended: (span as any).ended }) + seen.push({ + kind: info.kind, + toolName: info.toolName, + toolCallId: info.toolCallId, + ended: (span as any).ended, + }) }, }) const ctx = makeCtx({ hasTools: true }) @@ -488,7 +512,11 @@ describe('otelMiddleware — error and abort paths', () => { ctx.phase = 'beforeModel' await mw.onConfig?.(ctx, { messages: [], systemPrompts: [], tools: [] }) await mw.onBeforeToolCall?.(ctx, { - toolCall: { id: 'tc-abort', type: 'function', function: { name: 'slow_tool', arguments: '{}' } } as any, + toolCall: { + id: 'tc-abort', + type: 'function', + function: { name: 'slow_tool', arguments: '{}' }, + } as any, tool: undefined, args: {}, toolName: 'slow_tool', From e5a48081e6c535a70362d4fd0d7a56a7d27ce689 Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Thu, 23 Apr 2026 18:09:20 +0200 Subject: [PATCH 21/31] test(ai): hygiene cleanup on otel middleware tests - Extract makeToolCall() factory in fake-otel.ts, replace 10+ as-any casts on tool-call test objects with the typed factory. - Extract runToIterationStart() helper, remove 3-line setup duplication from every behavioral test. - Use ev.runFinished() from test-utils for RUN_FINISHED chunks where the shared shorthand applies. - Cast span to FakeSpan (not any) in onSpanEnd callbacks. - Use RateLimitError subclass instead of mutating err.name via as-any. - Use as-const / satisfies instead of as-any for multimodal content arrays. --- .../ai/tests/middlewares/fake-otel.ts | 23 ++ .../ai/tests/middlewares/otel.test.ts | 304 +++++------------- 2 files changed, 112 insertions(+), 215 deletions(-) diff --git a/packages/typescript/ai/tests/middlewares/fake-otel.ts b/packages/typescript/ai/tests/middlewares/fake-otel.ts index 45d5e5201..03d86df73 100644 --- a/packages/typescript/ai/tests/middlewares/fake-otel.ts +++ b/packages/typescript/ai/tests/middlewares/fake-otel.ts @@ -12,6 +12,7 @@ import type { Histogram, } from '@opentelemetry/api' import { SpanStatusCode } from '@opentelemetry/api' +import type { ToolCall } from '../../src/types' export interface RecordedEvent { name: string @@ -198,6 +199,28 @@ export function createFakeMeter(): FakeMeter { return { meter, records } } +/** + * Build a minimal ToolCall for tests. Fills required fields with plausible + * defaults; overrides take precedence. + */ +export function makeToolCall( + overrides: { id: string } & Partial> & { + function?: Partial + }, +): ToolCall { + return { + id: overrides.id, + type: overrides.type ?? 'function', + function: { + name: overrides.function?.name ?? 'test_tool', + arguments: overrides.function?.arguments ?? '{}', + }, + ...(overrides.providerMetadata !== undefined + ? { providerMetadata: overrides.providerMetadata } + : {}), + } +} + /** * Build a minimal ChatMiddlewareContext for unit tests. Only fields the * otel middleware reads need realistic values; others can be placeholders. diff --git a/packages/typescript/ai/tests/middlewares/otel.test.ts b/packages/typescript/ai/tests/middlewares/otel.test.ts index 33f4cef9b..641af432b 100644 --- a/packages/typescript/ai/tests/middlewares/otel.test.ts +++ b/packages/typescript/ai/tests/middlewares/otel.test.ts @@ -1,8 +1,42 @@ import { describe, it, expect } from 'vitest' import { SpanKind, SpanStatusCode } from '@opentelemetry/api' import { otelMiddleware } from '../../src/middlewares/otel' -import { EventType } from '../../src/types' -import { createFakeTracer, createFakeMeter, makeCtx } from './fake-otel' +import { + createFakeTracer, + createFakeMeter, + makeCtx, + makeToolCall, + type FakeSpan, +} from './fake-otel' +import type { + ChatMiddleware, + ChatMiddlewareContext, + ChatMiddlewareConfig, +} from '../../src/activities/chat/middleware/types' +import { ev } from '../test-utils' + +// --------------------------------------------------------------------------- +// File-local helpers +// --------------------------------------------------------------------------- + +async function runToIterationStart( + mw: ChatMiddleware, + ctx: ChatMiddlewareContext, + config: Partial = {}, +) { + await mw.onStart?.(ctx) + ctx.phase = 'beforeModel' + await mw.onConfig?.(ctx, { + messages: [], + systemPrompts: [], + tools: [], + ...config, + }) +} + +class RateLimitError extends Error { + override name = 'RateLimitError' +} describe('otelMiddleware — root span lifecycle', () => { it('creates a root span on onStart and closes it on onFinish', async () => { @@ -36,13 +70,8 @@ describe('otelMiddleware — iteration span lifecycle', () => { const ctx = makeCtx() ctx.phase = 'init' - await mw.onStart?.(ctx) - ctx.phase = 'beforeModel' - - await mw.onConfig?.(ctx, { + await runToIterationStart(mw, ctx, { messages: [{ role: 'user', content: 'hi' }], - systemPrompts: [], - tools: [], temperature: 0.7, topP: 0.9, maxTokens: 512, @@ -55,14 +84,8 @@ describe('otelMiddleware — iteration span lifecycle', () => { expect(iterSpan!.kind).toBe(SpanKind.CLIENT) expect(iterSpan!.ended).toBe(false) - await mw.onChunk?.(ctx, { - type: EventType.RUN_FINISHED, - threadId: 't-1', - runId: 'r-1', - model: 'gpt-4o', - timestamp: Date.now(), - finishReason: 'stop', - }) + // model field needed so gen_ai.response.model is set; spread ev.runFinished for the rest + await mw.onChunk?.(ctx, { ...ev.runFinished('stop'), model: 'gpt-4o' }) expect(iterSpan!.ended).toBe(true) expect(iterSpan!.attributes['gen_ai.response.finish_reasons']).toEqual([ 'stop', @@ -81,27 +104,11 @@ describe('otelMiddleware — iteration span lifecycle', () => { const mw = otelMiddleware({ tracer }) const ctx = makeCtx() - await mw.onStart?.(ctx) - ctx.phase = 'beforeModel' - await mw.onConfig?.(ctx, { messages: [], systemPrompts: [], tools: [] }) - await mw.onChunk?.(ctx, { - type: EventType.RUN_FINISHED, - threadId: 't-1', - runId: 'r-1', - model: 'gpt-4o', - timestamp: 0, - finishReason: 'tool_calls', - }) + await runToIterationStart(mw, ctx) + await mw.onChunk?.(ctx, ev.runFinished('tool_calls')) ctx.iteration = 1 await mw.onConfig?.(ctx, { messages: [], systemPrompts: [], tools: [] }) - await mw.onChunk?.(ctx, { - type: EventType.RUN_FINISHED, - threadId: 't-1', - runId: 'r-2', - model: 'gpt-4o', - timestamp: 0, - finishReason: 'stop', - }) + await mw.onChunk?.(ctx, ev.runFinished('stop')) await mw.onFinish?.(ctx, { finishReason: 'stop', duration: 10, @@ -122,9 +129,7 @@ describe('otelMiddleware — token histogram', () => { const mw = otelMiddleware({ tracer, meter }) const ctx = makeCtx() - await mw.onStart?.(ctx) - ctx.phase = 'beforeModel' - await mw.onConfig?.(ctx, { messages: [], systemPrompts: [], tools: [] }) + await runToIterationStart(mw, ctx) await mw.onUsage?.(ctx, { promptTokens: 100, completionTokens: 50, @@ -155,9 +160,7 @@ describe('otelMiddleware — token histogram', () => { const mw = otelMiddleware({ tracer }) const ctx = makeCtx() - await mw.onStart?.(ctx) - ctx.phase = 'beforeModel' - await mw.onConfig?.(ctx, { messages: [], systemPrompts: [], tools: [] }) + await runToIterationStart(mw, ctx) await mw.onUsage?.(ctx, { promptTokens: 100, completionTokens: 50, @@ -173,9 +176,7 @@ describe('otelMiddleware — token histogram', () => { const mw = otelMiddleware({ tracer }) const ctx = makeCtx() - await mw.onStart?.(ctx) - ctx.phase = 'beforeModel' - await mw.onConfig?.(ctx, { messages: [], systemPrompts: [], tools: [] }) + await runToIterationStart(mw, ctx) // Should not throw: await mw.onUsage?.(ctx, { promptTokens: 100, @@ -192,22 +193,14 @@ describe('otelMiddleware — duration histogram and rollup', () => { const mw = otelMiddleware({ tracer, meter }) const ctx = makeCtx() - await mw.onStart?.(ctx) - ctx.phase = 'beforeModel' - await mw.onConfig?.(ctx, { messages: [], systemPrompts: [], tools: [] }) + await runToIterationStart(mw, ctx) await mw.onUsage?.(ctx, { promptTokens: 100, completionTokens: 50, totalTokens: 150, }) - await mw.onChunk?.(ctx, { - type: EventType.RUN_FINISHED, - threadId: 't-1', - runId: 'r', - model: 'gpt-4o', - timestamp: 0, - finishReason: 'stop', - }) + // model field needed for gen_ai.response.model on duration histogram attributes + await mw.onChunk?.(ctx, { ...ev.runFinished('stop'), model: 'gpt-4o' }) await mw.onFinish?.(ctx, { finishReason: 'stop', duration: 1250, @@ -239,17 +232,11 @@ describe('otelMiddleware — tool spans', () => { const mw = otelMiddleware({ tracer }) const ctx = makeCtx({ hasTools: true, toolNames: ['get_weather'] }) - await mw.onStart?.(ctx) - ctx.phase = 'beforeModel' - await mw.onConfig?.(ctx, { messages: [], systemPrompts: [], tools: [] }) + await runToIterationStart(mw, ctx) const iterSpan = spans[1]! await mw.onBeforeToolCall?.(ctx, { - toolCall: { - id: 'tc-1', - type: 'function', - function: { name: 'get_weather', arguments: '{}' }, - } as any, + toolCall: makeToolCall({ id: 'tc-1', function: { name: 'get_weather' } }), tool: undefined, args: { city: 'NYC' }, toolName: 'get_weather', @@ -266,7 +253,7 @@ describe('otelMiddleware — tool spans', () => { expect(toolSpan.ended).toBe(false) await mw.onAfterToolCall?.(ctx, { - toolCall: { id: 'tc-1' } as any, + toolCall: makeToolCall({ id: 'tc-1' }), tool: undefined, toolName: 'get_weather', toolCallId: 'tc-1', @@ -284,15 +271,9 @@ describe('otelMiddleware — tool spans', () => { const mw = otelMiddleware({ tracer }) const ctx = makeCtx({ hasTools: true }) - await mw.onStart?.(ctx) - ctx.phase = 'beforeModel' - await mw.onConfig?.(ctx, { messages: [], systemPrompts: [], tools: [] }) + await runToIterationStart(mw, ctx) await mw.onBeforeToolCall?.(ctx, { - toolCall: { - id: 'tc-2', - type: 'function', - function: { name: 'broken', arguments: '{}' }, - } as any, + toolCall: makeToolCall({ id: 'tc-2', function: { name: 'broken' } }), tool: undefined, args: {}, toolName: 'broken', @@ -300,7 +281,7 @@ describe('otelMiddleware — tool spans', () => { }) const toolSpan = spans[2]! await mw.onAfterToolCall?.(ctx, { - toolCall: { id: 'tc-2' } as any, + toolCall: makeToolCall({ id: 'tc-2' }), tool: undefined, toolName: 'broken', toolCallId: 'tc-2', @@ -326,15 +307,12 @@ describe('otelMiddleware — captureContent', () => { }) const ctx = makeCtx() - await mw.onStart?.(ctx) - ctx.phase = 'beforeModel' - await mw.onConfig?.(ctx, { + await runToIterationStart(mw, ctx, { messages: [ { role: 'user', content: 'Hello 42 world' }, { role: 'assistant', content: 'Hi 7 there' }, ], systemPrompts: ['Be helpful 99'], - tools: [], }) const iter = spans[1]! @@ -353,12 +331,8 @@ describe('otelMiddleware — captureContent', () => { const mw = otelMiddleware({ tracer }) const ctx = makeCtx() - await mw.onStart?.(ctx) - ctx.phase = 'beforeModel' - await mw.onConfig?.(ctx, { + await runToIterationStart(mw, ctx, { messages: [{ role: 'user', content: 'Hello' }], - systemPrompts: [], - tools: [], }) const iter = spans[1]! @@ -372,20 +346,18 @@ describe('otelMiddleware — captureContent', () => { const mw = otelMiddleware({ tracer, captureContent: true }) const ctx = makeCtx() - await mw.onStart?.(ctx) - ctx.phase = 'beforeModel' - await mw.onConfig?.(ctx, { + // serializeContent inspects .type at runtime — the source.type shape doesn't + // matter for this test; only the top-level 'type: image' is checked. + await runToIterationStart(mw, ctx, { messages: [ { role: 'user', content: [ - { type: 'text', text: 'look at this' }, - { type: 'image', source: { type: 'base64', data: '...' } }, - ] as any, + { type: 'text', content: 'look at this' }, + { type: 'image', source: { type: 'url', value: 'data:...' } }, + ] as const, }, ], - systemPrompts: [], - tools: [], }) const userEvt = spans[1]!.events.find( @@ -402,11 +374,8 @@ describe('otelMiddleware — error and abort paths', () => { const mw = otelMiddleware({ tracer, meter }) const ctx = makeCtx() - await mw.onStart?.(ctx) - ctx.phase = 'beforeModel' - await mw.onConfig?.(ctx, { messages: [], systemPrompts: [], tools: [] }) - const err = new Error('rate limited') - ;(err as any).name = 'RateLimitError' + await runToIterationStart(mw, ctx) + const err = new RateLimitError('rate limited') await mw.onError?.(ctx, { error: err, duration: 200 }) const root = spans[0]! @@ -430,9 +399,7 @@ describe('otelMiddleware — error and abort paths', () => { const mw = otelMiddleware({ tracer }) const ctx = makeCtx() - await mw.onStart?.(ctx) - ctx.phase = 'beforeModel' - await mw.onConfig?.(ctx, { messages: [], systemPrompts: [], tools: [] }) + await runToIterationStart(mw, ctx) await mw.onAbort?.(ctx, { reason: 'user stop', duration: 80 }) expect(spans[0]!.status.code).toBe(SpanStatusCode.ERROR) @@ -442,34 +409,18 @@ describe('otelMiddleware — error and abort paths', () => { it('onError fires onSpanEnd for open tool spans before ending them', async () => { const { tracer } = createFakeTracer() - const seen: Array<{ - kind: string - toolName?: string - toolCallId?: string - ended: boolean - }> = [] + const seen: Array<{ kind: string; toolName?: string; toolCallId?: string; ended: boolean }> = [] const mw = otelMiddleware({ tracer, onSpanEnd: (info, span) => { - seen.push({ - kind: info.kind, - toolName: info.toolName, - toolCallId: info.toolCallId, - ended: (span as any).ended, - }) + seen.push({ kind: info.kind, toolName: info.toolName, toolCallId: info.toolCallId, ended: (span as FakeSpan).ended }) }, }) const ctx = makeCtx({ hasTools: true }) - await mw.onStart?.(ctx) - ctx.phase = 'beforeModel' - await mw.onConfig?.(ctx, { messages: [], systemPrompts: [], tools: [] }) + await runToIterationStart(mw, ctx) await mw.onBeforeToolCall?.(ctx, { - toolCall: { - id: 'tc-err', - type: 'function', - function: { name: 'my_tool', arguments: '{}' }, - } as any, + toolCall: makeToolCall({ id: 'tc-err', function: { name: 'my_tool' } }), tool: undefined, args: {}, toolName: 'my_tool', @@ -489,34 +440,18 @@ describe('otelMiddleware — error and abort paths', () => { it('onAbort fires onSpanEnd for open tool spans before ending them', async () => { const { tracer } = createFakeTracer() - const seen: Array<{ - kind: string - toolName?: string - toolCallId?: string - ended: boolean - }> = [] + const seen: Array<{ kind: string; toolName?: string; toolCallId?: string; ended: boolean }> = [] const mw = otelMiddleware({ tracer, onSpanEnd: (info, span) => { - seen.push({ - kind: info.kind, - toolName: info.toolName, - toolCallId: info.toolCallId, - ended: (span as any).ended, - }) + seen.push({ kind: info.kind, toolName: info.toolName, toolCallId: info.toolCallId, ended: (span as FakeSpan).ended }) }, }) const ctx = makeCtx({ hasTools: true }) - await mw.onStart?.(ctx) - ctx.phase = 'beforeModel' - await mw.onConfig?.(ctx, { messages: [], systemPrompts: [], tools: [] }) + await runToIterationStart(mw, ctx) await mw.onBeforeToolCall?.(ctx, { - toolCall: { - id: 'tc-abort', - type: 'function', - function: { name: 'slow_tool', arguments: '{}' }, - } as any, + toolCall: makeToolCall({ id: 'tc-abort', function: { name: 'slow_tool' } }), tool: undefined, args: {}, toolName: 'slow_tool', @@ -543,22 +478,16 @@ describe('otelMiddleware — tool-message and choice events', () => { }) const ctx = makeCtx({ hasTools: true }) - await mw.onStart?.(ctx) - ctx.phase = 'beforeModel' - await mw.onConfig?.(ctx, { messages: [], systemPrompts: [], tools: [] }) + await runToIterationStart(mw, ctx) await mw.onBeforeToolCall?.(ctx, { - toolCall: { - id: 'tc-1', - type: 'function', - function: { name: 'x', arguments: '{}' }, - } as any, + toolCall: makeToolCall({ id: 'tc-1', function: { name: 'x' } }), tool: undefined, args: {}, toolName: 'x', toolCallId: 'tc-1', }) await mw.onAfterToolCall?.(ctx, { - toolCall: { id: 'tc-1' } as any, + toolCall: makeToolCall({ id: 'tc-1' }), tool: undefined, toolName: 'x', toolCallId: 'tc-1', @@ -578,35 +507,11 @@ describe('otelMiddleware — tool-message and choice events', () => { const mw = otelMiddleware({ tracer, captureContent: true }) const ctx = makeCtx() - await mw.onStart?.(ctx) - ctx.phase = 'beforeModel' - await mw.onConfig?.(ctx, { messages: [], systemPrompts: [], tools: [] }) - await mw.onChunk?.(ctx, { - type: EventType.TEXT_MESSAGE_CONTENT, - threadId: 't-1', - messageId: 'm', - model: 'gpt-4o', - timestamp: 0, - delta: 'Hello ', - content: 'Hello ', - }) - await mw.onChunk?.(ctx, { - type: EventType.TEXT_MESSAGE_CONTENT, - threadId: 't-1', - messageId: 'm', - model: 'gpt-4o', - timestamp: 0, - delta: 'world', - content: 'Hello world', - }) - await mw.onChunk?.(ctx, { - type: EventType.RUN_FINISHED, - threadId: 't-1', - runId: 'r', - model: 'gpt-4o', - timestamp: 0, - finishReason: 'stop', - }) + await runToIterationStart(mw, ctx) + await mw.onChunk?.(ctx, ev.textContent('Hello ')) + await mw.onChunk?.(ctx, ev.textContent('world')) + // model field needed for gen_ai.response.model; rest from ev.runFinished + await mw.onChunk?.(ctx, { ...ev.runFinished('stop'), model: 'gpt-4o' }) const iter = spans[1]! const choice = iter.events.find((e) => e.name === 'gen_ai.choice')! @@ -630,22 +535,8 @@ describe('otelMiddleware — concurrent isolation', () => { mw.onConfig?.(ctxB, { messages: [], systemPrompts: [], tools: [] }), ]) await Promise.all([ - mw.onChunk?.(ctxA, { - type: EventType.RUN_FINISHED, - threadId: 't-A', - runId: 'A', - model: 'gpt-4o', - timestamp: 0, - finishReason: 'stop', - }), - mw.onChunk?.(ctxB, { - type: EventType.RUN_FINISHED, - threadId: 't-B', - runId: 'B', - model: 'gpt-4o', - timestamp: 0, - finishReason: 'tool_calls', - }), + mw.onChunk?.(ctxA, ev.runFinished('stop', 'A')), + mw.onChunk?.(ctxB, ev.runFinished('tool_calls', 'B')), ]) await Promise.all([ mw.onFinish?.(ctxA, { finishReason: 'stop', duration: 1, content: '' }), @@ -681,15 +572,9 @@ describe('otelMiddleware — extension points', () => { }) const ctx = makeCtx({ hasTools: true }) - await mw.onStart?.(ctx) - ctx.phase = 'beforeModel' - await mw.onConfig?.(ctx, { messages: [], systemPrompts: [], tools: [] }) + await runToIterationStart(mw, ctx) await mw.onBeforeToolCall?.(ctx, { - toolCall: { - id: 't-1', - type: 'function', - function: { name: 'lookup', arguments: '{}' }, - } as any, + toolCall: makeToolCall({ id: 't-1', function: { name: 'lookup' } }), tool: undefined, args: {}, toolName: 'lookup', @@ -709,9 +594,7 @@ describe('otelMiddleware — extension points', () => { }) const ctx = makeCtx() - await mw.onStart?.(ctx) - ctx.phase = 'beforeModel' - await mw.onConfig?.(ctx, { messages: [], systemPrompts: [], tools: [] }) + await runToIterationStart(mw, ctx) expect(spans[0]!.attributes['test.kind']).toBe('chat') expect(spans[1]!.attributes['test.kind']).toBe('iteration') @@ -739,22 +622,13 @@ describe('otelMiddleware — extension points', () => { const mw = otelMiddleware({ tracer, onSpanEnd: (info, span) => { - seen.push({ kind: info.kind, ended: (span as any).ended }) + seen.push({ kind: info.kind, ended: (span as FakeSpan).ended }) }, }) const ctx = makeCtx() - await mw.onStart?.(ctx) - ctx.phase = 'beforeModel' - await mw.onConfig?.(ctx, { messages: [], systemPrompts: [], tools: [] }) - await mw.onChunk?.(ctx, { - type: EventType.RUN_FINISHED, - threadId: 't-1', - runId: 'r', - model: 'gpt-4o', - timestamp: 0, - finishReason: 'stop', - }) + await runToIterationStart(mw, ctx) + await mw.onChunk?.(ctx, ev.runFinished('stop')) await mw.onFinish?.(ctx, { finishReason: 'stop', duration: 1, content: '' }) expect(seen.map((s) => s.kind)).toEqual(['iteration', 'chat']) From 37ee76dc0493047dd954548b2e5b4a76bed01b87 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Thu, 23 Apr 2026 16:13:19 +0000 Subject: [PATCH 22/31] ci: apply automated fixes --- .../ai/tests/middlewares/fake-otel.ts | 4 +-- .../ai/tests/middlewares/otel.test.ts | 33 ++++++++++++++++--- 2 files changed, 30 insertions(+), 7 deletions(-) diff --git a/packages/typescript/ai/tests/middlewares/fake-otel.ts b/packages/typescript/ai/tests/middlewares/fake-otel.ts index 03d86df73..793a589d5 100644 --- a/packages/typescript/ai/tests/middlewares/fake-otel.ts +++ b/packages/typescript/ai/tests/middlewares/fake-otel.ts @@ -205,8 +205,8 @@ export function createFakeMeter(): FakeMeter { */ export function makeToolCall( overrides: { id: string } & Partial> & { - function?: Partial - }, + function?: Partial + }, ): ToolCall { return { id: overrides.id, diff --git a/packages/typescript/ai/tests/middlewares/otel.test.ts b/packages/typescript/ai/tests/middlewares/otel.test.ts index 641af432b..c31766f09 100644 --- a/packages/typescript/ai/tests/middlewares/otel.test.ts +++ b/packages/typescript/ai/tests/middlewares/otel.test.ts @@ -409,11 +409,21 @@ describe('otelMiddleware — error and abort paths', () => { it('onError fires onSpanEnd for open tool spans before ending them', async () => { const { tracer } = createFakeTracer() - const seen: Array<{ kind: string; toolName?: string; toolCallId?: string; ended: boolean }> = [] + const seen: Array<{ + kind: string + toolName?: string + toolCallId?: string + ended: boolean + }> = [] const mw = otelMiddleware({ tracer, onSpanEnd: (info, span) => { - seen.push({ kind: info.kind, toolName: info.toolName, toolCallId: info.toolCallId, ended: (span as FakeSpan).ended }) + seen.push({ + kind: info.kind, + toolName: info.toolName, + toolCallId: info.toolCallId, + ended: (span as FakeSpan).ended, + }) }, }) const ctx = makeCtx({ hasTools: true }) @@ -440,18 +450,31 @@ describe('otelMiddleware — error and abort paths', () => { it('onAbort fires onSpanEnd for open tool spans before ending them', async () => { const { tracer } = createFakeTracer() - const seen: Array<{ kind: string; toolName?: string; toolCallId?: string; ended: boolean }> = [] + const seen: Array<{ + kind: string + toolName?: string + toolCallId?: string + ended: boolean + }> = [] const mw = otelMiddleware({ tracer, onSpanEnd: (info, span) => { - seen.push({ kind: info.kind, toolName: info.toolName, toolCallId: info.toolCallId, ended: (span as FakeSpan).ended }) + seen.push({ + kind: info.kind, + toolName: info.toolName, + toolCallId: info.toolCallId, + ended: (span as FakeSpan).ended, + }) }, }) const ctx = makeCtx({ hasTools: true }) await runToIterationStart(mw, ctx) await mw.onBeforeToolCall?.(ctx, { - toolCall: makeToolCall({ id: 'tc-abort', function: { name: 'slow_tool' } }), + toolCall: makeToolCall({ + id: 'tc-abort', + function: { name: 'slow_tool' }, + }), tool: undefined, args: {}, toolName: 'slow_tool', From 54666a99e44add4b3b5896e6e3385720c47a00e6 Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Fri, 24 Apr 2026 11:10:01 +0200 Subject: [PATCH 23/31] fix(ai): address otel-middleware review feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Critical - C1: onUsage was a no-op in production — RUN_FINISHED closed the iteration span before runOnUsage fired, dropping gen_ai.usage.* attrs and the token histogram. Fix: keep the iteration span open through tool execution and onUsage; close it on the next onConfig(beforeModel), on onFinish, or on onError/onAbort. Token histogram is also recorded directly from chunk.usage at RUN_FINISHED and redundantly from onUsage so neither hook-order variant loses data. - C3: otelMiddleware is now exported from the dedicated subpath @tanstack/ai/middlewares/otel instead of the main middlewares barrel, so importing toolCacheMiddleware or contentGuardMiddleware no longer eagerly requires @opentelemetry/api. - C4: redactor failures fail closed to the literal sentinel "[redaction_failed]" and log a warning — raw content can no longer leak when a PII redactor throws. - C2: added two middleware.spec.ts scenarios that exercise otel end-to-end through the real chat() runner (basic-text + with-tool), guarding against the C1 regression and verifying tool-span nesting. Important - I1: safeCall now logs callback failures via console.warn with a label, matching the docs' "a thrown callback becomes a log line" promise. - I2: replaced gen_ai.completion.reason='cancelled' (not a valid semconv attribute) with tanstack.ai.completion.reason. - I3: onAbort now records gen_ai.client.operation.duration with error.type='cancelled'. - I4: docs + changeset corrected — duration histogram is per-run, token histogram is per-iteration. - I5: error/abort tests now assert the full onSpanEnd sequence (iteration, tool, chat) with each span captured before .end(). - I6: tool spans still open at onFinish are swept with tanstack.ai.tool.outcome='unknown'. - I7: OtelSpanInfo is now a proper discriminated union; tool-only fields narrow inside callbacks and the internal 'as OtelSpanInfo<\"tool\">' casts are mostly gone. - I8: iteration spans now use 'chat #' so they are distinguishable in trace viewers. - I9: gen_ai.response.model dropped from the duration histogram attrs (high-cardinality). Misc - fake-otel: resolve parent via the explicit context arg passed to startSpan, eliminating the ;(span as any).parent = ... test hack. createHistogram also captures options so unit/description are assertable. - redactException paths use 'as Exception' instead of 'as Error' so non-Error throwables are preserved. - OtelSpanKind kept as a deprecated alias of OtelSpanScope to avoid shadowing OTel's built-in SpanKind. - Public types documented with JSDoc. - Assistant text buffer is now capped at maxContentLength (default 100k). --- .changeset/otel-middleware.md | 10 +- docs/advanced/otel.md | 32 +- packages/typescript/ai/package.json | 4 + .../typescript/ai/src/middlewares/index.ts | 10 +- .../typescript/ai/src/middlewares/otel.ts | 359 +++++++++++++----- .../ai/tests/middlewares/fake-otel.ts | 49 ++- .../ai/tests/middlewares/otel.test.ts | 352 +++++++++++++---- packages/typescript/ai/vite.config.ts | 1 + pnpm-lock.yaml | 3 + testing/e2e/package.json | 1 + testing/e2e/src/lib/otel-capture.ts | 103 +++++ testing/e2e/src/routes/api.middleware-test.ts | 175 ++++++++- testing/e2e/src/routes/middleware-test.tsx | 1 + testing/e2e/tests/middleware.spec.ts | 112 ++++++ 14 files changed, 997 insertions(+), 215 deletions(-) create mode 100644 testing/e2e/src/lib/otel-capture.ts diff --git a/.changeset/otel-middleware.md b/.changeset/otel-middleware.md index 8469f9b53..aebbc8b6c 100644 --- a/.changeset/otel-middleware.md +++ b/.changeset/otel-middleware.md @@ -4,10 +4,10 @@ **OpenTelemetry middleware.** `otelMiddleware({ tracer, meter?, captureContent?, redact?, ... })` emits GenAI-semantic-convention traces and metrics for every `chat()` call. -- Root span per `chat()` + child span per agent-loop iteration + grandchild span per tool call. -- `gen_ai.client.operation.duration` (seconds) and `gen_ai.client.token.usage` (tokens) histograms, recorded per iteration, with the minimal set of low-cardinality attributes. -- `captureContent: true` attaches prompt/completion content as `gen_ai.{user,system,assistant,tool}.message` and `gen_ai.choice` span events, with optional `redact` applied before anything lands on a span. Multimodal parts become placeholder strings. -- Four extension points for custom attributes, names, span-options, and end-of-span callbacks. -- `@opentelemetry/api` is an optional peer dependency; users who don't import the middleware never load OTel. +- Root span per `chat()` + child span per agent-loop iteration (named `chat #`) + grandchild span per tool call. +- `gen_ai.client.operation.duration` (seconds) recorded **once per `chat()` call**; `gen_ai.client.token.usage` (tokens) recorded **per iteration** (one input + one output record). Metric attributes are kept low-cardinality — `gen_ai.response.model` and `gen_ai.response.id` are intentionally excluded. +- `captureContent: true` attaches prompt/completion content as `gen_ai.{user,system,assistant,tool}.message` and `gen_ai.choice` span events. Redactor failures fail closed to a `"[redaction_failed]"` sentinel — raw content never leaks. Assistant text is capped at `maxContentLength` (default 100 000). +- Four extension points for custom attributes, names, span-options, and end-of-span callbacks. Thrown callbacks are caught and logged to `console.warn` with a label so failures remain diagnosable. +- `@opentelemetry/api` is an optional peer dependency. The middleware is exported from the dedicated subpath `@tanstack/ai/middlewares/otel` so that importing `@tanstack/ai/middlewares` does not eagerly require OTel. See `docs/advanced/otel.md` for the full guide. diff --git a/docs/advanced/otel.md b/docs/advanced/otel.md index 360a832a0..5e885b8c4 100644 --- a/docs/advanced/otel.md +++ b/docs/advanced/otel.md @@ -20,15 +20,15 @@ The `otelMiddleware` factory wires TanStack AI into your existing OpenTelemetry Install `@opentelemetry/api` — it's an optional peer dependency of `@tanstack/ai`: -``` +```bash pnpm add @opentelemetry/api ``` -Wire up your OTel SDK however you already do (e.g. `@opentelemetry/sdk-node`). Then pass a `Tracer` (and optionally a `Meter`) into the middleware: +Wire up your OTel SDK however you already do (e.g. `@opentelemetry/sdk-node`). Then pass a `Tracer` (and optionally a `Meter`) into the middleware. The OTel middleware lives on its own subpath — importing it never affects users who don't need OTel: ```ts import { chat } from '@tanstack/ai' -import { otelMiddleware } from '@tanstack/ai/middlewares' +import { otelMiddleware } from '@tanstack/ai/middlewares/otel' import { openaiText } from '@tanstack/ai-openai/adapters' import { trace, metrics } from '@opentelemetry/api' @@ -49,14 +49,16 @@ const result = await chat({ ### Spans -``` -chat gpt-4o (root, kind: INTERNAL) -├── chat gpt-4o (iteration, kind: CLIENT) -│ └── execute_tool get_weather +```text +chat gpt-4o (root, kind: INTERNAL) +├── chat gpt-4o #0 (iteration, kind: CLIENT) +│ ├── execute_tool get_weather │ └── execute_tool get_time -└── chat gpt-4o (iteration, kind: CLIENT) +└── chat gpt-4o #1 (iteration, kind: CLIENT) ``` +Iteration spans are numbered (`#0`, `#1`, ...) so distinct iterations of the same chat are easy to pick apart in trace viewers. + ### Attribute reference | Level | Attribute | Value | @@ -81,12 +83,12 @@ chat gpt-4o (root, kind: INTERNAL) ### Metrics -Two GenAI-standard histograms, recorded per iteration: +Two GenAI-standard histograms: -- `gen_ai.client.operation.duration` (seconds) — duration of the `chat()` operation, including all agent-loop iterations and tool execution -- `gen_ai.client.token.usage` (tokens) — recorded twice per iteration (input + output) with `gen_ai.token.type` attribute +- `gen_ai.client.operation.duration` (seconds) — recorded **once per `chat()` call**, covering all agent-loop iterations and tool execution. On error or abort the record carries an `error.type` attribute (the thrown error's `name`, or `"cancelled"` for aborts). +- `gen_ai.client.token.usage` (tokens) — recorded **once per iteration** (two records: input and output), tagged with `gen_ai.token.type`. -`gen_ai.response.id` is deliberately excluded from metric attributes to keep cardinality low. +Both `gen_ai.response.id` and `gen_ai.response.model` are deliberately excluded from metric attributes to keep cardinality low (per-request custom-model names and request IDs would blow up the series set). ## Privacy: capturing prompts and completions @@ -104,8 +106,14 @@ otelMiddleware({ }) ``` +If `redact` throws, the middleware writes the literal sentinel `"[redaction_failed]"` into the span event and logs a warning — it never falls back to the raw content. This is the load-bearing invariant for users who ship traces to third-party backends: a broken redactor should shut off capture, not leak prompts. + +Accumulated assistant text (the `gen_ai.choice` event) is capped at `maxContentLength` characters (default `100 000`); longer completions are truncated with a trailing `"…"` marker. + Multimodal content (images, audio, video, documents) is represented as placeholder strings (`[image]`, `[audio]`, ...) to preserve message order without dumping binary data onto spans. Use `onSpanEnd` if you need richer multimodal capture. +Prompt/system/user message events fire from `onConfig` at the start of every iteration, which means the full conversation history (as the adapter will re-send it) is re-emitted on each iteration span. This mirrors what the provider actually sees on the wire. + ## Extension points All four extensions are optional. Each wraps user code in try/catch — a thrown callback becomes a log line, never a broken chat. diff --git a/packages/typescript/ai/package.json b/packages/typescript/ai/package.json index 213f84bf9..cb7942697 100644 --- a/packages/typescript/ai/package.json +++ b/packages/typescript/ai/package.json @@ -25,6 +25,10 @@ "types": "./dist/esm/middlewares/index.d.ts", "import": "./dist/esm/middlewares/index.js" }, + "./middlewares/otel": { + "types": "./dist/esm/middlewares/otel.d.ts", + "import": "./dist/esm/middlewares/otel.js" + }, "./adapter-internals": { "types": "./dist/esm/adapter-internals.d.ts", "import": "./dist/esm/adapter-internals.js" diff --git a/packages/typescript/ai/src/middlewares/index.ts b/packages/typescript/ai/src/middlewares/index.ts index 7d329b067..1470cdcff 100644 --- a/packages/typescript/ai/src/middlewares/index.ts +++ b/packages/typescript/ai/src/middlewares/index.ts @@ -12,9 +12,7 @@ export { type ContentFilteredInfo, } from './content-guard' -export { - otelMiddleware, - type OtelMiddlewareOptions, - type OtelSpanInfo, - type OtelSpanKind, -} from './otel' +// otelMiddleware is exported from the dedicated subpath +// `@tanstack/ai/middlewares/otel` so that importing the main middlewares barrel +// does not eagerly require `@opentelemetry/api` (which is an optional peer +// dependency). diff --git a/packages/typescript/ai/src/middlewares/otel.ts b/packages/typescript/ai/src/middlewares/otel.ts index 24d6bd800..7f6cf8fcb 100644 --- a/packages/typescript/ai/src/middlewares/otel.ts +++ b/packages/typescript/ai/src/middlewares/otel.ts @@ -6,6 +6,7 @@ import { } from '@opentelemetry/api' import type { AttributeValue, + Exception, Meter, Span, SpanOptions, @@ -16,24 +17,79 @@ import type { ChatMiddlewareContext, } from '../activities/chat/middleware/types' -export type OtelSpanKind = 'chat' | 'iteration' | 'tool' - -export interface OtelSpanInfo { - kind: TKind - ctx: ChatMiddlewareContext - toolName?: string - toolCallId?: string - iteration?: number -} +/** + * Scope (role) of an OTel span emitted by this middleware. + * + * - `chat` — the root span for a single `chat()` call + * - `iteration` — one per agent-loop iteration (one model call) + * - `tool` — one per tool execution inside an iteration + */ +export type OtelSpanScope = 'chat' | 'iteration' | 'tool' + +/** + * Alias retained for backwards compatibility. Prefer {@link OtelSpanScope}. + * + * @deprecated Use `OtelSpanScope` instead — the name shadows OTel's built-in + * `SpanKind` which is also imported by integrations of this middleware. + */ +export type OtelSpanKind = OtelSpanScope + +/** + * Span metadata passed to `spanNameFormatter`, `attributeEnricher`, + * `onBeforeSpanStart`, and `onSpanEnd`. Discriminated by `kind` so that + * tool-only fields narrow automatically inside callback bodies. + */ +export type OtelSpanInfo = + TScope extends 'chat' + ? { kind: 'chat'; ctx: ChatMiddlewareContext } + : TScope extends 'iteration' + ? { kind: 'iteration'; ctx: ChatMiddlewareContext; iteration: number } + : TScope extends 'tool' + ? { + kind: 'tool' + ctx: ChatMiddlewareContext + iteration: number + toolName: string + toolCallId: string + } + : never export interface OtelMiddlewareOptions { + /** OTel `Tracer` used to start root, iteration, and tool spans. */ tracer: Tracer + /** + * Optional OTel `Meter`. When provided, the middleware records + * `gen_ai.client.operation.duration` and `gen_ai.client.token.usage` + * histograms. Omit to disable metrics without disabling tracing. + */ meter?: Meter + /** + * When `true`, prompt and completion content is attached to iteration spans + * as `gen_ai.*.message` / `gen_ai.choice` events. Defaults to `false` so + * that PII never lands on a span by accident. + */ captureContent?: boolean + /** + * Invoked on every captured content string before it lands on a span. + * Return a redacted version. If this function throws, the middleware emits + * the literal sentinel `"[redaction_failed]"` instead of the original text + * — it never falls back to raw content. + */ redact?: (text: string) => string + /** + * Maximum characters kept in the per-iteration assistant text buffer used + * to emit `gen_ai.choice` events. Extra characters are truncated with a + * trailing `"…"` marker. Defaults to 100 000. Set to `0` to disable the + * cap. Exporters typically truncate long attribute values anyway. + */ + maxContentLength?: number + /** Override the default span name for each `kind`. */ spanNameFormatter?: (info: OtelSpanInfo) => string + /** Add extra attributes to each span. */ attributeEnricher?: (info: OtelSpanInfo) => Record + /** Mutate `SpanOptions` immediately before `tracer.startSpan(...)`. */ onBeforeSpanStart?: (info: OtelSpanInfo, options: SpanOptions) => SpanOptions + /** Fires just before every `span.end()`. */ onSpanEnd?: (info: OtelSpanInfo, span: Span) => void } @@ -43,11 +99,15 @@ interface RequestState { toolSpans: Map iterationCount: number assistantTextBuffer: string + assistantTextBufferTruncated: boolean startTime: number } const stateByCtx = new WeakMap() +const DEFAULT_MAX_CONTENT_LENGTH = 100_000 +const REDACTION_FAILED_SENTINEL = '[redaction_failed]' + function serializeContent(content: unknown): string { if (typeof content === 'string') return content if (!Array.isArray(content)) return '' @@ -99,12 +159,33 @@ function messageEventName(role: string): string { } } +function errorMessage(err: unknown): string | undefined { + if (err instanceof Error) return err.message + if (typeof err === 'string') return err + if (err && typeof err === 'object' && 'message' in err) { + const m = (err as { message?: unknown }).message + if (typeof m === 'string') return m + } + return undefined +} + +function errorTypeName(err: unknown): string { + if (err instanceof Error) return err.name || 'Error' + if (err && typeof err === 'object' && 'name' in err) { + const n = (err as { name?: unknown }).name + if (typeof n === 'string') return n + } + return 'Error' +} + function safeCall(label: string, fn: () => T): T | undefined { try { return fn() } catch (err) { - void err - void label + // Keep middleware non-fatal, but surface callback failures so that broken + // extension points (attributeEnricher, spanNameFormatter, onSpanEnd, ...) + // are observable. Matches the guarantee documented in docs/advanced/otel.md. + console.warn(`[otelMiddleware] ${label} failed`, err) return undefined } } @@ -115,6 +196,7 @@ export function otelMiddleware(options: OtelMiddlewareOptions): ChatMiddleware { meter, captureContent = false, redact = (s) => s, + maxContentLength = DEFAULT_MAX_CONTENT_LENGTH, spanNameFormatter, attributeEnricher, onBeforeSpanStart, @@ -132,6 +214,54 @@ export function otelMiddleware(options: OtelMiddlewareOptions): ChatMiddleware { description: 'GenAI client token usage', unit: '{token}', }) + + // Redact user content, failing closed to a sentinel string instead of ever + // letting raw text through. Callers that pass `captureContent: true` with a + // third-party PII redactor depend on this invariant. + const redactContent = (text: string): string => { + try { + return redact(text) + } catch (err) { + console.warn('[otelMiddleware] otel.redact failed', err) + return REDACTION_FAILED_SENTINEL + } + } + + const appendAssistantText = (state: RequestState, delta: string): void => { + if (maxContentLength > 0) { + if (state.assistantTextBufferTruncated) return + const remaining = maxContentLength - state.assistantTextBuffer.length + if (remaining <= 0) { + state.assistantTextBufferTruncated = true + state.assistantTextBuffer += '…' + return + } + if (delta.length > remaining) { + state.assistantTextBuffer += delta.slice(0, remaining) + '…' + state.assistantTextBufferTruncated = true + return + } + } + state.assistantTextBuffer += delta + } + + const closeIterationSpan = ( + state: RequestState, + ctx: ChatMiddlewareContext, + ): void => { + if (!state.currentIterationSpan) return + const span = state.currentIterationSpan + const iteration = state.iterationCount - 1 + safeCall('otel.onSpanEnd', () => + onSpanEnd?.( + { kind: 'iteration', ctx, iteration } as OtelSpanInfo<'iteration'>, + span, + ), + ) + span.end() + state.currentIterationSpan = null + } + return { name: 'otel', @@ -166,6 +296,7 @@ export function otelMiddleware(options: OtelMiddlewareOptions): ChatMiddleware { toolSpans: new Map(), iterationCount: 0, assistantTextBuffer: '', + assistantTextBufferTruncated: false, startTime: Date.now(), }) }) @@ -177,18 +308,10 @@ export function otelMiddleware(options: OtelMiddlewareOptions): ChatMiddleware { const state = stateByCtx.get(ctx) if (!state) return - // Close any previously open iteration span (defensive — shouldn't normally be open - // here because onChunk(RUN_FINISHED) closes it, but guard against adapter quirks). - if (state.currentIterationSpan) { - safeCall('otel.onSpanEnd', () => - onSpanEnd?.( - { kind: 'iteration', ctx, iteration: state.iterationCount - 1 }, - state.currentIterationSpan!, - ), - ) - state.currentIterationSpan.end() - state.currentIterationSpan = null - } + // The previous iteration's span stays open through tool execution and + // onUsage so that tool spans nest under it and token attributes land + // on it. Close it here, just before opening the next iteration. + closeIterationSpan(state, ctx) const info: OtelSpanInfo<'iteration'> = { kind: 'iteration', @@ -197,7 +320,7 @@ export function otelMiddleware(options: OtelMiddlewareOptions): ChatMiddleware { } const name = safeCall('otel.spanNameFormatter', () => spanNameFormatter?.(info)) ?? - `chat ${ctx.model}` + `chat ${ctx.model} #${ctx.iteration}` const baseAttrs: Record = { 'gen_ai.system': ctx.provider, @@ -221,17 +344,18 @@ export function otelMiddleware(options: OtelMiddlewareOptions): ChatMiddleware { onBeforeSpanStart?.(info, baseOptions), ) ?? baseOptions - let iterSpan!: Span - otelContext.with( - otelTrace.setSpan(otelContext.active(), state.rootSpan), - () => { - iterSpan = tracer.startSpan(name, spanOptions) - }, + const parentCtx = otelTrace.setSpan( + otelContext.active(), + state.rootSpan, ) - // Fake-tracer test visibility: explicit parent pointer. In real OTel this is a - // no-op field write; the actual parent-child relationship is established via the - // active context above. - ;(iterSpan as unknown as { parent?: Span }).parent = state.rootSpan + let iterSpan!: Span + otelContext.with(parentCtx, () => { + // Pass the parent context explicitly as the 3rd arg — this is a + // real-OTel-compatible way to ensure the span is parented to + // `rootSpan` even when the host app has not registered a context + // manager (e.g. in tests or minimal setups). + iterSpan = tracer.startSpan(name, spanOptions, parentCtx) + }) const enriched = safeCall('otel.attributeEnricher', () => attributeEnricher?.(info), @@ -239,18 +363,20 @@ export function otelMiddleware(options: OtelMiddlewareOptions): ChatMiddleware { if (enriched) iterSpan.setAttributes(enriched) state.currentIterationSpan = iterSpan + state.assistantTextBuffer = '' + state.assistantTextBufferTruncated = false if (captureContent) { for (const sys of config.systemPrompts) { iterSpan.addEvent('gen_ai.system.message', { - content: safeCall('otel.redact', () => redact(sys)) ?? sys, + content: redactContent(sys), }) } for (const m of config.messages) { const body = serializeContent(m.content) if (body.length === 0) continue iterSpan.addEvent(messageEventName(m.role), { - content: safeCall('otel.redact', () => redact(body)) ?? body, + content: redactContent(body), }) } } @@ -266,12 +392,12 @@ export function otelMiddleware(options: OtelMiddlewareOptions): ChatMiddleware { if (!state) return if (captureContent && chunk.type === 'TEXT_MESSAGE_CONTENT') { - state.assistantTextBuffer += chunk.delta + appendAssistantText(state, chunk.delta) } if (chunk.type !== 'RUN_FINISHED') return - if (!state.currentIterationSpan) return const span = state.currentIterationSpan + if (!span) return if (chunk.finishReason) { span.setAttribute('gen_ai.response.finish_reasons', [ @@ -280,24 +406,44 @@ export function otelMiddleware(options: OtelMiddlewareOptions): ChatMiddleware { } if (chunk.model) span.setAttribute('gen_ai.response.model', chunk.model) + // Capture usage attributes and token-histogram metrics directly from + // the chunk. `onUsage` can still fire later (per-provider quirks); its + // implementation is additive to what's recorded here so nothing is + // lost regardless of ordering. + if (chunk.usage) { + span.setAttributes({ + 'gen_ai.usage.input_tokens': chunk.usage.promptTokens, + 'gen_ai.usage.output_tokens': chunk.usage.completionTokens, + }) + if (tokenHistogram) { + const metricAttrs = { + 'gen_ai.system': ctx.provider, + 'gen_ai.operation.name': 'chat', + 'gen_ai.request.model': ctx.model, + } + tokenHistogram.record(chunk.usage.promptTokens, { + ...metricAttrs, + 'gen_ai.token.type': 'input', + }) + tokenHistogram.record(chunk.usage.completionTokens, { + ...metricAttrs, + 'gen_ai.token.type': 'output', + }) + } + } + if (captureContent && state.assistantTextBuffer.length > 0) { span.addEvent('gen_ai.choice', { - content: - safeCall('otel.redact', () => - redact(state.assistantTextBuffer), - ) ?? state.assistantTextBuffer, + content: redactContent(state.assistantTextBuffer), }) state.assistantTextBuffer = '' + state.assistantTextBufferTruncated = false } - safeCall('otel.onSpanEnd', () => - onSpanEnd?.( - { kind: 'iteration', ctx, iteration: state.iterationCount - 1 }, - span, - ), - ) - span.end() - state.currentIterationSpan = null + // Intentionally leave the iteration span open: tool spans started + // after `RUN_FINISHED` (tool_calls finishReason) must nest under it, + // and `onUsage` may still fire. The span is closed in `onConfig` when + // the next iteration starts, or in `onFinish` / `onError` / `onAbort`. }) return undefined }, @@ -305,13 +451,11 @@ export function otelMiddleware(options: OtelMiddlewareOptions): ChatMiddleware { onUsage(ctx, usage) { safeCall('otel.onUsage', () => { const state = stateByCtx.get(ctx) - if (!state || !state.currentIterationSpan) return - - state.currentIterationSpan.setAttributes({ - 'gen_ai.usage.input_tokens': usage.promptTokens, - 'gen_ai.usage.output_tokens': usage.completionTokens, - }) + if (!state) return + // Always record the token histogram — metrics don't depend on having + // an iteration span, and skipping here would drop metric data if an + // adapter emits `onUsage` outside the iteration window. if (tokenHistogram) { const metricAttrs = { 'gen_ai.system': ctx.provider, @@ -327,13 +471,20 @@ export function otelMiddleware(options: OtelMiddlewareOptions): ChatMiddleware { 'gen_ai.token.type': 'output', }) } + + const span = state.currentIterationSpan ?? state.rootSpan + span.setAttributes({ + 'gen_ai.usage.input_tokens': usage.promptTokens, + 'gen_ai.usage.output_tokens': usage.completionTokens, + }) }) }, onBeforeToolCall(ctx, hookCtx) { safeCall('otel.onBeforeToolCall', () => { const state = stateByCtx.get(ctx) - if (!state || !state.currentIterationSpan) return + if (!state) return + const parent = state.currentIterationSpan ?? state.rootSpan const info: OtelSpanInfo<'tool'> = { kind: 'tool', @@ -360,15 +511,11 @@ export function otelMiddleware(options: OtelMiddlewareOptions): ChatMiddleware { onBeforeSpanStart?.(info, baseOptions), ) ?? baseOptions + const parentCtx = otelTrace.setSpan(otelContext.active(), parent) let toolSpan!: Span - otelContext.with( - otelTrace.setSpan(otelContext.active(), state.currentIterationSpan), - () => { - toolSpan = tracer.startSpan(name, spanOptions) - }, - ) - ;(toolSpan as unknown as { parent?: Span }).parent = - state.currentIterationSpan + otelContext.with(parentCtx, () => { + toolSpan = tracer.startSpan(name, spanOptions, parentCtx) + }) const enriched = safeCall('otel.attributeEnricher', () => attributeEnricher?.(info), @@ -395,10 +542,10 @@ export function otelMiddleware(options: OtelMiddlewareOptions): ChatMiddleware { toolSpan.setAttribute('tanstack.ai.tool.outcome', outcome) if (!info.ok && info.error !== undefined) { - toolSpan.recordException(info.error as Error) + toolSpan.recordException(info.error as Exception) toolSpan.setStatus({ code: SpanStatusCode.ERROR, - message: (info.error as Error).message, + message: errorMessage(info.error), }) } @@ -408,7 +555,7 @@ export function otelMiddleware(options: OtelMiddlewareOptions): ChatMiddleware { ? info.result : JSON.stringify(info.result ?? null) state.currentIterationSpan.addEvent('gen_ai.tool.message', { - content: safeCall('otel.redact', () => redact(body)) ?? body, + content: redactContent(body), tool_call_id: info.toolCallId, }) } @@ -421,7 +568,7 @@ export function otelMiddleware(options: OtelMiddlewareOptions): ChatMiddleware { toolName: info.toolName, toolCallId: info.toolCallId, iteration: state.iterationCount - 1, - }, + } as OtelSpanInfo<'tool'>, toolSpan, ), ) @@ -435,21 +582,23 @@ export function otelMiddleware(options: OtelMiddlewareOptions): ChatMiddleware { const state = stateByCtx.get(ctx) if (!state) return - const errType = - (info.error as { name?: string } | undefined)?.name ?? 'Error' - const message = (info.error as { message?: string } | undefined) - ?.message + const errType = errorTypeName(info.error) + const message = errorMessage(info.error) + const exception = info.error as Exception - // Close iteration span (if open) with ERROR. if (state.currentIterationSpan) { - state.currentIterationSpan.recordException(info.error as Error) + state.currentIterationSpan.recordException(exception) state.currentIterationSpan.setStatus({ code: SpanStatusCode.ERROR, message, }) safeCall('otel.onSpanEnd', () => onSpanEnd?.( - { kind: 'iteration', ctx, iteration: state.iterationCount - 1 }, + { + kind: 'iteration', + ctx, + iteration: state.iterationCount - 1, + } as OtelSpanInfo<'iteration'>, state.currentIterationSpan!, ), ) @@ -457,10 +606,9 @@ export function otelMiddleware(options: OtelMiddlewareOptions): ChatMiddleware { state.currentIterationSpan = null } - // Close any open tool spans as errored. for (const [id, entry] of state.toolSpans) { const { span, toolName } = entry - span.recordException(info.error as Error) + span.recordException(exception) span.setStatus({ code: SpanStatusCode.ERROR, message }) safeCall('otel.onSpanEnd', () => onSpanEnd?.( @@ -478,7 +626,7 @@ export function otelMiddleware(options: OtelMiddlewareOptions): ChatMiddleware { state.toolSpans.delete(id) } - state.rootSpan.recordException(info.error as Error) + state.rootSpan.recordException(exception) state.rootSpan.setStatus({ code: SpanStatusCode.ERROR, message }) if (durationHistogram) { @@ -486,7 +634,6 @@ export function otelMiddleware(options: OtelMiddlewareOptions): ChatMiddleware { 'gen_ai.system': ctx.provider, 'gen_ai.operation.name': 'chat', 'gen_ai.request.model': ctx.model, - 'gen_ai.response.model': ctx.model, 'error.type': errType, }) } @@ -499,13 +646,16 @@ export function otelMiddleware(options: OtelMiddlewareOptions): ChatMiddleware { }) }, - onAbort(ctx, _info) { + onAbort(ctx, info) { safeCall('otel.onAbort', () => { const state = stateByCtx.get(ctx) if (!state) return - const closeCancelled = (span: Span) => { - span.setAttribute('gen_ai.completion.reason', 'cancelled') + const closeCancelled = (span: Span): void => { + // `gen_ai.completion.reason` is not part of the GenAI semconv; use a + // TanStack-namespaced attribute so downstream exporters don't treat + // it as standard. The span status still carries the error code. + span.setAttribute('tanstack.ai.completion.reason', 'cancelled') span.setStatus({ code: SpanStatusCode.ERROR, message: 'cancelled' }) } @@ -513,7 +663,11 @@ export function otelMiddleware(options: OtelMiddlewareOptions): ChatMiddleware { closeCancelled(state.currentIterationSpan) safeCall('otel.onSpanEnd', () => onSpanEnd?.( - { kind: 'iteration', ctx, iteration: state.iterationCount - 1 }, + { + kind: 'iteration', + ctx, + iteration: state.iterationCount - 1, + } as OtelSpanInfo<'iteration'>, state.currentIterationSpan!, ), ) @@ -539,6 +693,16 @@ export function otelMiddleware(options: OtelMiddlewareOptions): ChatMiddleware { state.toolSpans.delete(id) } closeCancelled(state.rootSpan) + + if (durationHistogram) { + durationHistogram.record(info.duration / 1000, { + 'gen_ai.system': ctx.provider, + 'gen_ai.operation.name': 'chat', + 'gen_ai.request.model': ctx.model, + 'error.type': 'cancelled', + }) + } + safeCall('otel.onSpanEnd', () => onSpanEnd?.({ kind: 'chat', ctx }, state.rootSpan), ) @@ -552,24 +716,37 @@ export function otelMiddleware(options: OtelMiddlewareOptions): ChatMiddleware { const state = stateByCtx.get(ctx) if (!state) return - // Close a dangling iteration span if RUN_FINISHED never arrived (defensive). - if (state.currentIterationSpan) { + // Close any tool spans that never received `onAfterToolCall` (adapter + // quirk). Done before the iteration span so the hierarchy is closed + // in depth-first order. + for (const [id, entry] of state.toolSpans) { + const { span, toolName } = entry + span.setAttribute('tanstack.ai.tool.outcome', 'unknown') safeCall('otel.onSpanEnd', () => onSpanEnd?.( - { kind: 'iteration', ctx, iteration: state.iterationCount - 1 }, - state.currentIterationSpan!, + { + kind: 'tool', + ctx, + toolCallId: id, + toolName, + iteration: state.iterationCount - 1, + } as OtelSpanInfo<'tool'>, + span, ), ) - state.currentIterationSpan.end() - state.currentIterationSpan = null + span.end() + state.toolSpans.delete(id) } + // The final iteration's span is still open because we keep it open + // through tool execution and `onUsage`. Close it now. + closeIterationSpan(state, ctx) + if (durationHistogram) { durationHistogram.record(info.duration / 1000, { 'gen_ai.system': ctx.provider, 'gen_ai.operation.name': 'chat', 'gen_ai.request.model': ctx.model, - 'gen_ai.response.model': ctx.model, }) } diff --git a/packages/typescript/ai/tests/middlewares/fake-otel.ts b/packages/typescript/ai/tests/middlewares/fake-otel.ts index 793a589d5..a7efaf223 100644 --- a/packages/typescript/ai/tests/middlewares/fake-otel.ts +++ b/packages/typescript/ai/tests/middlewares/fake-otel.ts @@ -2,6 +2,8 @@ import type { Attributes, AttributeValue, Context, + Histogram, + MetricOptions, Meter, Span, SpanContext, @@ -9,9 +11,9 @@ import type { SpanStatus, TimeInput, Tracer, - Histogram, } from '@opentelemetry/api' -import { SpanStatusCode } from '@opentelemetry/api' +import { SpanStatusCode, trace as otelTrace } from '@opentelemetry/api' +import type { ChatMiddlewareContext } from '../../src/activities/chat/middleware/types' import type { ToolCall } from '../../src/types' export interface RecordedEvent { @@ -31,8 +33,8 @@ export interface FakeSpan extends Span { startTimeMs: number endTimeMs: number | null attributes: Record - events: RecordedEvent[] - exceptions: RecordedException[] + events: Array + exceptions: Array status: SpanStatus ended: boolean } @@ -41,17 +43,18 @@ export interface HistogramRecord { name: string value: number attributes?: Attributes + options?: MetricOptions } export interface FakeMeter { meter: Meter - records: HistogramRecord[] + records: Array } export interface FakeTracer { tracer: Tracer - spans: FakeSpan[] - activeStack: FakeSpan[] + spans: Array + activeStack: Array } function makeSpan( @@ -127,12 +130,22 @@ function makeSpan( } export function createFakeTracer(): FakeTracer { - const spans: FakeSpan[] = [] - const activeStack: FakeSpan[] = [] + const spans: Array = [] + const activeStack: Array = [] const tracer: Tracer = { - startSpan(name, options = {}, _ctx?: Context) { - const parent = activeStack[activeStack.length - 1] ?? null + startSpan(name, options = {}, ctx?: Context) { + // Resolve parent in this order: + // 1. Explicit `ctx` argument (how `otelMiddleware` passes the parent) + // 2. Fallback to the activeStack (for `startActiveSpan` callers) + let parent: FakeSpan | null = null + if (ctx) { + const fromCtx = otelTrace.getSpan(ctx) + if (fromCtx && (fromCtx as FakeSpan).startTimeMs !== undefined) { + parent = fromCtx as FakeSpan + } + } + if (!parent) parent = activeStack[activeStack.length - 1] ?? null const span = makeSpan(name, options, parent) spans.push(span) return span @@ -164,13 +177,13 @@ export function createFakeTracer(): FakeTracer { } export function createFakeMeter(): FakeMeter { - const records: HistogramRecord[] = [] + const records: Array = [] const meter: Meter = { - createHistogram(name: string): Histogram { + createHistogram(name: string, options?: MetricOptions): Histogram { return { record(value: number, attributes?: Attributes) { - records.push({ name, value, attributes }) + records.push({ name, value, attributes, options }) }, } }, @@ -226,10 +239,8 @@ export function makeToolCall( * otel middleware reads need realistic values; others can be placeholders. */ export function makeCtx( - overrides: Partial< - import('../../src/activities/chat/middleware/types').ChatMiddlewareContext - > = {}, -) { + overrides: Partial = {}, +): ChatMiddlewareContext { const base = { requestId: 'req-1', streamId: 'stream-1', @@ -256,5 +267,5 @@ export function makeCtx( return { ...base, ...overrides, - } as import('../../src/activities/chat/middleware/types').ChatMiddlewareContext + } as ChatMiddlewareContext } diff --git a/packages/typescript/ai/tests/middlewares/otel.test.ts b/packages/typescript/ai/tests/middlewares/otel.test.ts index c31766f09..3679c9c31 100644 --- a/packages/typescript/ai/tests/middlewares/otel.test.ts +++ b/packages/typescript/ai/tests/middlewares/otel.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect } from 'vitest' +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' import { SpanKind, SpanStatusCode } from '@opentelemetry/api' import { otelMiddleware } from '../../src/middlewares/otel' import { @@ -15,10 +15,6 @@ import type { } from '../../src/activities/chat/middleware/types' import { ev } from '../test-utils' -// --------------------------------------------------------------------------- -// File-local helpers -// --------------------------------------------------------------------------- - async function runToIterationStart( mw: ChatMiddleware, ctx: ChatMiddlewareContext, @@ -64,7 +60,7 @@ describe('otelMiddleware — root span lifecycle', () => { }) describe('otelMiddleware — iteration span lifecycle', () => { - it('opens an iteration span on onConfig(beforeModel) and closes it on RUN_FINISHED chunk', async () => { + it('opens an iteration span on onConfig(beforeModel) and keeps it open through RUN_FINISHED', async () => { const { tracer, spans } = createFakeTracer() const mw = otelMiddleware({ tracer }) const ctx = makeCtx() @@ -80,34 +76,41 @@ describe('otelMiddleware — iteration span lifecycle', () => { const [rootSpan, iterSpan] = spans expect(spans).toHaveLength(2) expect(iterSpan!.parent).toBe(rootSpan) - expect(iterSpan!.name).toBe('chat gpt-4o') + expect(iterSpan!.name).toBe('chat gpt-4o #0') expect(iterSpan!.kind).toBe(SpanKind.CLIENT) expect(iterSpan!.ended).toBe(false) - // model field needed so gen_ai.response.model is set; spread ev.runFinished for the rest await mw.onChunk?.(ctx, { ...ev.runFinished('stop'), model: 'gpt-4o' }) - expect(iterSpan!.ended).toBe(true) + // The iteration span stays open across RUN_FINISHED so tool spans can + // nest under it and onUsage still has a target. It closes on onFinish. + expect(iterSpan!.ended).toBe(false) expect(iterSpan!.attributes['gen_ai.response.finish_reasons']).toEqual([ 'stop', ]) + expect(iterSpan!.attributes['gen_ai.response.model']).toBe('gpt-4o') await mw.onFinish?.(ctx, { finishReason: 'stop', duration: 10, content: '', }) + expect(iterSpan!.ended).toBe(true) expect(rootSpan!.ended).toBe(true) }) - it('opens a fresh iteration span for each onConfig(beforeModel)', async () => { + it('opens a fresh iteration span for each onConfig(beforeModel) and closes the previous one', async () => { const { tracer, spans } = createFakeTracer() const mw = otelMiddleware({ tracer }) const ctx = makeCtx() await runToIterationStart(mw, ctx) await mw.onChunk?.(ctx, ev.runFinished('tool_calls')) + // First iteration span still open at this point. + expect(spans[1]!.ended).toBe(false) ctx.iteration = 1 await mw.onConfig?.(ctx, { messages: [], systemPrompts: [], tools: [] }) + // Opening the 2nd iteration closes the 1st. + expect(spans[1]!.ended).toBe(true) await mw.onChunk?.(ctx, ev.runFinished('stop')) await mw.onFinish?.(ctx, { finishReason: 'stop', @@ -115,25 +118,26 @@ describe('otelMiddleware — iteration span lifecycle', () => { content: '', }) - // 1 root + 2 iteration spans expect(spans).toHaveLength(3) expect(spans[1]!.ended).toBe(true) expect(spans[2]!.ended).toBe(true) + expect(spans[1]!.name).toBe('chat gpt-4o #0') + expect(spans[2]!.name).toBe('chat gpt-4o #1') }) }) describe('otelMiddleware — token histogram', () => { - it('records input and output token histograms on onUsage', async () => { - const { tracer } = createFakeTracer() + it('records input and output token histograms from RUN_FINISHED usage', async () => { + const { tracer, spans } = createFakeTracer() const { meter, records } = createFakeMeter() const mw = otelMiddleware({ tracer, meter }) const ctx = makeCtx() await runToIterationStart(mw, ctx) - await mw.onUsage?.(ctx, { - promptTokens: 100, - completionTokens: 50, - totalTokens: 150, + await mw.onChunk?.(ctx, { + ...ev.runFinished('stop'), + model: 'gpt-4o', + usage: { promptTokens: 100, completionTokens: 50, totalTokens: 150 }, }) const tokenRecords = records.filter( @@ -148,27 +152,60 @@ describe('otelMiddleware — token histogram', () => { tokenRecords.find((r) => r.attributes!['gen_ai.token.type'] === 'output')! .value, ).toBe(50) - // Cardinality guard: response.id must NOT appear on metric attributes. for (const r of tokenRecords) { expect(r.attributes!['gen_ai.response.id']).toBeUndefined() } + expect(spans[1]!.attributes['gen_ai.usage.input_tokens']).toBe(100) + expect(spans[1]!.attributes['gen_ai.usage.output_tokens']).toBe(50) }) - it('sets gen_ai.usage.* attributes on the iteration span', async () => { + it('records token histograms from onUsage in production hook order (after RUN_FINISHED)', async () => { const { tracer, spans } = createFakeTracer() - const mw = otelMiddleware({ tracer }) + const { meter, records } = createFakeMeter() + const mw = otelMiddleware({ tracer, meter }) const ctx = makeCtx() + // Production order: onChunk(RUN_FINISHED) fires first, then runOnUsage. + // This test is the regression guard for the "onUsage no-op" bug. await runToIterationStart(mw, ctx) + await mw.onChunk?.(ctx, { ...ev.runFinished('stop'), model: 'gpt-4o' }) await mw.onUsage?.(ctx, { - promptTokens: 100, - completionTokens: 50, - totalTokens: 150, + promptTokens: 42, + completionTokens: 17, + totalTokens: 59, }) - expect(spans[1]!.attributes['gen_ai.usage.input_tokens']).toBe(100) - expect(spans[1]!.attributes['gen_ai.usage.output_tokens']).toBe(50) + const tokenRecords = records.filter( + (r) => r.name === 'gen_ai.client.token.usage', + ) + expect(tokenRecords).toHaveLength(2) + expect( + tokenRecords.find((r) => r.attributes!['gen_ai.token.type'] === 'input')! + .value, + ).toBe(42) + // Iteration span is still open, so usage attrs land on it. + expect(spans[1]!.attributes['gen_ai.usage.input_tokens']).toBe(42) + expect(spans[1]!.attributes['gen_ai.usage.output_tokens']).toBe(17) + }) + + it('records token histogram even if onUsage fires after the iteration span is gone', async () => { + const { tracer, spans } = createFakeTracer() + const { meter, records } = createFakeMeter() + const mw = otelMiddleware({ tracer, meter }) + const ctx = makeCtx() + + await mw.onStart?.(ctx) + await mw.onUsage?.(ctx, { + promptTokens: 5, + completionTokens: 3, + totalTokens: 8, + }) + expect( + records.filter((r) => r.name === 'gen_ai.client.token.usage'), + ).toHaveLength(2) + // Falls back to the root span for attribute assignment. + expect(spans[0]!.attributes['gen_ai.usage.input_tokens']).toBe(5) }) it('skips metrics when meter is not provided', async () => { @@ -199,7 +236,6 @@ describe('otelMiddleware — duration histogram and rollup', () => { completionTokens: 50, totalTokens: 150, }) - // model field needed for gen_ai.response.model on duration histogram attributes await mw.onChunk?.(ctx, { ...ev.runFinished('stop'), model: 'gpt-4o' }) await mw.onFinish?.(ctx, { finishReason: 'stop', @@ -213,10 +249,18 @@ describe('otelMiddleware — duration histogram and rollup', () => { ) expect(durationRecords).toHaveLength(1) expect(durationRecords[0]!.value).toBe(1.25) - expect(durationRecords[0]!.attributes!['gen_ai.response.model']).toBe( - 'gpt-4o', - ) + // `gen_ai.response.model` is intentionally absent from the duration + // histogram attrs (high-cardinality for per-request custom models). + expect( + durationRecords[0]!.attributes!['gen_ai.response.model'], + ).toBeUndefined() expect(durationRecords[0]!.attributes!['error.type']).toBeUndefined() + // Histogram options are forwarded to the meter (unit/description). + expect(durationRecords[0]!.options?.unit).toBe('s') + const tokenRecord = records.find( + (r) => r.name === 'gen_ai.client.token.usage', + ) + expect(tokenRecord?.options?.unit).toBe('{token}') const root = spans[0]! expect(root.attributes['gen_ai.usage.input_tokens']).toBe(100) @@ -227,14 +271,19 @@ describe('otelMiddleware — duration histogram and rollup', () => { }) describe('otelMiddleware — tool spans', () => { - it('creates a tool span as child of the iteration span', async () => { + it('creates a tool span as child of the iteration span (including after RUN_FINISHED)', async () => { const { tracer, spans } = createFakeTracer() const mw = otelMiddleware({ tracer }) const ctx = makeCtx({ hasTools: true, toolNames: ['get_weather'] }) await runToIterationStart(mw, ctx) - const iterSpan = spans[1]! + // Real flow: RUN_FINISHED(tool_calls) fires before onBeforeToolCall. + // Tool span must still nest under the iteration span. + await mw.onChunk?.(ctx, { + ...ev.runFinished('tool_calls'), + model: 'gpt-4o', + }) await mw.onBeforeToolCall?.(ctx, { toolCall: makeToolCall({ id: 'tc-1', function: { name: 'get_weather' } }), tool: undefined, @@ -295,6 +344,35 @@ describe('otelMiddleware — tool spans', () => { expect((toolSpan.exceptions[0]!.exception as Error).message).toBe('boom') expect(toolSpan.status.code).toBe(SpanStatusCode.ERROR) }) + + it('preserves non-Error exception shapes through recordException', async () => { + const { tracer, spans } = createFakeTracer() + const mw = otelMiddleware({ tracer }) + const ctx = makeCtx({ hasTools: true }) + + await runToIterationStart(mw, ctx) + await mw.onBeforeToolCall?.(ctx, { + toolCall: makeToolCall({ id: 'tc-obj', function: { name: 'x' } }), + tool: undefined, + args: {}, + toolName: 'x', + toolCallId: 'tc-obj', + }) + const plainError = { code: 'E_SOMETHING', message: 'plain-object error' } + await mw.onAfterToolCall?.(ctx, { + toolCall: makeToolCall({ id: 'tc-obj' }), + tool: undefined, + toolName: 'x', + toolCallId: 'tc-obj', + ok: false, + duration: 1, + error: plainError, + }) + + const toolSpan = spans[2]! + expect(toolSpan.exceptions[0]!.exception).toBe(plainError) + expect(toolSpan.status.message).toBe('plain-object error') + }) }) describe('otelMiddleware — captureContent', () => { @@ -346,8 +424,6 @@ describe('otelMiddleware — captureContent', () => { const mw = otelMiddleware({ tracer, captureContent: true }) const ctx = makeCtx() - // serializeContent inspects .type at runtime — the source.type shape doesn't - // matter for this test; only the top-level 'type: image' is checked. await runToIterationStart(mw, ctx, { messages: [ { @@ -365,6 +441,53 @@ describe('otelMiddleware — captureContent', () => { )! expect(userEvt.attributes!['content']).toBe('look at this [image]') }) + + it('emits redaction sentinel and never raw content when redact throws', async () => { + const { tracer, spans } = createFakeTracer() + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const mw = otelMiddleware({ + tracer, + captureContent: true, + redact: () => { + throw new Error('redactor blew up') + }, + }) + const ctx = makeCtx() + + await runToIterationStart(mw, ctx, { + messages: [{ role: 'user', content: 'secret-ssn-123-45-6789' }], + systemPrompts: ['also-secret'], + }) + + const iter = spans[1]! + const userEvt = iter.events.find((e) => e.name === 'gen_ai.user.message')! + const sysEvt = iter.events.find((e) => e.name === 'gen_ai.system.message')! + expect(userEvt.attributes!['content']).toBe('[redaction_failed]') + expect(sysEvt.attributes!['content']).toBe('[redaction_failed]') + // The redactor failure is surfaced via console.warn. + expect(warn).toHaveBeenCalled() + warn.mockRestore() + }) + + it('caps assistantTextBuffer at maxContentLength', async () => { + const { tracer, spans } = createFakeTracer() + const mw = otelMiddleware({ + tracer, + captureContent: true, + maxContentLength: 10, + }) + const ctx = makeCtx() + + await runToIterationStart(mw, ctx) + await mw.onChunk?.(ctx, ev.textContent('abcdefghij')) + // Over the cap — should truncate and stop accumulating. + await mw.onChunk?.(ctx, ev.textContent('klmnopqrstuvwxyz')) + await mw.onChunk?.(ctx, { ...ev.runFinished('stop'), model: 'gpt-4o' }) + + const iter = spans[1]! + const choice = iter.events.find((e) => e.name === 'gen_ai.choice')! + expect(choice.attributes!['content']).toBe('abcdefghij…') + }) }) describe('otelMiddleware — error and abort paths', () => { @@ -394,20 +517,29 @@ describe('otelMiddleware — error and abort paths', () => { expect(durationRecords[0]!.attributes!['error.type']).toBe('RateLimitError') }) - it('onAbort sets ERROR status and cancelled reason', async () => { + it('onAbort sets ERROR status, marks cancellation, and records duration', async () => { const { tracer, spans } = createFakeTracer() - const mw = otelMiddleware({ tracer }) + const { meter, records } = createFakeMeter() + const mw = otelMiddleware({ tracer, meter }) const ctx = makeCtx() await runToIterationStart(mw, ctx) await mw.onAbort?.(ctx, { reason: 'user stop', duration: 80 }) expect(spans[0]!.status.code).toBe(SpanStatusCode.ERROR) - expect(spans[0]!.attributes['gen_ai.completion.reason']).toBe('cancelled') + expect(spans[0]!.attributes['tanstack.ai.completion.reason']).toBe( + 'cancelled', + ) expect(spans[0]!.ended).toBe(true) + const durationRecords = records.filter( + (r) => r.name === 'gen_ai.client.operation.duration', + ) + expect(durationRecords).toHaveLength(1) + expect(durationRecords[0]!.value).toBeCloseTo(0.08) + expect(durationRecords[0]!.attributes!['error.type']).toBe('cancelled') }) - it('onError fires onSpanEnd for open tool spans before ending them', async () => { + it('onError fires onSpanEnd for iteration, open tool spans, then root — in depth-first order', async () => { const { tracer } = createFakeTracer() const seen: Array<{ kind: string @@ -420,8 +552,8 @@ describe('otelMiddleware — error and abort paths', () => { onSpanEnd: (info, span) => { seen.push({ kind: info.kind, - toolName: info.toolName, - toolCallId: info.toolCallId, + toolName: info.kind === 'tool' ? info.toolName : undefined, + toolCallId: info.kind === 'tool' ? info.toolCallId : undefined, ended: (span as FakeSpan).ended, }) }, @@ -437,18 +569,16 @@ describe('otelMiddleware — error and abort paths', () => { toolCallId: 'tc-err', }) - const err = new Error('fatal') - await mw.onError?.(ctx, { error: err, duration: 100 }) + await mw.onError?.(ctx, { error: new Error('fatal'), duration: 100 }) - // onSpanEnd should have been called for: iteration, tool, root (in that order) - const toolCall = seen.find((s) => s.kind === 'tool') - expect(toolCall).toBeDefined() - expect(toolCall!.toolName).toBe('my_tool') - expect(toolCall!.toolCallId).toBe('tc-err') - expect(toolCall!.ended).toBe(false) // fired before span.end() + expect(seen.map((s) => s.kind)).toEqual(['iteration', 'tool', 'chat']) + expect(seen.every((s) => s.ended === false)).toBe(true) + const toolCall = seen.find((s) => s.kind === 'tool')! + expect(toolCall.toolName).toBe('my_tool') + expect(toolCall.toolCallId).toBe('tc-err') }) - it('onAbort fires onSpanEnd for open tool spans before ending them', async () => { + it('onAbort fires onSpanEnd for iteration, open tool spans, then root — in depth-first order', async () => { const { tracer } = createFakeTracer() const seen: Array<{ kind: string @@ -461,8 +591,8 @@ describe('otelMiddleware — error and abort paths', () => { onSpanEnd: (info, span) => { seen.push({ kind: info.kind, - toolName: info.toolName, - toolCallId: info.toolCallId, + toolName: info.kind === 'tool' ? info.toolName : undefined, + toolCallId: info.kind === 'tool' ? info.toolCallId : undefined, ended: (span as FakeSpan).ended, }) }, @@ -483,11 +613,33 @@ describe('otelMiddleware — error and abort paths', () => { await mw.onAbort?.(ctx, { reason: 'user stop', duration: 50 }) - const toolCall = seen.find((s) => s.kind === 'tool') - expect(toolCall).toBeDefined() - expect(toolCall!.toolName).toBe('slow_tool') - expect(toolCall!.toolCallId).toBe('tc-abort') - expect(toolCall!.ended).toBe(false) // fired before span.end() + expect(seen.map((s) => s.kind)).toEqual(['iteration', 'tool', 'chat']) + expect(seen.every((s) => s.ended === false)).toBe(true) + }) + + it('onFinish sweeps dangling tool spans with outcome=unknown before closing the iteration span', async () => { + const { tracer, spans } = createFakeTracer() + const mw = otelMiddleware({ tracer }) + const ctx = makeCtx({ hasTools: true }) + + await runToIterationStart(mw, ctx) + await mw.onBeforeToolCall?.(ctx, { + toolCall: makeToolCall({ + id: 'tc-leak', + function: { name: 'never_resolves' }, + }), + tool: undefined, + args: {}, + toolName: 'never_resolves', + toolCallId: 'tc-leak', + }) + // Never call onAfterToolCall — simulate an adapter that dropped the call. + + await mw.onFinish?.(ctx, { finishReason: 'stop', duration: 1, content: '' }) + + const toolSpan = spans[2]! + expect(toolSpan.ended).toBe(true) + expect(toolSpan.attributes['tanstack.ai.tool.outcome']).toBe('unknown') }) }) @@ -533,7 +685,6 @@ describe('otelMiddleware — tool-message and choice events', () => { await runToIterationStart(mw, ctx) await mw.onChunk?.(ctx, ev.textContent('Hello ')) await mw.onChunk?.(ctx, ev.textContent('world')) - // model field needed for gen_ai.response.model; rest from ev.runFinished await mw.onChunk?.(ctx, { ...ev.runFinished('stop'), model: 'gpt-4o' }) const iter = spans[1]! @@ -572,18 +723,17 @@ describe('otelMiddleware — concurrent isolation', () => { // Total: 2 root spans + 2 iteration spans, all ended. expect(spans.filter((s) => s.ended).length).toBe(4) - // Each iteration has its own finish_reason. const iters = spans.filter((s) => s.parent !== null) const reasons = iters.flatMap((s) => { const v = s.attributes['gen_ai.response.finish_reasons'] - return Array.isArray(v) ? (v as string[]) : [] + return Array.isArray(v) ? (v as Array) : [] }) expect(reasons).toEqual(expect.arrayContaining(['stop', 'tool_calls'])) }) }) describe('otelMiddleware — extension points', () => { - it('spanNameFormatter overrides default names', async () => { + it('spanNameFormatter overrides default names for chat, iteration, and tool', async () => { const { tracer, spans } = createFakeTracer() const mw = otelMiddleware({ tracer, @@ -609,34 +759,54 @@ describe('otelMiddleware — extension points', () => { expect(spans[2]!.name).toBe('tool-lookup') }) - it('attributeEnricher merges attributes onto every span', async () => { + it('attributeEnricher merges attributes onto chat, iteration, and tool spans', async () => { const { tracer, spans } = createFakeTracer() const mw = otelMiddleware({ tracer, attributeEnricher: (info) => ({ 'test.kind': info.kind }), }) - const ctx = makeCtx() + const ctx = makeCtx({ hasTools: true }) await runToIterationStart(mw, ctx) + await mw.onBeforeToolCall?.(ctx, { + toolCall: makeToolCall({ id: 't-1', function: { name: 'lookup' } }), + tool: undefined, + args: {}, + toolName: 'lookup', + toolCallId: 't-1', + }) expect(spans[0]!.attributes['test.kind']).toBe('chat') expect(spans[1]!.attributes['test.kind']).toBe('iteration') + expect(spans[2]!.attributes['test.kind']).toBe('tool') }) - it('onBeforeSpanStart can mutate SpanOptions before startSpan', async () => { + it('onBeforeSpanStart can mutate SpanOptions before startSpan for every kind', async () => { const { tracer, spans } = createFakeTracer() const mw = otelMiddleware({ tracer, - onBeforeSpanStart: (_info, options) => ({ + onBeforeSpanStart: (info, options) => ({ ...options, - attributes: { ...(options.attributes ?? {}), 'custom.start': true }, + attributes: { + ...(options.attributes ?? {}), + 'custom.kind': info.kind, + }, }), }) - const ctx = makeCtx() + const ctx = makeCtx({ hasTools: true }) - await mw.onStart?.(ctx) + await runToIterationStart(mw, ctx) + await mw.onBeforeToolCall?.(ctx, { + toolCall: makeToolCall({ id: 't-1', function: { name: 'x' } }), + tool: undefined, + args: {}, + toolName: 'x', + toolCallId: 't-1', + }) - expect(spans[0]!.attributes['custom.start']).toBe(true) + expect(spans[0]!.attributes['custom.kind']).toBe('chat') + expect(spans[1]!.attributes['custom.kind']).toBe('iteration') + expect(spans[2]!.attributes['custom.kind']).toBe('tool') }) it('onSpanEnd fires before span.end()', async () => { @@ -658,21 +828,41 @@ describe('otelMiddleware — extension points', () => { expect(seen.every((s) => s.ended === false)).toBe(true) }) - it('throwing user callback does NOT break the chat run', async () => { - const { tracer, spans } = createFakeTracer() - const mw = otelMiddleware({ - tracer, - attributeEnricher: () => { - throw new Error('boom') - }, + describe('callback resilience', () => { + let warn: ReturnType + + beforeEach(() => { + warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) }) - const ctx = makeCtx() - // onStart and onFinish must not throw even when attributeEnricher throws - expect(() => mw.onStart?.(ctx)).not.toThrow() - expect(() => - mw.onFinish?.(ctx, { finishReason: 'stop', duration: 1, content: '' }), - ).not.toThrow() - expect(spans[0]!.ended).toBe(true) + afterEach(() => { + warn.mockRestore() + }) + + it('a throwing user callback does not break the run and is logged', async () => { + const { tracer, spans } = createFakeTracer() + const mw = otelMiddleware({ + tracer, + attributeEnricher: () => { + throw new Error('boom') + }, + }) + const ctx = makeCtx() + + await mw.onStart?.(ctx) + await mw.onFinish?.(ctx, { + finishReason: 'stop', + duration: 1, + content: '', + }) + + expect(spans[0]!.ended).toBe(true) + expect(warn).toHaveBeenCalled() + // Label identifies the failing hook for diagnosis. + const call = warn.mock.calls.find((args: Array) => + String(args[0]).includes('otel.attributeEnricher'), + ) + expect(call).toBeDefined() + }) }) }) diff --git a/packages/typescript/ai/vite.config.ts b/packages/typescript/ai/vite.config.ts index cb2d342e3..580db682e 100644 --- a/packages/typescript/ai/vite.config.ts +++ b/packages/typescript/ai/vite.config.ts @@ -33,6 +33,7 @@ export default mergeConfig( './src/index.ts', './src/activities/index.ts', './src/middlewares/index.ts', + './src/middlewares/otel.ts', './src/adapter-internals.ts', ], srcDir: './src', diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b71679591..15eea0236 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1672,6 +1672,9 @@ importers: '@copilotkit/aimock': specifier: latest version: 1.14.0 + '@opentelemetry/api': + specifier: ^1.9.0 + version: 1.9.1 '@tailwindcss/vite': specifier: ^4.1.18 version: 4.1.18(vite@7.3.1(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) diff --git a/testing/e2e/package.json b/testing/e2e/package.json index 0dc700b0d..b3995afe3 100644 --- a/testing/e2e/package.json +++ b/testing/e2e/package.json @@ -12,6 +12,7 @@ }, "dependencies": { "@copilotkit/aimock": "latest", + "@opentelemetry/api": "^1.9.0", "@tailwindcss/vite": "^4.1.18", "@tanstack/ai": "workspace:*", "@tanstack/ai-anthropic": "workspace:*", diff --git a/testing/e2e/src/lib/otel-capture.ts b/testing/e2e/src/lib/otel-capture.ts new file mode 100644 index 000000000..bb5046f11 --- /dev/null +++ b/testing/e2e/src/lib/otel-capture.ts @@ -0,0 +1,103 @@ +import type { + AttributeValue, + Attributes, + SpanStatusCode, +} from '@opentelemetry/api' + +export interface CapturedEvent { + name: string + attributes?: Attributes +} + +export interface CapturedException { + exception: string + attributes?: Attributes +} + +export interface CapturedSpan { + id: string + name: string + kind?: number + attributes: Record + status: SpanStatusCode + statusMessage?: string + events: Array + exceptions: Array + ended: boolean +} + +export interface CapturedHistogram { + name: string + value: number + attributes?: Attributes + unit?: string +} + +export interface OtelCapture { + spans: Array + histograms: Array +} + +// Keyed by testId. Lives as a module-global — Nitro's dev server keeps a +// single JS context per process, which is all these tests need. +const captures: Map = new Map() + +function bucketFor(captureId: string): OtelCapture { + let bucket = captures.get(captureId) + if (!bucket) { + bucket = { spans: [], histograms: [] } + captures.set(captureId, bucket) + } + return bucket +} + +export function resetOtelCapture(captureId: string): void { + captures.set(captureId, { spans: [], histograms: [] }) +} + +export function getOtelCapture(captureId: string): OtelCapture { + return bucketFor(captureId) +} + +export function recordOtelSpan( + captureId: string, + entry: + | CapturedSpan + | { id: string; patch: Partial> }, +): void { + const bucket = bucketFor(captureId) + if ('patch' in entry) { + const existing = bucket.spans.find((s) => s.id === entry.id) + if (!existing) return + Object.assign(existing, entry.patch) + return + } + bucket.spans.push(entry) +} + +export function recordOtelEvent( + captureId: string, + spanId: string, + event: CapturedEvent, +): void { + const existing = bucketFor(captureId).spans.find((s) => s.id === spanId) + if (!existing) return + existing.events.push(event) +} + +export function recordOtelException( + captureId: string, + spanId: string, + exception: CapturedException, +): void { + const existing = bucketFor(captureId).spans.find((s) => s.id === spanId) + if (!existing) return + existing.exceptions.push(exception) +} + +export function recordOtelHistogram( + captureId: string, + entry: CapturedHistogram, +): void { + bucketFor(captureId).histograms.push(entry) +} diff --git a/testing/e2e/src/routes/api.middleware-test.ts b/testing/e2e/src/routes/api.middleware-test.ts index a8c0def9b..f1cf8e408 100644 --- a/testing/e2e/src/routes/api.middleware-test.ts +++ b/testing/e2e/src/routes/api.middleware-test.ts @@ -6,8 +6,30 @@ import { toolDefinition, } from '@tanstack/ai' import type { ChatMiddleware } from '@tanstack/ai' +import { otelMiddleware } from '@tanstack/ai/middlewares/otel' +import type { + Attributes, + AttributeValue, + Context, + Histogram, + MetricOptions, + Meter, + Span, + SpanContext, + SpanStatus, + Tracer, +} from '@opentelemetry/api' +import { SpanStatusCode } from '@opentelemetry/api' import { z } from 'zod' import { createTextAdapter } from '@/lib/providers' +import { + getOtelCapture, + recordOtelEvent, + recordOtelException, + recordOtelHistogram, + recordOtelSpan, + resetOtelCapture, +} from '@/lib/otel-capture' const weatherTool = toolDefinition({ name: 'get_weather', @@ -44,6 +66,122 @@ const toolSkipMiddleware: ChatMiddleware = { }, } +// Minimal in-memory tracer/meter. Captures into a per-testId bucket so that +// the Playwright spec can fetch the recorded state via GET after the stream +// finishes. Not exported — only used to build otelMiddleware for the test. +function createCaptureTracer(captureId: string): Tracer { + let spanSeq = 0 + const tracer: Tracer = { + startSpan(name, options = {}, _ctx?: Context): Span { + const id = `span-${spanSeq++}` + const attrs: Record = {} + for (const [k, v] of Object.entries(options.attributes ?? {})) { + if (v !== undefined) attrs[k] = v as AttributeValue + } + recordOtelSpan(captureId, { + id, + name, + kind: options.kind, + attributes: attrs, + status: SpanStatusCode.UNSET, + events: [], + exceptions: [], + ended: false, + }) + const status: SpanStatus = { code: SpanStatusCode.UNSET } + const span: Span = { + spanContext(): SpanContext { + return { traceId: 'capture-trace', spanId: id, traceFlags: 1 } + }, + setAttribute(key, value) { + attrs[key] = value as AttributeValue + recordOtelSpan(captureId, { id, patch: { attributes: { ...attrs } } }) + return span + }, + setAttributes(next) { + for (const [k, v] of Object.entries(next)) { + attrs[k] = v as AttributeValue + } + recordOtelSpan(captureId, { id, patch: { attributes: { ...attrs } } }) + return span + }, + addEvent(eventName, eventAttrs) { + recordOtelEvent(captureId, id, { + name: eventName, + attributes: eventAttrs as Attributes | undefined, + }) + return span + }, + addLink() { + return span + }, + addLinks() { + return span + }, + setStatus(next) { + status.code = next.code + status.message = next.message + recordOtelSpan(captureId, { + id, + patch: { status: next.code, statusMessage: next.message }, + }) + return span + }, + updateName(next) { + recordOtelSpan(captureId, { id, patch: { name: next } }) + return span + }, + end() { + recordOtelSpan(captureId, { id, patch: { ended: true } }) + }, + isRecording() { + return status.code === SpanStatusCode.UNSET + }, + recordException(exception, exceptionAttrs) { + recordOtelException(captureId, id, { + exception: String( + (exception as { message?: string } | undefined)?.message ?? + exception, + ), + attributes: exceptionAttrs as Attributes | undefined, + }) + }, + } + return span + }, + // Minimal implementation — our middleware never calls startActiveSpan. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + startActiveSpan(...args: Array) { + const fn = args[args.length - 1] as (span: Span) => unknown + const name = args[0] as string + const span = tracer.startSpan(name, {}) + try { + return fn(span) + } finally { + span.end() + } + }, + } + return tracer +} + +function createCaptureMeter(captureId: string): Meter { + const histogram = (name: string, options?: MetricOptions): Histogram => ({ + record(value: number, attributes?: Attributes) { + recordOtelHistogram(captureId, { + name, + value, + attributes, + unit: options?.unit, + }) + }, + }) + return { + createHistogram: histogram, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any as Meter +} + export const Route = createFileRoute('/api/middleware-test')({ server: { handlers: { @@ -70,12 +208,31 @@ export const Route = createFileRoute('/api/middleware-test')({ testId, ) - const middleware: ChatMiddleware[] = [] + const middleware: Array = [] if (middlewareMode === 'chunk-transform') middleware.push(chunkTransformMiddleware) if (middlewareMode === 'tool-skip') middleware.push(toolSkipMiddleware) + if (middlewareMode === 'otel') { + if (!testId) { + return new Response( + JSON.stringify({ error: 'otel mode requires testId' }), + { + status: 400, + headers: { 'Content-Type': 'application/json' }, + }, + ) + } + resetOtelCapture(testId) + middleware.push( + otelMiddleware({ + tracer: createCaptureTracer(testId), + meter: createCaptureMeter(testId), + captureContent: true, + }), + ) + } const tools = scenario === 'with-tool' ? [weatherTool] : [] @@ -100,6 +257,22 @@ export const Route = createFileRoute('/api/middleware-test')({ }) } }, + GET: async ({ request }) => { + const url = new URL(request.url) + const testId = url.searchParams.get('testId') + if (!testId) { + return new Response( + JSON.stringify({ error: 'testId query param required' }), + { + status: 400, + headers: { 'Content-Type': 'application/json' }, + }, + ) + } + return new Response(JSON.stringify(getOtelCapture(testId)), { + headers: { 'Content-Type': 'application/json' }, + }) + }, }, }, }) diff --git a/testing/e2e/src/routes/middleware-test.tsx b/testing/e2e/src/routes/middleware-test.tsx index 20c2ba76e..92134835e 100644 --- a/testing/e2e/src/routes/middleware-test.tsx +++ b/testing/e2e/src/routes/middleware-test.tsx @@ -6,6 +6,7 @@ const MIDDLEWARE_MODES = [ { id: 'none', label: 'No Middleware' }, { id: 'chunk-transform', label: 'Chunk Transform (prefix text)' }, { id: 'tool-skip', label: 'Tool Skip (skip with custom result)' }, + { id: 'otel', label: 'OpenTelemetry (capture spans/metrics)' }, ] as const export const Route = createFileRoute('/middleware-test')({ diff --git a/testing/e2e/tests/middleware.spec.ts b/testing/e2e/tests/middleware.spec.ts index d6e1994f7..2598b5367 100644 --- a/testing/e2e/tests/middleware.spec.ts +++ b/testing/e2e/tests/middleware.spec.ts @@ -65,6 +65,118 @@ test.describe('Middleware Lifecycle', () => { expect(toolResults[0].content).toContain('skipped') }) + test('otel middleware emits chat span + per-iteration token histograms', async ({ + page, + testId, + aimockPort, + baseURL, + }) => { + const params = new URLSearchParams() + if (testId) params.set('testId', testId) + if (aimockPort) params.set('aimockPort', String(aimockPort)) + const qs = params.toString() + await page.goto(`/middleware-test${qs ? '?' + qs : ''}`) + await page.waitForTimeout(2000) + await page.locator('#mw-scenario-select').selectOption('basic-text') + await page.locator('#mw-mode-select').selectOption('otel') + await page.locator('#mw-run-button').click() + + await page.waitForFunction( + () => + document + .querySelector('#mw-metadata') + ?.getAttribute('data-test-complete') === 'true', + { timeout: 10000 }, + ) + + // Fetch the captured otel state from the server. + const captureUrl = `${baseURL ?? ''}/api/middleware-test?testId=${encodeURIComponent(testId)}` + const response = await page.request.get(captureUrl) + expect(response.ok()).toBe(true) + const capture = await response.json() + + // Chat span + at least one iteration span, all ended. + const chatSpan = capture.spans.find( + (s: any) => + s.attributes['gen_ai.operation.name'] === 'chat' && + !('tanstack.ai.iteration' in s.attributes), + ) + expect(chatSpan).toBeDefined() + expect(chatSpan.ended).toBe(true) + + const iterationSpans = capture.spans.filter( + (s: any) => 'tanstack.ai.iteration' in s.attributes, + ) + expect(iterationSpans.length).toBeGreaterThanOrEqual(1) + for (const iter of iterationSpans) { + expect(iter.ended).toBe(true) + } + + // Token histogram records show up with correct unit and low-cardinality attrs. + const tokenRecords = capture.histograms.filter( + (h: any) => h.name === 'gen_ai.client.token.usage', + ) + // Guard against the C1 regression: onUsage used to no-op in production order, + // losing every token histogram record. If we ever regress, this assertion fails. + expect(tokenRecords.length).toBeGreaterThanOrEqual(2) + for (const r of tokenRecords) { + expect(r.unit).toBe('{token}') + expect(r.attributes['gen_ai.response.id']).toBeUndefined() + expect(r.attributes['gen_ai.response.model']).toBeUndefined() + } + + // Duration histogram is per-run. + const durationRecords = capture.histograms.filter( + (h: any) => h.name === 'gen_ai.client.operation.duration', + ) + expect(durationRecords.length).toBe(1) + expect(durationRecords[0].unit).toBe('s') + expect( + durationRecords[0].attributes['gen_ai.response.model'], + ).toBeUndefined() + }) + + test('otel middleware nests tool spans under the iteration span that triggered them', async ({ + page, + testId, + aimockPort, + baseURL, + }) => { + const params = new URLSearchParams() + if (testId) params.set('testId', testId) + if (aimockPort) params.set('aimockPort', String(aimockPort)) + const qs = params.toString() + await page.goto(`/middleware-test${qs ? '?' + qs : ''}`) + await page.waitForTimeout(2000) + await page.locator('#mw-scenario-select').selectOption('with-tool') + await page.locator('#mw-mode-select').selectOption('otel') + await page.locator('#mw-run-button').click() + + await page.waitForFunction( + () => + document + .querySelector('#mw-metadata') + ?.getAttribute('data-test-complete') === 'true', + { timeout: 15000 }, + ) + + const captureUrl = `${baseURL ?? ''}/api/middleware-test?testId=${encodeURIComponent(testId)}` + const response = await page.request.get(captureUrl) + const capture = await response.json() + + // Every tool span carries gen_ai.tool.name + ended outcome. This also + // guards against the "iteration span closed before onBeforeToolCall" + // regression — if it regressed, onBeforeToolCall would skip span creation. + const toolSpans = capture.spans.filter( + (s: any) => 'gen_ai.tool.name' in s.attributes, + ) + expect(toolSpans.length).toBeGreaterThanOrEqual(1) + for (const tool of toolSpans) { + expect(tool.ended).toBe(true) + expect(tool.attributes['tanstack.ai.tool.outcome']).toBeDefined() + } + }) + test('no middleware passes content through unchanged', async ({ page, testId, From 92943ee753e23264036a9e3ec62e87333a2a4b0d Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Fri, 24 Apr 2026 11:44:00 +0200 Subject: [PATCH 24/31] fix(ai): address second CodeRabbit review pass on otel middleware - otel.ts: remove token histogram recording from onChunk; the chat runner always follows RUN_FINISHED-with-usage by runOnUsage, so recording in both hooks double-counted every histogram. onChunk keeps attribute setting, onUsage is the canonical histogram source. - otel.ts: wrap JSON.stringify in onAfterToolCall with safeCall. A tool result containing circular refs or BigInt used to throw out of the handler body and skip toolSpan.end() + state.toolSpans cleanup, leaving the span dangling. Failed serialization now yields a sentinel string and the handler always finalizes the span. - fake-otel.ts: cache spanId once per FakeSpan so repeat spanContext() calls return a consistent id. - api.middleware-test.ts (e2e): gate both the POST 'otel' middleware mode and the GET capture fetch behind an OTEL_TEST_ENABLED env check so the endpoint cannot be used as an oracle outside E2E runs. Track 'ended' separately so the capture span's isRecording() flips after end() like real OTel. Reorder imports. - otel-capture.ts: document the shallow Object.assign patch behavior on recordOtelSpan so future callers know nested fields replace instead of merging. - middleware.spec.ts: discriminate root-vs-iteration spans by SpanKind (INTERNAL vs CLIENT) instead of presence of the 'tanstack.ai.iteration' attribute; factor the capture-fetch into a helper that validates testId and response.ok. --- .../typescript/ai/src/middlewares/otel.ts | 32 ++++------ .../ai/tests/middlewares/fake-otel.ts | 3 +- .../ai/tests/middlewares/otel.test.ts | 63 ++++++++++++++----- testing/e2e/src/lib/otel-capture.ts | 10 +++ testing/e2e/src/routes/api.middleware-test.ts | 22 +++++-- testing/e2e/tests/middleware.spec.ts | 45 ++++++++----- 6 files changed, 117 insertions(+), 58 deletions(-) diff --git a/packages/typescript/ai/src/middlewares/otel.ts b/packages/typescript/ai/src/middlewares/otel.ts index 7f6cf8fcb..5a7b11af9 100644 --- a/packages/typescript/ai/src/middlewares/otel.ts +++ b/packages/typescript/ai/src/middlewares/otel.ts @@ -406,30 +406,16 @@ export function otelMiddleware(options: OtelMiddlewareOptions): ChatMiddleware { } if (chunk.model) span.setAttribute('gen_ai.response.model', chunk.model) - // Capture usage attributes and token-histogram metrics directly from - // the chunk. `onUsage` can still fire later (per-provider quirks); its - // implementation is additive to what's recorded here so nothing is - // lost regardless of ordering. + // Set usage attributes on the iteration span directly from the chunk + // so they're available before `onUsage` fires. Histogram recording is + // deliberately NOT done here — the chat runner always invokes + // `runOnUsage` when `chunk.usage` is present, and `onUsage` is the + // canonical place for the metric. Recording in both would double-count. if (chunk.usage) { span.setAttributes({ 'gen_ai.usage.input_tokens': chunk.usage.promptTokens, 'gen_ai.usage.output_tokens': chunk.usage.completionTokens, }) - if (tokenHistogram) { - const metricAttrs = { - 'gen_ai.system': ctx.provider, - 'gen_ai.operation.name': 'chat', - 'gen_ai.request.model': ctx.model, - } - tokenHistogram.record(chunk.usage.promptTokens, { - ...metricAttrs, - 'gen_ai.token.type': 'input', - }) - tokenHistogram.record(chunk.usage.completionTokens, { - ...metricAttrs, - 'gen_ai.token.type': 'output', - }) - } } if (captureContent && state.assistantTextBuffer.length > 0) { @@ -550,10 +536,16 @@ export function otelMiddleware(options: OtelMiddlewareOptions): ChatMiddleware { } if (captureContent && state.currentIterationSpan) { + // Serialization can throw on circular refs or `BigInt` values. If it + // does, fall back to a sentinel so the rest of this handler (span + // end, onSpanEnd, toolSpans cleanup) still runs — otherwise the tool + // span would dangle until the onFinish/onError sweep. const body = typeof info.result === 'string' ? info.result - : JSON.stringify(info.result ?? null) + : (safeCall('otel.serializeToolResult', () => + JSON.stringify(info.result ?? null), + ) ?? '[unserializable_tool_result]') state.currentIterationSpan.addEvent('gen_ai.tool.message', { content: redactContent(body), tool_call_id: info.toolCallId, diff --git a/packages/typescript/ai/tests/middlewares/fake-otel.ts b/packages/typescript/ai/tests/middlewares/fake-otel.ts index a7efaf223..47989470d 100644 --- a/packages/typescript/ai/tests/middlewares/fake-otel.ts +++ b/packages/typescript/ai/tests/middlewares/fake-otel.ts @@ -62,6 +62,7 @@ function makeSpan( options: SpanOptions, parent: FakeSpan | null, ): FakeSpan { + const spanId = `fake-span-${Math.random().toString(36).slice(2, 10)}` const span: FakeSpan = { name, kind: options.kind, @@ -80,7 +81,7 @@ function makeSpan( spanContext(): SpanContext { return { traceId: 'fake-trace', - spanId: `fake-span-${Math.random().toString(36).slice(2, 10)}`, + spanId, traceFlags: 1, } }, diff --git a/packages/typescript/ai/tests/middlewares/otel.test.ts b/packages/typescript/ai/tests/middlewares/otel.test.ts index 3679c9c31..c7b76562a 100644 --- a/packages/typescript/ai/tests/middlewares/otel.test.ts +++ b/packages/typescript/ai/tests/middlewares/otel.test.ts @@ -127,7 +127,10 @@ describe('otelMiddleware — iteration span lifecycle', () => { }) describe('otelMiddleware — token histogram', () => { - it('records input and output token histograms from RUN_FINISHED usage', async () => { + it('sets usage attributes on the iteration span from RUN_FINISHED chunk.usage without recording a histogram', async () => { + // The chat runner always follows onChunk(RUN_FINISHED) with runOnUsage, so + // histogram recording lives in onUsage alone to avoid double-counting. + // onChunk is responsible only for the per-iteration span attributes. const { tracer, spans } = createFakeTracer() const { meter, records } = createFakeMeter() const mw = otelMiddleware({ tracer, meter }) @@ -140,24 +143,11 @@ describe('otelMiddleware — token histogram', () => { usage: { promptTokens: 100, completionTokens: 50, totalTokens: 150 }, }) - const tokenRecords = records.filter( - (r) => r.name === 'gen_ai.client.token.usage', - ) - expect(tokenRecords).toHaveLength(2) - expect( - tokenRecords.find((r) => r.attributes!['gen_ai.token.type'] === 'input')! - .value, - ).toBe(100) - expect( - tokenRecords.find((r) => r.attributes!['gen_ai.token.type'] === 'output')! - .value, - ).toBe(50) - // Cardinality guard: response.id must NOT appear on metric attributes. - for (const r of tokenRecords) { - expect(r.attributes!['gen_ai.response.id']).toBeUndefined() - } expect(spans[1]!.attributes['gen_ai.usage.input_tokens']).toBe(100) expect(spans[1]!.attributes['gen_ai.usage.output_tokens']).toBe(50) + expect( + records.filter((r) => r.name === 'gen_ai.client.token.usage'), + ).toHaveLength(0) }) it('records token histograms from onUsage in production hook order (after RUN_FINISHED)', async () => { @@ -373,6 +363,45 @@ describe('otelMiddleware — tool spans', () => { expect(toolSpan.exceptions[0]!.exception).toBe(plainError) expect(toolSpan.status.message).toBe('plain-object error') }) + + it('finalizes the tool span even when the result fails to JSON.stringify', async () => { + const { tracer, spans } = createFakeTracer() + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const mw = otelMiddleware({ tracer, captureContent: true }) + const ctx = makeCtx({ hasTools: true }) + + await runToIterationStart(mw, ctx) + await mw.onBeforeToolCall?.(ctx, { + toolCall: makeToolCall({ id: 'tc-cyc', function: { name: 'circular' } }), + tool: undefined, + args: {}, + toolName: 'circular', + toolCallId: 'tc-cyc', + }) + + // Craft a result that JSON.stringify cannot handle (circular ref). + const circular: Record = {} + circular.self = circular + + await mw.onAfterToolCall?.(ctx, { + toolCall: makeToolCall({ id: 'tc-cyc' }), + tool: undefined, + toolName: 'circular', + toolCallId: 'tc-cyc', + ok: true, + duration: 3, + result: circular, + }) + + const iter = spans[1]! + const toolSpan = spans[2]! + // The span is still properly ended — no dangling state. + expect(toolSpan.ended).toBe(true) + // The tool-message event uses the sentinel instead of raw serialization. + const toolEvt = iter.events.find((e) => e.name === 'gen_ai.tool.message')! + expect(toolEvt.attributes!['content']).toBe('[unserializable_tool_result]') + warn.mockRestore() + }) }) describe('otelMiddleware — captureContent', () => { diff --git a/testing/e2e/src/lib/otel-capture.ts b/testing/e2e/src/lib/otel-capture.ts index bb5046f11..036b07824 100644 --- a/testing/e2e/src/lib/otel-capture.ts +++ b/testing/e2e/src/lib/otel-capture.ts @@ -59,6 +59,16 @@ export function getOtelCapture(captureId: string): OtelCapture { return bucketFor(captureId) } +/** + * Append a new span or patch an existing one. + * + * Patches are applied with a shallow `Object.assign`, so nested objects and + * arrays (e.g. `attributes`, `events`, `exceptions`) are replaced, not merged. + * Callers that want to extend those fields must pre-merge before passing the + * patch (see how `api.middleware-test.ts` spreads `{ ...attrs }`). Prefer + * `recordOtelEvent` / `recordOtelException` for those fields — they append + * rather than replace. + */ export function recordOtelSpan( captureId: string, entry: diff --git a/testing/e2e/src/routes/api.middleware-test.ts b/testing/e2e/src/routes/api.middleware-test.ts index f1cf8e408..6a817c265 100644 --- a/testing/e2e/src/routes/api.middleware-test.ts +++ b/testing/e2e/src/routes/api.middleware-test.ts @@ -7,19 +7,19 @@ import { } from '@tanstack/ai' import type { ChatMiddleware } from '@tanstack/ai' import { otelMiddleware } from '@tanstack/ai/middlewares/otel' +import { SpanStatusCode } from '@opentelemetry/api' import type { - Attributes, AttributeValue, + Attributes, Context, Histogram, - MetricOptions, Meter, + MetricOptions, Span, SpanContext, SpanStatus, Tracer, } from '@opentelemetry/api' -import { SpanStatusCode } from '@opentelemetry/api' import { z } from 'zod' import { createTextAdapter } from '@/lib/providers' import { @@ -31,6 +31,12 @@ import { resetOtelCapture, } from '@/lib/otel-capture' +// The otel capture endpoint is only useful during E2E runs. Gate both the +// POST 'otel' mode and the GET capture fetch behind this flag so the route +// cannot be used as an oracle in a production-like build. +const OTEL_TEST_ENABLED = + process.env.E2E_TEST === '1' || process.env.NODE_ENV !== 'production' + const weatherTool = toolDefinition({ name: 'get_weather', description: 'Get weather', @@ -89,6 +95,7 @@ function createCaptureTracer(captureId: string): Tracer { ended: false, }) const status: SpanStatus = { code: SpanStatusCode.UNSET } + let ended = false const span: Span = { spanContext(): SpanContext { return { traceId: 'capture-trace', spanId: id, traceFlags: 1 } @@ -132,10 +139,11 @@ function createCaptureTracer(captureId: string): Tracer { return span }, end() { + ended = true recordOtelSpan(captureId, { id, patch: { ended: true } }) }, isRecording() { - return status.code === SpanStatusCode.UNSET + return !ended }, recordException(exception, exceptionAttrs) { recordOtelException(captureId, id, { @@ -215,6 +223,9 @@ export const Route = createFileRoute('/api/middleware-test')({ if (middlewareMode === 'tool-skip') middleware.push(toolSkipMiddleware) if (middlewareMode === 'otel') { + if (!OTEL_TEST_ENABLED) { + return new Response(null, { status: 404 }) + } if (!testId) { return new Response( JSON.stringify({ error: 'otel mode requires testId' }), @@ -258,6 +269,9 @@ export const Route = createFileRoute('/api/middleware-test')({ } }, GET: async ({ request }) => { + if (!OTEL_TEST_ENABLED) { + return new Response(null, { status: 404 }) + } const url = new URL(request.url) const testId = url.searchParams.get('testId') if (!testId) { diff --git a/testing/e2e/tests/middleware.spec.ts b/testing/e2e/tests/middleware.spec.ts index 2598b5367..7ad5c3c4e 100644 --- a/testing/e2e/tests/middleware.spec.ts +++ b/testing/e2e/tests/middleware.spec.ts @@ -1,5 +1,22 @@ +import { SpanKind } from '@opentelemetry/api' import { test, expect } from './fixtures' +async function fetchOtelCapture( + page: import('@playwright/test').Page, + baseURL: string | undefined, + testId: string | undefined, +) { + if (!testId) throw new Error('otel capture test requires a testId fixture') + const url = `${baseURL ?? ''}/api/middleware-test?testId=${encodeURIComponent(testId)}` + const response = await page.request.get(url) + if (!response.ok()) { + throw new Error( + `GET ${url} failed: ${response.status()} ${await response.text()}`, + ) + } + return response.json() +} + test.describe('Middleware Lifecycle', () => { test('onChunk transforms text content', async ({ page, @@ -89,23 +106,21 @@ test.describe('Middleware Lifecycle', () => { { timeout: 10000 }, ) - // Fetch the captured otel state from the server. - const captureUrl = `${baseURL ?? ''}/api/middleware-test?testId=${encodeURIComponent(testId)}` - const response = await page.request.get(captureUrl) - expect(response.ok()).toBe(true) - const capture = await response.json() - - // Chat span + at least one iteration span, all ended. - const chatSpan = capture.spans.find( - (s: any) => - s.attributes['gen_ai.operation.name'] === 'chat' && - !('tanstack.ai.iteration' in s.attributes), + const capture = await fetchOtelCapture(page, baseURL, testId) + + // Root span is kind=INTERNAL; iteration spans are kind=CLIENT. This is a + // structural discriminator, immune to accidental attribute renames on + // either span. + const chatSpans = capture.spans.filter( + (s: any) => s.kind === SpanKind.INTERNAL, ) - expect(chatSpan).toBeDefined() + expect(chatSpans).toHaveLength(1) + const chatSpan = chatSpans[0] expect(chatSpan.ended).toBe(true) + expect(chatSpan.attributes['gen_ai.operation.name']).toBe('chat') const iterationSpans = capture.spans.filter( - (s: any) => 'tanstack.ai.iteration' in s.attributes, + (s: any) => s.kind === SpanKind.CLIENT, ) expect(iterationSpans.length).toBeGreaterThanOrEqual(1) for (const iter of iterationSpans) { @@ -160,9 +175,7 @@ test.describe('Middleware Lifecycle', () => { { timeout: 15000 }, ) - const captureUrl = `${baseURL ?? ''}/api/middleware-test?testId=${encodeURIComponent(testId)}` - const response = await page.request.get(captureUrl) - const capture = await response.json() + const capture = await fetchOtelCapture(page, baseURL, testId) // Every tool span carries gen_ai.tool.name + ended outcome. This also // guards against the "iteration span closed before onBeforeToolCall" From 5359a60484991c09bcc6f4aca781de2a22bf6d59 Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Fri, 24 Apr 2026 11:53:46 +0200 Subject: [PATCH 25/31] ci(ai): ignore @opentelemetry/api in knip for the ai workspace MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit @opentelemetry/api is an intentional optional peer dependency of @tanstack/ai — it is referenced from src/middlewares/otel.ts but users who don't import the otel subpath never load it. Knip's default rule flags referenced optional peers as an error; add a workspace-scoped ignoreDependencies entry so knip stays happy without forcing the peer to be non-optional. --- knip.json | 3 +++ 1 file changed, 3 insertions(+) diff --git a/knip.json b/knip.json index 1fa06e311..7ece05b5b 100644 --- a/knip.json +++ b/knip.json @@ -22,6 +22,9 @@ "packages/react-ai": { "ignore": [] }, + "packages/typescript/ai": { + "ignoreDependencies": ["@opentelemetry/api"] + }, "packages/typescript/ai-anthropic": { "ignore": ["src/tools/**"] }, From 0ce2c75c53bb07ac3c0f4aa0af665b9edf057427 Mon Sep 17 00:00:00 2001 From: Tom Beckenham <34339192+tombeckenham@users.noreply.github.com> Date: Mon, 4 May 2026 15:46:07 +1000 Subject: [PATCH 26/31] feat(ai): emit GenAI semconv attributes on otel spans for PostHog compatibility Adds `gen_ai.input.messages` / `gen_ai.output.messages` attribute form alongside existing span events so backends that read prompt/completion content from attributes (e.g. PostHog LLM Analytics) render correctly. Also drops `gen_ai.operation.name` from the root span to avoid duplicate generation events, and stamps tool args/results onto tool spans. --- docs/advanced/otel.md | 2 +- .../typescript/ai/src/middlewares/otel.ts | 84 ++++++++++++++++--- .../ai/tests/middlewares/otel.test.ts | 4 +- 3 files changed, 78 insertions(+), 12 deletions(-) diff --git a/docs/advanced/otel.md b/docs/advanced/otel.md index 5e885b8c4..a8c14673b 100644 --- a/docs/advanced/otel.md +++ b/docs/advanced/otel.md @@ -64,7 +64,7 @@ Iteration spans are numbered (`#0`, `#1`, ...) so distinct iterations of the sam | Level | Attribute | Value | | --- | --- | --- | | root / iteration | `gen_ai.system` | `openai`, `anthropic`, ... | -| root / iteration | `gen_ai.operation.name` | `chat` | +| iteration | `gen_ai.operation.name` | `chat` | | root / iteration | `gen_ai.request.model` | requested model | | iteration | `gen_ai.response.model` | actual model | | iteration | `gen_ai.request.temperature` | from config | diff --git a/packages/typescript/ai/src/middlewares/otel.ts b/packages/typescript/ai/src/middlewares/otel.ts index 5a7b11af9..1cf9e2f44 100644 --- a/packages/typescript/ai/src/middlewares/otel.ts +++ b/packages/typescript/ai/src/middlewares/otel.ts @@ -275,8 +275,13 @@ export function otelMiddleware(options: OtelMiddlewareOptions): ChatMiddleware { kind: SpanKind.INTERNAL, attributes: { 'gen_ai.system': ctx.provider, - 'gen_ai.operation.name': 'chat', 'gen_ai.request.model': ctx.model, + // NOTE: `gen_ai.operation.name` is deliberately NOT set on the + // root span. The root represents a `chat()` invocation that may + // span multiple model calls; only iteration spans correspond to + // a single chat operation. Backends that map `operation.name=chat` + // to a "generation" event (e.g. PostHog LLM Analytics) would + // otherwise emit a duplicate generation for the wrapper span. }, } const spanOptions = @@ -367,6 +372,8 @@ export function otelMiddleware(options: OtelMiddlewareOptions): ChatMiddleware { state.assistantTextBufferTruncated = false if (captureContent) { + // Span events follow the original GenAI semconv (one event per + // message). Backends that read events get content this way. for (const sys of config.systemPrompts) { iterSpan.addEvent('gen_ai.system.message', { content: redactContent(sys), @@ -379,6 +386,31 @@ export function otelMiddleware(options: OtelMiddlewareOptions): ChatMiddleware { content: redactContent(body), }) } + + // Also emit the current GenAI-semconv attribute form + // (`gen_ai.input.messages`) — backends like PostHog read prompt + // content from this attribute, not from span events. + const inputMessages: Array<{ role: string; content: string }> = [] + for (const sys of config.systemPrompts) { + inputMessages.push({ + role: 'system', + content: redactContent(sys), + }) + } + for (const m of config.messages) { + const body = serializeContent(m.content) + if (body.length === 0) continue + inputMessages.push({ + role: m.role, + content: redactContent(body), + }) + } + if (inputMessages.length > 0) { + iterSpan.setAttribute( + 'gen_ai.input.messages', + JSON.stringify(inputMessages), + ) + } } state.iterationCount += 1 @@ -419,9 +451,15 @@ export function otelMiddleware(options: OtelMiddlewareOptions): ChatMiddleware { } if (captureContent && state.assistantTextBuffer.length > 0) { - span.addEvent('gen_ai.choice', { - content: redactContent(state.assistantTextBuffer), - }) + const completion = redactContent(state.assistantTextBuffer) + // Event form (older semconv) — kept for backends that consume it. + span.addEvent('gen_ai.choice', { content: completion }) + // Attribute form (current semconv) — required by backends like + // PostHog that read completion content from `gen_ai.output.messages`. + span.setAttribute( + 'gen_ai.output.messages', + JSON.stringify([{ role: 'assistant', content: completion }]), + ) state.assistantTextBuffer = '' state.assistantTextBufferTruncated = false } @@ -508,6 +546,23 @@ export function otelMiddleware(options: OtelMiddlewareOptions): ChatMiddleware { ) if (enriched) toolSpan.setAttributes(enriched) + // Stamp the tool args onto the tool span so backends that render an + // input panel per span (e.g. PostHog) have something to show. + if (captureContent) { + const argsBody = + typeof hookCtx.args === 'string' + ? hookCtx.args + : (safeCall('otel.serializeToolArgs', () => + JSON.stringify(hookCtx.args ?? null), + ) ?? '[unserializable_tool_args]') + toolSpan.setAttribute( + 'gen_ai.input.messages', + JSON.stringify([ + { role: 'tool', content: redactContent(argsBody) }, + ]), + ) + } + state.toolSpans.set(hookCtx.toolCallId, { span: toolSpan, toolName: hookCtx.toolName, @@ -535,7 +590,7 @@ export function otelMiddleware(options: OtelMiddlewareOptions): ChatMiddleware { }) } - if (captureContent && state.currentIterationSpan) { + if (captureContent) { // Serialization can throw on circular refs or `BigInt` values. If it // does, fall back to a sentinel so the rest of this handler (span // end, onSpanEnd, toolSpans cleanup) still runs — otherwise the tool @@ -546,10 +601,19 @@ export function otelMiddleware(options: OtelMiddlewareOptions): ChatMiddleware { : (safeCall('otel.serializeToolResult', () => JSON.stringify(info.result ?? null), ) ?? '[unserializable_tool_result]') - state.currentIterationSpan.addEvent('gen_ai.tool.message', { - content: redactContent(body), - tool_call_id: info.toolCallId, - }) + const redactedBody = redactContent(body) + if (state.currentIterationSpan) { + state.currentIterationSpan.addEvent('gen_ai.tool.message', { + content: redactedBody, + tool_call_id: info.toolCallId, + }) + } + // Output panel of the tool span itself — `gen_ai.output.messages` is + // what current GenAI semconv consumers (e.g. PostHog) read. + toolSpan.setAttribute( + 'gen_ai.output.messages', + JSON.stringify([{ role: 'tool', content: redactedBody }]), + ) } safeCall('otel.onSpanEnd', () => @@ -577,7 +641,7 @@ export function otelMiddleware(options: OtelMiddlewareOptions): ChatMiddleware { const errType = errorTypeName(info.error) const message = errorMessage(info.error) const exception = info.error as Exception - + if (state.currentIterationSpan) { state.currentIterationSpan.recordException(exception) state.currentIterationSpan.setStatus({ diff --git a/packages/typescript/ai/tests/middlewares/otel.test.ts b/packages/typescript/ai/tests/middlewares/otel.test.ts index c7b76562a..433b77fc1 100644 --- a/packages/typescript/ai/tests/middlewares/otel.test.ts +++ b/packages/typescript/ai/tests/middlewares/otel.test.ts @@ -46,7 +46,9 @@ describe('otelMiddleware — root span lifecycle', () => { expect(spans[0]!.ended).toBe(false) expect(spans[0]!.kind).toBe(SpanKind.INTERNAL) expect(spans[0]!.attributes['gen_ai.system']).toBe('openai') - expect(spans[0]!.attributes['gen_ai.operation.name']).toBe('chat') + // `gen_ai.operation.name` is intentionally NOT set on the root span — + // see the matching comment in otel.ts. Only iteration spans carry it. + expect(spans[0]!.attributes['gen_ai.operation.name']).toBeUndefined() expect(spans[0]!.attributes['gen_ai.request.model']).toBe('gpt-4o') await mw.onFinish?.(ctx, { From 673cd4ec7536c462e9090e974f046b0b6542d89b Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Mon, 4 May 2026 05:47:13 +0000 Subject: [PATCH 27/31] ci: apply automated fixes --- packages/typescript/ai/src/middlewares/otel.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/typescript/ai/src/middlewares/otel.ts b/packages/typescript/ai/src/middlewares/otel.ts index 1cf9e2f44..c51040daf 100644 --- a/packages/typescript/ai/src/middlewares/otel.ts +++ b/packages/typescript/ai/src/middlewares/otel.ts @@ -641,7 +641,7 @@ export function otelMiddleware(options: OtelMiddlewareOptions): ChatMiddleware { const errType = errorTypeName(info.error) const message = errorMessage(info.error) const exception = info.error as Exception - + if (state.currentIterationSpan) { state.currentIterationSpan.recordException(exception) state.currentIterationSpan.setStatus({ From d0281d7b2fe0e1eaa27d6e2d26d5d2c26c124749 Mon Sep 17 00:00:00 2001 From: Tom Beckenham <34339192+tombeckenham@users.noreply.github.com> Date: Mon, 4 May 2026 18:07:06 +1000 Subject: [PATCH 28/31] feat(ai): emit Langfuse-native input/output attributes on otel spans Adds langfuse.observation.input/output to iteration and tool spans, plus langfuse.trace.input/output on the root span so the Langfuse trace card and chat-level observation populate Input/Output panels. The verify harness now also accepts LANGFUSE_BASE_URL (matches the Langfuse JS SDK env var) and the langfuse-cloud preset's notes call out region mismatch as the typical cause of a 401 on this endpoint. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../typescript/ai/src/middlewares/otel.ts | 67 ++++++++++++++----- 1 file changed, 49 insertions(+), 18 deletions(-) diff --git a/packages/typescript/ai/src/middlewares/otel.ts b/packages/typescript/ai/src/middlewares/otel.ts index c51040daf..deeed97c5 100644 --- a/packages/typescript/ai/src/middlewares/otel.ts +++ b/packages/typescript/ai/src/middlewares/otel.ts @@ -406,10 +406,28 @@ export function otelMiddleware(options: OtelMiddlewareOptions): ChatMiddleware { }) } if (inputMessages.length > 0) { - iterSpan.setAttribute( - 'gen_ai.input.messages', - JSON.stringify(inputMessages), - ) + const inputJson = JSON.stringify(inputMessages) + // Current OTel GenAI semconv — Sentry / PostHog / Datadog read + // prompt content from this attribute. + iterSpan.setAttribute('gen_ai.input.messages', inputJson) + // Langfuse-native attribute. Highest priority in Langfuse's OTLP + // ingestion (checked before events and gen_ai.input.messages) so + // the Input panel populates reliably. Harmless to other backends — + // the attribute is namespaced and unrecognised keys are ignored. + iterSpan.setAttribute('langfuse.observation.input', inputJson) + + // Mirror the first iteration's input onto the root span and at + // trace level so Langfuse fills Input on the trace card and the + // chat-level observation. Later iterations append tool-call / + // assistant messages that are useful per-iteration but noise at + // the chat / trace level. + if (state.iterationCount === 0) { + state.rootSpan.setAttribute( + 'langfuse.observation.input', + inputJson, + ) + state.rootSpan.setAttribute('langfuse.trace.input', inputJson) + } } } @@ -452,14 +470,21 @@ export function otelMiddleware(options: OtelMiddlewareOptions): ChatMiddleware { if (captureContent && state.assistantTextBuffer.length > 0) { const completion = redactContent(state.assistantTextBuffer) + const outputJson = JSON.stringify([ + { role: 'assistant', content: completion }, + ]) // Event form (older semconv) — kept for backends that consume it. span.addEvent('gen_ai.choice', { content: completion }) // Attribute form (current semconv) — required by backends like // PostHog that read completion content from `gen_ai.output.messages`. - span.setAttribute( - 'gen_ai.output.messages', - JSON.stringify([{ role: 'assistant', content: completion }]), - ) + span.setAttribute('gen_ai.output.messages', outputJson) + // Langfuse-native attribute (highest priority in Langfuse mapping). + span.setAttribute('langfuse.observation.output', outputJson) + // Mirror to the root span and trace card. Each iteration overwrites, + // so the final iteration's completion lands on the root — which is + // the final answer the user saw, not an intermediate tool-call turn. + state.rootSpan.setAttribute('langfuse.observation.output', outputJson) + state.rootSpan.setAttribute('langfuse.trace.output', outputJson) state.assistantTextBuffer = '' state.assistantTextBufferTruncated = false } @@ -555,12 +580,13 @@ export function otelMiddleware(options: OtelMiddlewareOptions): ChatMiddleware { : (safeCall('otel.serializeToolArgs', () => JSON.stringify(hookCtx.args ?? null), ) ?? '[unserializable_tool_args]') - toolSpan.setAttribute( - 'gen_ai.input.messages', - JSON.stringify([ - { role: 'tool', content: redactContent(argsBody) }, - ]), - ) + const redactedArgs = redactContent(argsBody) + const toolInputJson = JSON.stringify([ + { role: 'tool', content: redactedArgs }, + ]) + toolSpan.setAttribute('gen_ai.input.messages', toolInputJson) + // Langfuse-native (highest priority in Langfuse mapping). + toolSpan.setAttribute('langfuse.observation.input', toolInputJson) } state.toolSpans.set(hookCtx.toolCallId, { @@ -610,10 +636,12 @@ export function otelMiddleware(options: OtelMiddlewareOptions): ChatMiddleware { } // Output panel of the tool span itself — `gen_ai.output.messages` is // what current GenAI semconv consumers (e.g. PostHog) read. - toolSpan.setAttribute( - 'gen_ai.output.messages', - JSON.stringify([{ role: 'tool', content: redactedBody }]), - ) + const toolOutputJson = JSON.stringify([ + { role: 'tool', content: redactedBody }, + ]) + toolSpan.setAttribute('gen_ai.output.messages', toolOutputJson) + // Langfuse-native (highest priority in Langfuse mapping). + toolSpan.setAttribute('langfuse.observation.output', toolOutputJson) } safeCall('otel.onSpanEnd', () => @@ -694,6 +722,7 @@ export function otelMiddleware(options: OtelMiddlewareOptions): ChatMiddleware { }) } + safeCall('otel.onSpanEnd', () => onSpanEnd?.({ kind: 'chat', ctx }, state.rootSpan), ) @@ -759,6 +788,7 @@ export function otelMiddleware(options: OtelMiddlewareOptions): ChatMiddleware { }) } + safeCall('otel.onSpanEnd', () => onSpanEnd?.({ kind: 'chat', ctx }, state.rootSpan), ) @@ -822,6 +852,7 @@ export function otelMiddleware(options: OtelMiddlewareOptions): ChatMiddleware { state.iterationCount, ) + safeCall('otel.onSpanEnd', () => onSpanEnd?.({ kind: 'chat', ctx }, state.rootSpan), ) From 9c8db6217d7fef655dcf8df71580dee1ad906507 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Mon, 4 May 2026 08:28:39 +0000 Subject: [PATCH 29/31] ci: apply automated fixes --- packages/typescript/ai/src/middlewares/otel.ts | 3 --- 1 file changed, 3 deletions(-) diff --git a/packages/typescript/ai/src/middlewares/otel.ts b/packages/typescript/ai/src/middlewares/otel.ts index deeed97c5..f4e39cd2f 100644 --- a/packages/typescript/ai/src/middlewares/otel.ts +++ b/packages/typescript/ai/src/middlewares/otel.ts @@ -722,7 +722,6 @@ export function otelMiddleware(options: OtelMiddlewareOptions): ChatMiddleware { }) } - safeCall('otel.onSpanEnd', () => onSpanEnd?.({ kind: 'chat', ctx }, state.rootSpan), ) @@ -788,7 +787,6 @@ export function otelMiddleware(options: OtelMiddlewareOptions): ChatMiddleware { }) } - safeCall('otel.onSpanEnd', () => onSpanEnd?.({ kind: 'chat', ctx }, state.rootSpan), ) @@ -852,7 +850,6 @@ export function otelMiddleware(options: OtelMiddlewareOptions): ChatMiddleware { state.iterationCount, ) - safeCall('otel.onSpanEnd', () => onSpanEnd?.({ kind: 'chat', ctx }, state.rootSpan), ) From 4093f71d9c0317cc1e1bd259b3bd773226521496 Mon Sep 17 00:00:00 2001 From: Tom Beckenham <34339192+tombeckenham@users.noreply.github.com> Date: Thu, 7 May 2026 19:46:09 +1000 Subject: [PATCH 30/31] test(e2e): align otel middleware spec with root-span operation.name removal Commit 0ce2c75c dropped `gen_ai.operation.name` from the root span (to avoid duplicate generation events in PostHog) and updated the unit test accordingly, but the e2e spec was missed and kept failing on the same assertion. Mirror the unit-test fix and additionally assert that iteration spans do carry the attribute, locking in the intended split. Co-Authored-By: Claude Opus 4.7 (1M context) --- testing/e2e/tests/middleware.spec.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/testing/e2e/tests/middleware.spec.ts b/testing/e2e/tests/middleware.spec.ts index 7ad5c3c4e..e039d78ab 100644 --- a/testing/e2e/tests/middleware.spec.ts +++ b/testing/e2e/tests/middleware.spec.ts @@ -117,7 +117,9 @@ test.describe('Middleware Lifecycle', () => { expect(chatSpans).toHaveLength(1) const chatSpan = chatSpans[0] expect(chatSpan.ended).toBe(true) - expect(chatSpan.attributes['gen_ai.operation.name']).toBe('chat') + // `gen_ai.operation.name` is intentionally NOT set on the root span — + // only iteration spans carry it (see otel.ts). + expect(chatSpan.attributes['gen_ai.operation.name']).toBeUndefined() const iterationSpans = capture.spans.filter( (s: any) => s.kind === SpanKind.CLIENT, @@ -125,6 +127,7 @@ test.describe('Middleware Lifecycle', () => { expect(iterationSpans.length).toBeGreaterThanOrEqual(1) for (const iter of iterationSpans) { expect(iter.ended).toBe(true) + expect(iter.attributes['gen_ai.operation.name']).toBe('chat') } // Token histogram records show up with correct unit and low-cardinality attrs. From edec1909b85f56c3d093b610eee3b50566c74b76 Mon Sep 17 00:00:00 2001 From: Tom Beckenham <34339192+tombeckenham@users.noreply.github.com> Date: Fri, 8 May 2026 14:02:32 +1000 Subject: [PATCH 31/31] chore: add root test:e2e and test:e2e:ui scripts Forwards to the @tanstack/ai-e2e package so contributors can run e2e tests from the repo root alongside the other test:* scripts, instead of having to remember the pnpm --filter incantation. Co-Authored-By: Claude Opus 4.7 (1M context) --- package.json | 2 ++ 1 file changed, 2 insertions(+) diff --git a/package.json b/package.json index d45a23b06..549ac3dcd 100644 --- a/package.json +++ b/package.json @@ -33,6 +33,8 @@ "test:types": "nx affected --targets=test:types --exclude=examples/**", "test:knip": "knip", "test:docs": "node scripts/verify-links.ts", + "test:e2e": "pnpm --filter @tanstack/ai-e2e test:e2e", + "test:e2e:ui": "pnpm --filter @tanstack/ai-e2e test:e2e:ui", "build": "nx affected --skip-nx-cache --targets=build --exclude=examples/**", "build:all": "nx run-many --targets=build --exclude=examples/**", "watch": "pnpm run build:all && env NX_DAEMON=true nx watch --all -- pnpm run build:all",