Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
39 commits
Select commit Hold shift + click to select a range
eb83d6b
feat(vercel-ai-sdk): add AI SDK v7 integration
hassiebp Apr 22, 2026
df19078
test(vercel-ai-sdk): add otel parity coverage
hassiebp Apr 22, 2026
29bfbed
refactor(vercel-ai-sdk): delegate to ai sdk otel
hassiebp Apr 24, 2026
1d95618
test: cover vercel ai sdk propagation
hassiebp Apr 24, 2026
86e4879
test: add vercel ai sdk v7 e2e coverage
hassiebp Apr 24, 2026
13cc435
chore: remove legacy ai sdk v7 tool shims
hassiebp Apr 24, 2026
3c9cbe9
refactor(vercel-ai-sdk): use ai sdk otel enrichSpan
hassiebp May 8, 2026
4018e14
chore(vercel-ai-sdk): use published ai sdk canary
hassiebp May 12, 2026
190feb0
fix(vercel-ai-sdk): align canary telemetry usage
hassiebp May 14, 2026
81b643f
fix(vercel-ai-sdk): align v7 e2e assertions
hassiebp May 14, 2026
81f1546
fix(vercel-ai-sdk): relax v7 e2e server shape checks
hassiebp May 14, 2026
0c3c378
fix(vercel-ai-sdk): align v7 prompt and media telemetry
hassiebp May 14, 2026
1a86c7a
test(vercel-ai-sdk): harden context test setup
hassiebp May 14, 2026
fbcae9f
test(vercel-ai-sdk): relax v7 stream e2e timeout
hassiebp May 14, 2026
00a5bd3
docs(vercel-ai-sdk): fix package readme links
hassiebp May 14, 2026
501e5c3
fix(vercel-ai-sdk): address review feedback
hassiebp May 15, 2026
bb4a350
push
hassiebp May 16, 2026
918b77f
fix(vercel-ai-sdk): align runtime context metadata
hassiebp May 16, 2026
40be5b9
fix(vercel-ai-sdk): align telemetry callback names
hassiebp May 16, 2026
69d08ad
fix(vercel-ai-sdk): send valid media fixtures in e2e
hassiebp May 16, 2026
148389c
fix(vercel-ai-sdk): align e2e openai provider version
hassiebp May 16, 2026
6ad8555
push
hassiebp May 18, 2026
77fb543
push
hassiebp May 18, 2026
7a40b16
push
hassiebp May 18, 2026
8864776
push
hassiebp May 19, 2026
39eff5f
Merge branch 'main' into codex/add-vercel-ai-sdk-integration
hassiebp May 25, 2026
7bede0f
Merge branch 'main' into codex/add-vercel-ai-sdk-integration
hassiebp May 25, 2026
52d42b6
chore: release v5.4.0-beta.0
May 25, 2026
dfd4159
push
hassiebp May 25, 2026
c800441
update package lock
hassiebp May 26, 2026
dbdebb1
lock ai dep
hassiebp May 26, 2026
91d849f
chore: release v5.4.0-beta.1
hassiebp May 26, 2026
f79096a
fix(vercel-ai-sdk): align latest ai sdk telemetry hooks
hassiebp Jun 12, 2026
b51f773
Merge branch 'main' into codex/add-vercel-ai-sdk-integration
hassiebp Jun 18, 2026
538fb91
push
hassiebp Jun 18, 2026
5f88c8c
push
hassiebp Jun 18, 2026
81bfa1f
chore: release v5.5.0-beta.3
hassiebp Jun 18, 2026
beb0ff7
Merge branch 'main' into codex/add-vercel-ai-sdk-integration
hassiebp Jun 26, 2026
1f4d6a9
chore(vercel-ai-sdk): target ai sdk v7 ga
hassiebp Jun 26, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 9 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,14 +15,15 @@ Modular mono repo for the Langfuse JS/TS client libraries.
> [!IMPORTANT]
> The SDK was rewritten in v5 and released in March 2026. Refer to the [v5 migration guide](https://langfuse.com/docs/observability/sdk/upgrade-path/js-v4-to-v5) for instructions on updating your code.

| Package | NPM | Description | Environments |
| ------------------------------------------- | ----------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------- | ------------ |
| [@langfuse/client](./packages/client) | [![NPM](https://img.shields.io/npm/v/@langfuse/client.svg)](https://www.npmjs.com/package/@langfuse/client) | Langfuse API client for universal JavaScript environments | Universal JS |
| [@langfuse/browser](./packages/browser) | [![NPM](https://img.shields.io/npm/v/@langfuse/browser.svg)](https://www.npmjs.com/package/@langfuse/browser) | Langfuse browser SDK for public-key score ingestion | Browser |
| [@langfuse/tracing](./packages/tracing) | [![NPM](https://img.shields.io/npm/v/@langfuse/tracing.svg)](https://www.npmjs.com/package/@langfuse/tracing) | Langfuse instrumentation methods based on OpenTelemetry | Node.js 20+ |
| [@langfuse/otel](./packages/otel) | [![NPM](https://img.shields.io/npm/v/@langfuse/otel.svg)](https://www.npmjs.com/package/@langfuse/otel) | Langfuse OpenTelemetry export helpers | Node.js 20+ |
| [@langfuse/openai](./packages/openai) | [![NPM](https://img.shields.io/npm/v/@langfuse/openai.svg)](https://www.npmjs.com/package/@langfuse/openai) | Langfuse integration for OpenAI SDK | Universal JS |
| [@langfuse/langchain](./packages/langchain) | [![NPM](https://img.shields.io/npm/v/@langfuse/langchain.svg)](https://www.npmjs.com/package/@langfuse/langchain) | Langfuse integration for LangChain | Universal JS |
| Package | NPM | Description | Environments |
| --------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------- | ------------ |
| [@langfuse/client](./packages/client) | [![NPM](https://img.shields.io/npm/v/@langfuse/client.svg)](https://www.npmjs.com/package/@langfuse/client) | Langfuse API client for universal JavaScript environments | Universal JS |
| [@langfuse/browser](./packages/browser) | [![NPM](https://img.shields.io/npm/v/@langfuse/browser.svg)](https://www.npmjs.com/package/@langfuse/browser) | Langfuse browser SDK for public-key score ingestion | Browser |
| [@langfuse/tracing](./packages/tracing) | [![NPM](https://img.shields.io/npm/v/@langfuse/tracing.svg)](https://www.npmjs.com/package/@langfuse/tracing) | Langfuse instrumentation methods based on OpenTelemetry | Node.js 20+ |
| [@langfuse/otel](./packages/otel) | [![NPM](https://img.shields.io/npm/v/@langfuse/otel.svg)](https://www.npmjs.com/package/@langfuse/otel) | Langfuse OpenTelemetry export helpers | Node.js 20+ |
| [@langfuse/openai](./packages/openai) | [![NPM](https://img.shields.io/npm/v/@langfuse/openai.svg)](https://www.npmjs.com/package/@langfuse/openai) | Langfuse integration for OpenAI SDK | Universal JS |
| [@langfuse/langchain](./packages/langchain) | [![NPM](https://img.shields.io/npm/v/@langfuse/langchain.svg)](https://www.npmjs.com/package/@langfuse/langchain) | Langfuse integration for LangChain | Universal JS |
| [@langfuse/vercel-ai-sdk](./packages/vercel-ai-sdk) | [![NPM](https://img.shields.io/npm/v/@langfuse/vercel-ai-sdk.svg)](https://www.npmjs.com/package/@langfuse/vercel-ai-sdk) | Langfuse integration for AI SDK v7 | Universal JS |
Comment thread
hassiebp marked this conversation as resolved.

## Documentation

Expand Down
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@
},
"devDependencies": {
"@ai-sdk/anthropic": "^2",
"@ai-sdk/openai": "^2",
"@ai-sdk/openai": "4.0.0",
"@eslint/js": "^9.32.0",
"@langchain/core": "^1.1.24",
"@langchain/langgraph": "^1.1.4",
Expand All @@ -53,7 +53,7 @@
"@types/mustache": "^4.2.6",
"@typescript-eslint/eslint-plugin": "^8.36.0",
"@typescript-eslint/parser": "^8.39.0",
"ai": "^5",
"ai": "7.0.2",
"autoevals": "^0.0.131",
"dotenv": "^17.2.0",
"eslint": "^9.32.0",
Expand Down
167 changes: 136 additions & 31 deletions packages/otel/src/MediaService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
getGlobalLogger,
uploadMedia,
} from "@langfuse/core";
import type { MediaContentType } from "@langfuse/core";
import { ReadableSpan } from "@opentelemetry/sdk-trace-base";

export class MediaService {
Expand Down Expand Up @@ -96,8 +97,8 @@ export class MediaService {
}
}

// Handle media from Vercel AI SDK
if (span.instrumentationScope.name === "ai") {
// Handle media from Vercel AI SDK v6 and AI SDK v7.
if (["ai", "gen_ai"].includes(span.instrumentationScope.name)) {
Comment thread
hassiebp marked this conversation as resolved.
Comment on lines +100 to +101

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔴 AI SDK v6/v7 media handling is skipped whenever a user passes a custom tracer to LangfuseVercelAiSdkIntegration, because MediaService.ts:101 gates the entire media block on ['ai','gen_ai'].includes(span.instrumentationScope.name) — but a user-supplied tracer (a publicly supported option in LangfuseVercelAiSdkIntegrationOptions) sets the scope name to whatever the user picked. The spans still get exported (the rest of the pipeline correctly classifies them by attribute presence via isGenAISpan), but base64 file/image payloads stay inline in gen_ai.input.messages / gen_ai.output.messages instead of being uploaded to Langfuse media storage. Fix: gate by attribute presence (e.g. reuse isGenAISpan from span-filter.ts, or check any gen_ai.*/ai.* key) so the classification matches the rest of the export pipeline.

Extended reasoning...

What the bug is

LangfuseVercelAiSdkIntegrationOptions (packages/vercel-ai-sdk/src/types.ts:9) exposes a public tracer?: Tracer option, which LangfuseVercelAiSdkIntegration.ts:37 forwards verbatim to the @ai-sdk/otel OpenTelemetry constructor. In the installed @ai-sdk/otel@1.0.0-beta.125, the constructor resolves this.tracer = options.tracer ?? trace.getTracer('gen_ai') — so every span produced by every this.tracer.startSpan(...) call inherits the user's chosen instrumentation scope name when a custom tracer is supplied.

The AI SDK semantic-convention attribute keys (gen_ai.input.messages, gen_ai.output.messages, gen_ai.operation.name, etc.) are hardcoded string literals on those span attributes, so they are emitted regardless of which tracer created the span. LangfuseSpanProcessor correctly exports those spans because span-filter.ts:32-36 isGenAISpan checks attribute presence (Object.keys(span.attributes).some(k => k.startsWith('gen_ai.'))), not the scope name.

But packages/otel/src/MediaService.ts:100-101 gates the entire v6 + v7 media-handling block on ["ai", "gen_ai"].includes(span.instrumentationScope.name). With a custom tracer like new LangfuseVercelAiSdkIntegration({ tracer: trace.getTracer("my-app") }), the scope is my-app, the check fails, and the whole v6 (ai.prompt.messages/ai.prompt) AND v7 (gen_ai.input.messages/gen_ai.output.messages) media replacement block is skipped. Base64 file/image payloads stay inline in the exported attributes — never uploaded to Langfuse media storage, never replaced with @@@langfuseMedia:...@@@ tags. The Langfuse UI then renders huge raw base64 strings instead of the inlined-media tag.

Step-by-step proof

  1. User writes new LangfuseVercelAiSdkIntegration({ tracer: trace.getTracer("my-app") }) (a documented option on LangfuseVercelAiSdkIntegrationOptions).
  2. That tracer reaches @ai-sdk/otel OpenTelemetry. Every span it creates carries instrumentationScope.name === "my-app".
  3. The user calls generateText({ messages: [{ role: "user", content: [{ type: "file", mediaType: "application/pdf", data: "<base64-pdf>" }, ...] }] }). @ai-sdk/otel serializes the messages into gen_ai.input.messages as JSON [{ role: "user", parts: [{ type: "blob", content: "<base64-pdf>", mime_type: "application/pdf" }, ...] }].
  4. LangfuseSpanProcessor calls isDefaultExportSpan(span)isGenAISpan(span) returns true (attributes start with gen_ai.), span is exported.
  5. MediaService.process(span) reaches line 100: ["ai", "gen_ai"].includes("my-app")false. The entire block at lines 100-232 is skipped.
  6. The exported attribute still contains the raw base64. No upload happens. Langfuse UI shows the base64 blob inline.

Replacing the check with isGenAISpan(span) (or any gen_ai.* attribute presence test) keeps the existing default behavior and fixes the custom-tracer case — and matches the asymmetry the verifiers flagged: the rest of the pipeline already classifies these spans by attribute presence; only this one site uses scope name.

Why it survived review

The tracer option is not exercised by the README examples, the package tests, the integration test, or the v7 e2e suite — every test path uses the default gen_ai scope. So the gate check works in the default flow and never triggers in CI. The codex bot raised the same issue in this PR's inline comment 3435755845 (2026-06-18T12:29:46Z), without maintainer response.

Suggested fix

In packages/otel/src/MediaService.ts, replace the scope-name check with attribute-presence detection. Either inline:

const isAiSdkSpan = Object.keys(span.attributes).some(
  (k) => k.startsWith("gen_ai.") || k.startsWith("ai."),
);
if (isAiSdkSpan) { ... }

or reuse the existing helper from span-filter.ts:

import { isGenAISpan } from "./span-filter.js";
// ...
if (isGenAISpan(span)) { ... }

The latter has the bonus of staying consistent with the rest of the export pipeline's classification.

🔬 also observed by chatgpt-codex-connector

const aiSDKMediaAttributes = ["ai.prompt.messages", "ai.prompt"];

for (const mediaAttribute of aiSDKMediaAttributes) {
Expand All @@ -121,54 +122,34 @@ export class MediaService {
for (const part of contentParts) {
if (part["type"] === "file") {
let base64Content: string | null = null;
const mediaType = part["mediaType"];
// FilePart
if (
part["data"] != null &&
part["mediaType"] != null &&
typeof part["data"] !== "object" && // skip URL instances
!String(part["data"]).startsWith("http") // skip URL strings
typeof part["data"] === "string" &&
!part["data"].startsWith("http") // skip URL strings
) {
base64Content = part["data"];
}

//ImagePart
if (
part["image"] != null &&
part["mediaType"] != null &&
typeof part["image"] === "string" &&
!part["image"].startsWith("http") // skip URLs
) {
base64Content = part["image"];
}

if (!base64Content) continue;

const media = new LangfuseMedia({
contentType: part["mediaType"],
contentBytes: base64ToBytes(base64Content),
source: "bytes",
});

const langfuseMediaTag = await media.getTag();

if (!langfuseMediaTag) {
this.logger.warn(
"Failed to create Langfuse media tag. Skipping media item.",
);

if (!base64Content || typeof mediaType !== "string") {
continue;
}

this.scheduleUpload({
mediaReplacedValue = await this.replaceBytesMedia({
span,
media,
mediaReplacedValue,
base64Content,
contentType: mediaType,
field: "input",
});

// Replace original attribute with media escaped attribute
mediaReplacedValue = mediaReplacedValue.replaceAll(
base64Content,
langfuseMediaTag,
);
}
}
}
Expand All @@ -183,9 +164,111 @@ export class MediaService {
);
}
}

// Handle media from AI SDK v7 OpenTelemetry semantic-convention
// attributes emitted by @ai-sdk/otel.
Comment thread
hassiebp marked this conversation as resolved.
const aiSDKV7MediaAttributes = [
{ attribute: "gen_ai.input.messages", field: "input" },
{ attribute: "gen_ai.output.messages", field: "output" },
] as const;

for (const { attribute, field } of aiSDKV7MediaAttributes) {
const value = span.attributes[attribute];

if (!value || typeof value !== "string") {
continue;
}

let mediaReplacedValue = value;

try {
const parsed = JSON.parse(value);

if (Array.isArray(parsed)) {
for (const message of parsed) {
if (!Array.isArray(message["parts"])) {
continue;
}

for (const part of message["parts"]) {
const content = part["content"];
const mediaType = part["mime_type"];

if (
part["type"] !== "blob" ||
typeof content !== "string" ||
!content ||
typeof mediaType !== "string" ||
content.startsWith("http")
Comment thread
hassiebp marked this conversation as resolved.
) {
continue;
}

const normalizedContent = normalizeBase64Content(content);

if (!normalizedContent) {
continue;
}

mediaReplacedValue = await this.replaceBytesMedia({
span,
mediaReplacedValue,
base64Content: normalizedContent.base64Content,
contentToReplace: content,
contentType: mediaType,
field,
});
}
}
}

span.attributes[attribute] = mediaReplacedValue;
} catch (err) {
this.logger.warn(
`Failed to handle media for AI SDK v7 attribute ${attribute} for span ${span.spanContext().spanId}`,
err,
);
}
}
}
}

private async replaceBytesMedia(params: {
span: ReadableSpan;
mediaReplacedValue: string;
base64Content: string;
contentToReplace?: string;
contentType: string;
field: string;
}): Promise<string> {
const media = new LangfuseMedia({
contentType: params.contentType as MediaContentType,
contentBytes: base64ToBytes(params.base64Content),
source: "bytes",
});

const langfuseMediaTag = await media.getTag();

if (!langfuseMediaTag) {
this.logger.warn(
"Failed to create Langfuse media tag. Skipping media item.",
);

return params.mediaReplacedValue;
}

this.scheduleUpload({
span: params.span,
media,
field: params.field,
});

return params.mediaReplacedValue.replaceAll(
params.contentToReplace ?? params.base64Content,
langfuseMediaTag,
);
}

private scheduleUpload(params: {
span: ReadableSpan;
field: string;
Expand Down Expand Up @@ -234,3 +317,25 @@ export class MediaService {
}
}
}

function normalizeBase64Content(
content: string,
): { base64Content: string } | undefined {
if (!content.startsWith("data:")) {
return { base64Content: content };
}

const base64Start = content.indexOf(";base64,");

if (base64Start === -1) {
return;
}

const base64Content = content.slice(base64Start + ";base64,".length);

if (!base64Content) {
return;
}

return { base64Content };
}
102 changes: 102 additions & 0 deletions packages/vercel-ai-sdk/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
![GitHub Banner](https://github.com/langfuse/langfuse-js/assets/2834609/d1613347-445f-4e91-9e84-428fda9c3659)

# @langfuse/vercel-ai-sdk

This package provides a Langfuse-owned telemetry integration for AI SDK v7 (`ai@7`) using the new callback-based telemetry system.

It delegates AI SDK-compatible OpenTelemetry span creation to Vercel's `@ai-sdk/otel` package so it works with the existing Langfuse OTEL ingestion pipeline. Runtime context keys included via AI SDK telemetry are attached as Langfuse observation metadata. The only special runtime context key is `langfusePrompt`, which links Langfuse prompt name and version to model-call observations.

Trace-level attributes such as user ID, session ID, tags, trace name, and trace metadata should be set with `propagateAttributes` from `@langfuse/tracing`.

## Compatibility

This integration targets AI SDK v7 GA. Install it together with `ai@^7`; the package depends on the matching `@ai-sdk/otel` integration internally.

```sh
pnpm add @langfuse/vercel-ai-sdk ai

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Add required Langfuse tracing packages to install step

pnpm add @langfuse/vercel-ai-sdk ai is not enough for the Usage example immediately below: it imports @langfuse/tracing, which this package does not depend on, and the spans also need a LangfuseSpanProcessor from @langfuse/otel to reach Langfuse. Users who follow these docs in a fresh project will hit a module-resolution error or end up with no exported spans, so include the required Langfuse/OpenTelemetry packages or make the sample not depend on them.

Useful? React with 👍 / 👎.

```

## Usage

```ts
import { generateText, registerTelemetry } from "ai";
import { propagateAttributes } from "@langfuse/tracing";
import { LangfuseVercelAiSdkIntegration } from "@langfuse/vercel-ai-sdk";

registerTelemetry(new LangfuseVercelAiSdkIntegration());

await propagateAttributes(
{
userId: "user-123",
sessionId: "session-456",
tags: ["production", "chat"],
metadata: {
feature: "assistant",
},
},
() =>
generateText({
model,
prompt: "Explain RAG in one paragraph",
runtimeContext: {
route: "support-chat",
langfusePrompt: {
name: "assistant/default",
version: 3,
isFallback: false,
},
},
telemetry: {
functionId: "chat-assistant",
includeRuntimeContext: {
route: true,
langfusePrompt: true,
},
},
}),
);
```

AI SDK v7 excludes `runtimeContext` from telemetry events unless each top-level key is explicitly included. This integration maps every included runtime context key to Langfuse observation metadata, except `langfusePrompt`, which is used for prompt linking and is not added as metadata.

You can also pass the integration on a single call:

```ts
import { generateText } from "ai";
import { LangfuseVercelAiSdkIntegration } from "@langfuse/vercel-ai-sdk";

await generateText({
model,
prompt: "Summarize this article",
runtimeContext: {
feature: "article-summary",
},
telemetry: {
functionId: "article-summary",
includeRuntimeContext: {
feature: true,
},
integrations: new LangfuseVercelAiSdkIntegration(),
},
});
```

## Packages

| Package | NPM | Description | Environments |
| --------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------- | ------------ |
| [@langfuse/client](https://github.com/langfuse/langfuse-js/tree/main/packages/client) | [![NPM](https://img.shields.io/npm/v/@langfuse/client.svg)](https://www.npmjs.com/package/@langfuse/client) | Langfuse API client for universal JavaScript environments | Universal JS |
| [@langfuse/tracing](https://github.com/langfuse/langfuse-js/tree/main/packages/tracing) | [![NPM](https://img.shields.io/npm/v/@langfuse/tracing.svg)](https://www.npmjs.com/package/@langfuse/tracing) | Langfuse instrumentation methods based on OpenTelemetry | Node.js 20+ |
| [@langfuse/otel](https://github.com/langfuse/langfuse-js/tree/main/packages/otel) | [![NPM](https://img.shields.io/npm/v/@langfuse/otel.svg)](https://www.npmjs.com/package/@langfuse/otel) | Langfuse OpenTelemetry export helpers | Node.js 20+ |
| [@langfuse/openai](https://github.com/langfuse/langfuse-js/tree/main/packages/openai) | [![NPM](https://img.shields.io/npm/v/@langfuse/openai.svg)](https://www.npmjs.com/package/@langfuse/openai) | Langfuse integration for OpenAI SDK | Universal JS |
| [@langfuse/langchain](https://github.com/langfuse/langfuse-js/tree/main/packages/langchain) | [![NPM](https://img.shields.io/npm/v/@langfuse/langchain.svg)](https://www.npmjs.com/package/@langfuse/langchain) | Langfuse integration for LangChain | Universal JS |
| [@langfuse/vercel-ai-sdk](https://github.com/langfuse/langfuse-js/tree/main/packages/vercel-ai-sdk) | [![NPM](https://img.shields.io/npm/v/@langfuse/vercel-ai-sdk.svg)](https://www.npmjs.com/package/@langfuse/vercel-ai-sdk) | Langfuse integration for AI SDK v7 | Universal JS |

## Documentation

- Docs: https://langfuse.com/docs/sdk/typescript
- Reference: https://js.reference.langfuse.com

## License

[MIT](LICENSE)
Loading
Loading