Skip to content

feat(ai): activity-agnostic observability hook for media activities (#720)#760

Open
season179 wants to merge 3 commits into
TanStack:mainfrom
season179:feat/activity-observers
Open

feat(ai): activity-agnostic observability hook for media activities (#720)#760
season179 wants to merge 3 commits into
TanStack:mainfrom
season179:feat/activity-observers

Conversation

@season179

@season179 season179 commented Jun 15, 2026

Copy link
Copy Markdown
Contributor

Summary

Closes #720.

Adds an activity-agnostic observability hook so non-chat activities (image, video, audio, speech, transcription) get the same OpenTelemetry coverage chat already has through otelMiddleware.

An ActivityObserver is a small contract with three optional callbacks — onStart, onFinish, onError — whose payloads are discriminated by an activity field. Observers are registered per call via a new observers option, awaited in registration order, and strictly non-fatal: a throwing observer is logged and skipped, never breaking the activity.

The first observer shipped is otelObserver(), exported from a new @tanstack/ai/observability subpath. It opens one gen_ai.* span per call, tagged with the right gen_ai.operation.name for the activity (image_generation, video_generation, audio_generation, text_to_speech, transcription), and reuses the shared usage-attribute set so cost, totals, cache/reasoning detail, duration billing, and media unit counts land identically across activities. When a Meter is supplied it also records the gen_ai.client.operation.duration histogram — the same metric the chat middleware emits.

Stacked on #747

This is stacked on #747 (feat/otel-full-usage-emission), which introduces the shared usageAttributes() helper this PR consumes. Merge #747 first.

Until #747 merges, the diff below includes its single commit (c7df2a33, "emit full usage on otel spans") in addition to the #720 work — review that commit in #747, not here. Once #747 lands in main, this PR's diff narrows automatically to just the activity-observer changes.

Test plan

  • Unit: packages/ai/tests/observability/ — observer notification semantics, otelObserver span/metric behavior, and a regression test for the streaming-video abandonment case (20 tests, green).
  • E2E: testing/e2e route + spec exercising otelObserver on generateImage through the mock transport.
  • pnpm test:pr green locally (sherif, knip, docs, eslint, lib, types, build) plus the E2E suite.

Summary by CodeRabbit

  • New Features
    • Added activity observers for media generation (image, audio, speech, transcription, video) with start/finish/error callbacks.
    • Added otelObserver() to automatically emit OpenTelemetry gen_ai.* spans per activity, including correct operation names and optional duration metrics.
    • Exposed the new observability API via the @tanstack/ai/observability subpath.
  • Bug Fixes
    • Improved observer reliability for streaming video, including proper cancellation handling and non-fatal observer errors.
  • Documentation
    • Expanded OpenTelemetry documentation for media observability and additional usage/cost attributes.

…g details)

otelMiddleware only emitted gen_ai.usage.input_tokens/output_tokens even
though TokenUsage already carries provider-reported cost, total tokens,
cache/reasoning breakdowns, and duration-based billing. Backends like
PostHog had to re-derive cost from their own price tables, losing cache
discounts and gateway markup (OpenRouter), and duration-billed activities
had no cost signal at all.

A shared usageAttributes() helper now builds the full guarded attribute
set at all three emission sites (RUN_FINISHED chunk, onUsage, onFinish
rollup):

- gen_ai.usage.total_tokens / gen_ai.usage.cost (de-facto extensions
  consumed directly by PostHog and LiteLLM-style backends)
- gen_ai.usage.cache_read.input_tokens, cache_creation.input_tokens,
  reasoning.output_tokens (official GenAI semconv names)
- tanstack.ai.usage.duration_seconds and the upstream cost split
  (no semconv equivalent exists)

E2E: new /api/otel-usage route drives the existing openai-usage-details
and openrouter-cost aimock mounts through otelMiddleware with a local
capture tracer; middleware.spec.ts asserts the attributes land on
iteration and root spans.

Fixes TanStack#721
…anStack#720)

Add an `ActivityObserver` contract (onStart/onFinish/onError, payload
discriminated by `activity`) registerable on any activity via a new
`observers` option. Observers are awaited in order and strictly non-fatal —
a throwing observer is logged and skipped, never breaking the activity.

Wire it into generateImage, generateVideo, generateAudio, generateSpeech,
and generateTranscription. Chat stays on otelMiddleware; the ActivityKind
type includes `chat` for a future unified contract.

Ship `otelObserver()` on a new `@tanstack/ai/observability` subpath: one
gen_ai.* span per call tagged with the correct gen_ai.operation.name,
reusing the shared usage-attribute set (now including
tanstack.ai.usage.units_billed) and the gen_ai.client.operation.duration
histogram when a Meter is supplied. The observer types are exported from the
package root while the otelObserver value lives only on the subpath, so
importing @tanstack/ai never requires the optional @opentelemetry/api peer.

Streaming generateVideo covers the full create/poll/complete lifecycle and
ends its span even if the consumer abandons the stream mid-poll;
non-streaming generateVideo records a submit span.
@coderabbitai

coderabbitai Bot commented Jun 15, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: c73b5173-5d5e-481e-a485-7c817c63f14a

📥 Commits

Reviewing files that changed from the base of the PR and between 2dd2912 and f2f0e61.

📒 Files selected for processing (5)
  • docs/advanced/otel.md
  • packages/ai/src/activities/generateVideo/index.ts
  • packages/ai/src/observability/otel.ts
  • packages/ai/tests/observability/activity-observers.test.ts
  • testing/e2e/src/routes/api.otel-media.ts
✅ Files skipped from review due to trivial changes (1)
  • docs/advanced/otel.md
🚧 Files skipped from review as they are similar to previous changes (4)
  • packages/ai/tests/observability/activity-observers.test.ts
  • testing/e2e/src/routes/api.otel-media.ts
  • packages/ai/src/activities/generateVideo/index.ts
  • packages/ai/src/observability/otel.ts

📝 Walkthrough

Walkthrough

Adds activity-agnostic observability to @tanstack/ai by introducing ActivityObserver hooks (onStart/onFinish/onError) on all five media activities, a new otelObserver() function in a @tanstack/ai/observability subpath, and a shared usageAttributes() helper that also expands otelMiddleware's span emission to include full TokenUsage fields (cost, cache, reasoning, billing).

Changes

Activity Observability and Full OTel Usage

Layer / File(s) Summary
Observability types and shared utilities
packages/ai/src/observability/types.ts, packages/ai/src/utilities/errors.ts, packages/ai/src/utilities/numbers.ts
Defines ActivityKind, ActivityEventBase, and three discriminated event interfaces (ActivityStartEvent, ActivityFinishEvent, ActivityErrorEvent) plus the ActivityObserver hook contract. Adds firstNumber(), errorMessage(), and errorTypeName() shared utilities used by observability and error reporting.
Notification dispatch and usageAttributes helper
packages/ai/src/observability/notify.ts, packages/ai/src/observability/usage-attributes.ts
Implements runHooks and the three exported notifyObserver* wrappers that sequentially await each observer's hook while suppressing errors. Adds usageAttributes() that builds the full gen_ai.usage.* / tanstack.ai.usage.* attribute map from TokenUsage.
otelMiddleware: adopt usageAttributes for full usage emission
packages/ai/src/middlewares/otel.ts
Replaces manual per-token attribute assignments in three callsites (onChunk, onUsage, onFinish) with usageAttributes(), expanding emitted span attributes to include total tokens, cost, cache/reasoning breakdowns, and TanStack billing fields. Removes local helper duplicates.
otelObserver implementation and subpath entry
packages/ai/src/observability/otel.ts, packages/ai/src/observability/index.ts, packages/ai/src/index.ts, packages/ai/package.json, packages/ai/vite.config.ts
Implements otelObserver() mapping ActivityKind to gen_ai.operation.name, managing per-requestId CLIENT spans, optional duration histograms, and attributeEnricher/spanNameFormatter extension points. Registers ./observability as a package subpath and exports observer types from the package root.
Observer wiring in all five media activities
packages/ai/src/activities/generateAudio/index.ts, packages/ai/src/activities/generateImage/index.ts, packages/ai/src/activities/generateSpeech/index.ts, packages/ai/src/activities/generateTranscription/index.ts, packages/ai/src/activities/generateVideo/index.ts
Adds observers?: Array<ActivityObserver> to each activity's options type and wires notifyObserverStart/notifyObserverFinish/notifyObserverError around adapter calls. Video streaming adds a settled flag plus a finally block emitting a 'cancelled' error when the stream is abandoned mid-poll.
Unit tests
packages/ai/tests/middlewares/otel.test.ts, packages/ai/tests/observability/otel-observer.test.ts, packages/ai/tests/observability/activity-observers.test.ts
Adds otelMiddleware full usage fixture/assertion tests, otelObserver unit tests (span creation, operation name mapping, requestId isolation, formatter/enricher), and activity-observer integration tests for all five media activities including streaming abandon/cancellation and throwing-observer resilience.
E2E routes and Playwright specs
testing/e2e/src/routes/api.otel-usage.ts, testing/e2e/src/routes/api.otel-media.ts, testing/e2e/src/routeTree.gen.ts, testing/e2e/tests/middleware.spec.ts
Adds /api/otel-usage (in-memory tracer running otelMiddleware over OpenAI/OpenRouter) and /api/otel-media (running generateImage with otelObserver). Playwright specs assert full usage fields on chat spans and a single image_generation CLIENT span for media.
Documentation and changesets
docs/advanced/otel.md, docs/config.json, .changeset/activity-observers.md, .changeset/otel-full-usage-emission.md
Extends OTel docs with additional gen_ai.usage.* attribute rows, conditional emission notes, and a new "Beyond chat: media activities" section. Adds two changeset files declaring minor version bumps for both features.

Sequence Diagram(s)

sequenceDiagram
  participant Caller
  participant MediaActivity as Media Activity<br/>(generateImage/etc.)
  participant notifyObserver
  participant ActivityObserver as ActivityObserver<br/>(e.g. otelObserver)
  participant Adapter

  Caller->>MediaActivity: generateImage({ observers: [otelObserver] })
  MediaActivity->>notifyObserver: notifyObserverStart(observers, startEvent)
  notifyObserver->>ActivityObserver: onStart(ActivityStartEvent)
  ActivityObserver->>ActivityObserver: startSpan(gen_ai.operation.name=image_generation)

  MediaActivity->>Adapter: adapter.generateImage(request)
  Adapter-->>MediaActivity: result + usage

  MediaActivity->>notifyObserver: notifyObserverFinish(observers, finishEvent + durationMs + usage)
  notifyObserver->>ActivityObserver: onFinish(ActivityFinishEvent)
  ActivityObserver->>ActivityObserver: span.setAttributes(usageAttributes)<br/>span.end()<br/>recordDuration()

  MediaActivity-->>Caller: result
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

  • TanStack/ai#723: The otelObserver/usageAttributes implementation in this PR emits tanstack.ai.usage.units_billed, which reads TokenUsage.unitsBilled introduced in that PR.
  • TanStack/ai#242: This PR's usageAttributes() and otelMiddleware emission logic depends on the TokenUsage shape (totals, cost, cache, reasoning, duration, and provider details) updated in that PR.

Suggested reviewers

  • tombeckenham
  • jherr

🐇 No span left behind, no activity unseen,
From image to audio, observers convene.
onStart, onFinish, onError they wait,
While gen_ai.* attributes propagate.
The rabbit hops through each lifecycle hook,
And leaves a tidy OTel trail in its wake! 🎉

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 59.09% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately describes the main change: adding activity-agnostic observability hooks for media activities, matching the core feature introduced in the PR.
Description check ✅ Passed The PR description is comprehensive and follows the template structure, explaining changes, testing approach, and acknowledging the dependency on PR #747, but lacks explicit checklist confirmations.
Linked Issues check ✅ Passed All coding requirements from issue #720 are met: ActivityObserver interface with onStart/onFinish/onError callbacks is implemented, observers are registered per-call via options, non-fatal error handling is in place, and otelObserver() with proper operation name mapping across all media activities is shipped.
Out of Scope Changes check ✅ Passed All changes are directly scoped to the PR objectives: activity observer implementation, otelObserver shipping, usage attributes integration, media activity instrumentation, and supporting documentation/tests. Includes expected dependency on PR #747 changes.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@season179 season179 marked this pull request as ready for review June 15, 2026 03:35

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@docs/advanced/otel.md`:
- Line 194: The openaiImage adapter example in the documentation is using an
outdated model identifier. In the openaiImage function call, replace the model
parameter from 'gpt-image-1' to 'gpt-image-2' to reflect the latest OpenAI image
generation model as defined in the adapter's model-meta.ts file.

In `@packages/ai/src/activities/generateVideo/index.ts`:
- Around line 375-385: The observer lifecycle is mismanaged: notifyObserverStart
at lines 375 and around 395-397 occurs after yields, allowing early breaks to
emit onError without onStart, and notifyObserverFinish at lines 459-472 occurs
after yielding generation:result, causing the finally block (lines 518-539) to
see settled === false and emit a synthetic cancellation error. Move both
notifyObserverStart calls to execute before any yields in the function, and move
notifyObserverFinish to execute before the generation:result yield (or set
settled === true before yielding), so that the finally block's settled flag
accurately reflects whether the observer lifecycle was properly started and
completed.

In `@testing/e2e/src/routes/api.otel-media.ts`:
- Around line 117-127: The payload validation is missing before destructuring
properties from the data object. Currently, if body.forwardedProps, body.data,
or body is null or a primitive value, or if required fields (prompt, provider)
are missing, the destructuring assignment will throw an unhandled error
resulting in a 500 response instead of the proper error response. Add validation
logic before the destructuring of prompt, provider, testId, and aimockPort from
the data object to check that data is a valid object with the required
properties, and return { ok: false, error } with an appropriate error message if
validation fails. Only proceed to createImageAdapter and
createLocalCaptureTracer after successful validation.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 3e1da7aa-1e1b-402b-98f8-1b412af95984

📥 Commits

Reviewing files that changed from the base of the PR and between 984ac3c and 2dd2912.

📒 Files selected for processing (27)
  • .changeset/activity-observers.md
  • .changeset/otel-full-usage-emission.md
  • docs/advanced/otel.md
  • docs/config.json
  • packages/ai/package.json
  • packages/ai/src/activities/generateAudio/index.ts
  • packages/ai/src/activities/generateImage/index.ts
  • packages/ai/src/activities/generateSpeech/index.ts
  • packages/ai/src/activities/generateTranscription/index.ts
  • packages/ai/src/activities/generateVideo/index.ts
  • packages/ai/src/index.ts
  • packages/ai/src/middlewares/otel.ts
  • packages/ai/src/observability/index.ts
  • packages/ai/src/observability/notify.ts
  • packages/ai/src/observability/otel.ts
  • packages/ai/src/observability/types.ts
  • packages/ai/src/observability/usage-attributes.ts
  • packages/ai/src/utilities/errors.ts
  • packages/ai/src/utilities/numbers.ts
  • packages/ai/tests/middlewares/otel.test.ts
  • packages/ai/tests/observability/activity-observers.test.ts
  • packages/ai/tests/observability/otel-observer.test.ts
  • packages/ai/vite.config.ts
  • testing/e2e/src/routeTree.gen.ts
  • testing/e2e/src/routes/api.otel-media.ts
  • testing/e2e/src/routes/api.otel-usage.ts
  • testing/e2e/tests/middleware.spec.ts

Comment thread docs/advanced/otel.md Outdated
Comment on lines +375 to +385
await notifyObserverStart(
observers,
{
activity: 'video',
requestId,
provider: adapter.name,
model,
modelOptions,
},
logger,
)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Observer terminal state can be misreported as cancelled on successful completion.

At Line 459, notifyObserverFinish runs after yielding generation:result; if the consumer breaks at that yield, finally (Line 519) still sees settled === false and emits a synthetic cancellation error. Also, notifyObserverStart is currently after the first yield (Line 375), so an early break can produce onError without onStart.

Suggested fix
-  yield {
-    type: 'RUN_STARTED',
-    runId,
-    threadId,
-    timestamp: Date.now(),
-  } as StreamChunk
-
   await notifyObserverStart(
     observers,
     {
       activity: 'video',
       requestId,
       provider: adapter.name,
       model,
       modelOptions,
     },
     logger,
   )
+
+  yield {
+    type: 'RUN_STARTED',
+    runId,
+    threadId,
+    timestamp: Date.now(),
+  } as StreamChunk
...
-        yield {
-          type: 'CUSTOM',
-          name: 'generation:result',
-          value: {
-            jobId: jobResult.jobId,
-            status: 'completed',
-            url: urlResult.url,
-            expiresAt: urlResult.expiresAt,
-            ...(urlResult.usage ? { usage: urlResult.usage } : {}),
-          },
-          timestamp: Date.now(),
-        } as StreamChunk
-
+        settled = true
         await notifyObserverFinish(
           observers,
           {
             activity: 'video',
             requestId,
             provider: adapter.name,
             model,
             durationMs: Date.now() - obsStartTime,
             usage: urlResult.usage,
           },
           logger,
         )
-        settled = true
+
+        yield {
+          type: 'CUSTOM',
+          name: 'generation:result',
+          value: {
+            jobId: jobResult.jobId,
+            status: 'completed',
+            url: urlResult.url,
+            expiresAt: urlResult.expiresAt,
+            ...(urlResult.usage ? { usage: urlResult.usage } : {}),
+          },
+          timestamp: Date.now(),
+        } as StreamChunk

Also applies to: 395-397, 459-472, 518-539

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/ai/src/activities/generateVideo/index.ts` around lines 375 - 385,
The observer lifecycle is mismanaged: notifyObserverStart at lines 375 and
around 395-397 occurs after yields, allowing early breaks to emit onError
without onStart, and notifyObserverFinish at lines 459-472 occurs after yielding
generation:result, causing the finally block (lines 518-539) to see settled ===
false and emit a synthetic cancellation error. Move both notifyObserverStart
calls to execute before any yields in the function, and move
notifyObserverFinish to execute before the generation:result yield (or set
settled === true before yielding), so that the finally block's settled flag
accurately reflects whether the observer lifecycle was properly started and
completed.

Comment on lines +117 to +127
const body = await request.json()
const data = body.forwardedProps ?? body.data ?? body
const { prompt, provider, testId, aimockPort } = data as {
prompt: string
provider: Provider
testId?: string
aimockPort?: number
}

const adapter = createImageAdapter(provider, aimockPort, testId)
const { tracer, spans } = createLocalCaptureTracer()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Guard payload shape before property access/destructuring.

body.forwardedProps ?? body.data ?? body and the typed destructure run before the handler’s error response path. A null/primitive payload (or missing required fields) can throw and surface as an unhandled 500 instead of { ok: false, error }.

Suggested fix
-        const body = await request.json()
-        const data = body.forwardedProps ?? body.data ?? body
-        const { prompt, provider, testId, aimockPort } = data as {
-          prompt: string
-          provider: Provider
-          testId?: string
-          aimockPort?: number
-        }
-
-        const adapter = createImageAdapter(provider, aimockPort, testId)
+        let prompt: string
+        let provider: Provider
+        let testId: string | undefined
+        let aimockPort: number | undefined
+        let adapter: ReturnType<typeof createImageAdapter>
+
+        try {
+          const body: unknown = await request.json()
+          const container =
+            body && typeof body === 'object'
+              ? ((body as Record<string, unknown>).forwardedProps ??
+                (body as Record<string, unknown>).data ??
+                body)
+              : null
+          if (!container || typeof container !== 'object') {
+            throw new Error('Invalid request body')
+          }
+          const raw = container as Record<string, unknown>
+          if (typeof raw.prompt !== 'string' || typeof raw.provider !== 'string') {
+            throw new Error('Missing required fields: prompt/provider')
+          }
+          prompt = raw.prompt
+          provider = raw.provider as Provider
+          testId = typeof raw.testId === 'string' ? raw.testId : undefined
+          aimockPort = typeof raw.aimockPort === 'number' ? raw.aimockPort : undefined
+          adapter = createImageAdapter(provider, aimockPort, testId)
+        } catch (error) {
+          return new Response(
+            JSON.stringify({
+              ok: false,
+              error: error instanceof Error ? error.message : String(error),
+            }),
+            { status: 200, headers: { 'Content-Type': 'application/json' } },
+          )
+        }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@testing/e2e/src/routes/api.otel-media.ts` around lines 117 - 127, The payload
validation is missing before destructuring properties from the data object.
Currently, if body.forwardedProps, body.data, or body is null or a primitive
value, or if required fields (prompt, provider) are missing, the destructuring
assignment will throw an unhandled error resulting in a 500 response instead of
the proper error response. Add validation logic before the destructuring of
prompt, provider, testId, and aimockPort from the data object to check that data
is a valid object with the required properties, and return { ok: false, error }
with an appropriate error message if validation fails. Only proceed to
createImageAdapter and createLocalCaptureTracer after successful validation.

Move notifyObserverFinish + settled=true ahead of the generation:result yield
in streaming generateVideo. Previously they ran after the yield, so a consumer
that stopped reading once it had the result (without pulling RUN_FINISHED)
tripped the finally cleanup with settled still false — reporting a spurious
cancellation onError and never firing onFinish. Add a regression test for that
abandonment case.

Also use the latest gpt-image-2 model id in the otel docs and otelObserver
JSDoc examples, and clear lint (import order, redundant casts) in the
otel-media e2e route.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat(observability): activity-agnostic observability hook for non-chat activities (image/video/audio/speech/transcription)

1 participant