---
title: "EveのTUI実行とtool callingをLangfuseで観測する"
description: "Vercelのエージェントフレームワークeveで動かしたtool calling実行を、Langfuseへtrace/span/generationとして送る方法を2パターン試して比較したログ。"
lang: "ja"
canonical: "https://llm-lab.dev/posts/vercel-eve-langfuse-observability/"
source: "https://llm-lab.dev/posts/vercel-eve-langfuse-observability.md"
publishedAt: "2026-06-21"
updatedAt: "2026-06-21"
category: "eve"
tags:
  - "vercel"
  - "langfuse"
  - "observability"
  - "agent"
---

# EveのTUI実行とtool callingをLangfuseで観測する

import LinkCard from "../../components/LinkCard.astro";

> [!NOTE]
> この記事で確認したこと
>
> Eveのtool calling実行をLangfuseへ送る方法として、`agent/instrumentation.ts` + OTLP exporterと、`get_weather.execute`をLangfuse SDKでラップするTool側adapterの2通りを試しました。認証情報を直した後、どちらの経路でもtrace送信は確認できましたが、ツールが増える前提では外側から拾う`instrumentation.ts`側の構成が保守しやすいです。
>
> 今回確定できたのは、`langfuse.session.id`やmetadataでEveの実行とLangfuse traceを紐付けるところまでです。`action.result`から専用のtool I/O spanを作ること、`eve eval`とTUI実行を同じtraceに結びつけること、実データ運用でのredactionルールは未確認範囲として残っています。

## TUIで満足して終われない理由

[前回の記事 - 「VercelのEveでツール付きエージェントを組んで、TUIから動かしてみた」](https://llm-lab.dev/posts/vercel-eve-deep-dive/)では、`get_weather`ツールを追加して`eve dev`のTUI上で呼び出しを確認し、evalで`includes("partly cloudy")`が一度落ちて期待値を直すところまでやりました。
TUIは思考プロセスやツール呼び出しがリアルタイムで見えるので、開発中の体験としてはかなり快適でした。

<LinkCard
  href="https://llm-lab.dev/posts/vercel-eve-deep-dive/"
  title="VercelのEveでツール付きエージェントを組んで、TUIから動かしてみた"
  description="Eveにツールとevalを足し、Vercel AI Gateway経由のモデル設定、TUIでのツール呼び出し、infoやevalまわりを確認した実装ログ。"
  siteName="つれづれなる Agent OPS"
  image="/images/posts/eve-vercel-agent-framework-survey/heroImage.webp"
/>

ただ、TUIで見えているものは「今、目の前で起きていること」だけです。AgentOpsの観点で言うと、これはその場の観察にすぎません。
あとから「いつ、どのモデルで、どのツールが呼ばれ、どのevalが落ちたか」を振り返って比較するには、TUIのログとは別に、外部の観測基盤に残しておく必要があります。

今回は、その記録先としてLangfuseを使い、Eveの実行をtrace/span/generationとして送る最小構成を作ってみました。

![Eveのsend:turn実行でget_weatherが呼ばれ、actions.requestedとaction.resultが出ているターミナル](/images/posts/vercel-eve-langfuse-observability/001-eve-tui-get-weather.webp)

## どこから記録を取るか、2つの選択肢

最初に詰まったのが、「Eveのどこに記録コードを差し込むか」という入口の問題でした。
ドキュメントを読み直すと、大きく2つの選択肢がありそうだと分かりました。

1. **`instrumentation.ts`またはHookを使う方法**: Eveのセッション・ターン・ステップという実行ライフサイクルに対して、外側からイベントを観測する
2. **Tool定義の`execute`内に直接記録コードを埋め込む方法**: 自分が書いたツール（`get_weather.ts`など）の中で、Langfuse SDKを直接呼ぶ

実際に両方試してみました。
Eve本体のライフサイクルに乗る方が、ツールを増やすたびに記録コードを書き直す必要がなくなるはずなので、`instrumentation.ts`やHookの方が「正しい」やり方なのかなと思いつつ。

### 方法1: instrumentation.tsとHookを試す

最初は`input.requested`のようなストリームイベントだけを見ていたので、「tool callの開始・終了イベントはどこから拾うんだ」と迷いました。
ただ、そこで止めずにEveのパッケージ内の型定義まで追うと、入口はちゃんとありました。

まず、Eveには`agent/instrumentation.ts`というファイルベースの入口があります。
ここで`defineInstrumentation`をexportすると、モデル呼び出しに対するtelemetryが有効になり、`step.started`でAI SDKのtelemetry spanに渡す`runtimeContext`を追加できます。

```typescript
import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-http";
import { resourceFromAttributes } from "@opentelemetry/resources";
import { NodeSDK } from "@opentelemetry/sdk-node";
import { ATTR_SERVICE_NAME } from "@opentelemetry/semantic-conventions";
import { defineInstrumentation } from "eve/instrumentation";

function getLangfuseAuthorizationHeader() {
  const publicKey = process.env.LANGFUSE_PUBLIC_KEY;
  const secretKey = process.env.LANGFUSE_SECRET_KEY;
  if (!publicKey || !secretKey) return undefined;

  return "Basic " + Buffer.from(`${publicKey}:${secretKey}`).toString("base64");
}

function getLangfuseOtelEndpoint() {
  if (process.env.LANGFUSE_OTEL_ENDPOINT) {
    return process.env.LANGFUSE_OTEL_ENDPOINT;
  }

  const baseUrl = process.env.LANGFUSE_BASE_URL ?? "https://cloud.langfuse.com";
  return baseUrl.replace(/\/$/, "") + "/api/public/otel/v1/traces";
}

export default defineInstrumentation({
  functionId: "vercel-eve-langfuse-observability",
  recordInputs: true,
  recordOutputs: true,
  setup({ agentName }) {
    const authorization = getLangfuseAuthorizationHeader();
    if (!authorization) return;

    const sdk = new NodeSDK({
      resource: resourceFromAttributes({
        [ATTR_SERVICE_NAME]: agentName,
      }),
      traceExporter: new OTLPTraceExporter({
        url: getLangfuseOtelEndpoint(),
        headers: {
          Authorization: authorization,
          "x-langfuse-ingestion-version": "4",
        },
      }),
    });

    sdk.start();
  },
  events: {
    "step.started"(event) {
      return {
        runtimeContext: {
          "langfuse.trace.name": "eve-session",
          "langfuse.session.id": event.session.id,
          "langfuse.trace.metadata.experiment": "vercel-eve-langfuse-observability",
          "langfuse.trace.metadata.turn_id": event.turn.id,
          "langfuse.trace.metadata.step_index": event.step.index,
        },
      };
    },
  },
});
```

このファイルは実験環境の`agent/instrumentation.ts`に追加しました。
`npm run start`でサーバーを起動すると、OTLP exporterも起動しました。

```text
[instrumentation] Langfuse OTLP exporter started {
  agentName: 'vercel-eve-langfuse-observability',
  endpoint: 'https://cloud.langfuse.com/api/public/otel/v1/traces'
}
```

![Eveサーバー起動時にLangfuse OTLP exporterが開始されたターミナル](/images/posts/vercel-eve-langfuse-observability/005-eve-start-otel-exporter.webp)

もう1つ、Hook側でも`action.result`を購読できました。
これはツール、サブエージェント、スキルの実行結果が確定したあとに流れてくるイベントです。
`toolResultFrom`を使うと、対象のtool定義に対応する結果だけを取り出せます。

```typescript
import { defineHook } from "eve/hooks";
import { toolResultFrom } from "eve/tools";
import getWeather from "#tools/get_weather";

export default defineHook({
  events: {
    "action.result"(event, ctx) {
      const result = toolResultFrom(event.data.result, getWeather);
      if (!result) return;

      console.info({
        hook: "action.result",
        agent: ctx.agent.name,
        channel: ctx.channel.kind ?? "unknown",
        toolName: result.toolName,
        callId: result.callId,
        output: result.output,
        status: event.data.status,
      });
    },
  },
});
```

つまり、「Hook/stream eventではtool callを拾えなさそう」という最初の見立ては浅かったです。
少なくともEve 0.11.9では、標準の観測入口として`instrumentation.ts`があり、結果イベントとして`action.result`もあります。

では、これでLangfuseまで送れたのか。
最初の検証ではAI Gateway認証不足やLangfuse 401で止まりましたが、認証情報を直して再実行すると、Eveのtool callまで到達できました。

ここで使っている`node scripts/send-turn.mjs`は、Eveに最初から入っているコマンドではなく、今回の検証用に用意した小さなスクリプトです。
やっていることは、起動中のEveサーバー（`http://127.0.0.1:3000`）へEve Clientで1ターン送るだけです。

```javascript
import { Client } from "eve/client";

const client = new Client({ host: process.env.EVE_HOST ?? "http://127.0.0.1:3000" });
const session = client.session();
const response = await session.send("東京の天気を教えてください。ツールを使ってください。");
const result = await response.result();

console.log(JSON.stringify({
  status: result.status,
  sessionId: result.sessionId,
  message: result.message,
  events: result.events.map((event) => event.type),
}, null, 2));
```

TUIで手入力してもよいのですが、再現可能な証跡と再実行のために、今回はこのスクリプトで毎回同じ入力を投げました。

実行結果には、`actions.requested`と`action.result`が出ています。
これは、Eveが`get_weather`の呼び出しを要求し、その結果を受け取ったことを示しています。

```text
"actions.requested",
"action.result",
"session.waiting"
```

最後が`session.completed`ではなく`session.waiting`なのは、会話セッションが次の入力待ちとして残っているためです。
今回の観測目的では、`actions.requested`と`action.result`が出ていれば十分です。

### 方法2: Tool execute内に記録コードを埋め込む

比較対象として、もう一つの素朴な方法も試しました。
`agent/tools/get_weather.ts`の`execute`関数の中で、直接Langfuse SDKを呼び出す方法です。
これはEveのライフサイクルに乗るというより、自分で書いたtoolの実装内に観測コードを置くアプローチです。

```typescript
import { Langfuse } from "langfuse";

const langfuse = new Langfuse();

export default defineTool({
  description: "指定した都市の天気を取得する",
  inputSchema: z.object({
    city: z.string(),
  }),
  execute: async ({ city }, ctx) => {
    const trace = langfuse.trace({
      name: "eve-tool-call",
      sessionId: ctx.session.id,
      metadata: {
        tool: "get_weather",
        model: ctx.session.model, // model IDが取れる場合
      },
    });

    const span = trace.span({
      name: "get_weather",
      input: { city },
    });

    const result = await fetchWeather(city);

    span.end({ output: result });
    await langfuse.flushAsync();

    return result;
  },
});
```

これは動きました。ただ、動いた瞬間に別の問題が見えてきます。

> tool側に観測コードを書くと、ツールを増やすたびに同じボイラープレートを毎回コピーすることになる。これは結構つらい。

ツールが1つの今は気にならないレベルですが、5個、10個と増えていくと、`langfuse.trace()`の呼び出しとflush処理を全ツールに重複させることになります。共通化するなら、`execute`をラップする薄いヘルパー関数を1つ作り、各ツールはそのヘルパーを通すという構成にするのが妥当そうです。

```typescript
function withLangfuseTrace(toolName: string, execute: ToolExecute): ToolExecute {
  return async (input, ctx) => {
    const trace = langfuse.trace({
      name: `eve-tool-call:${toolName}`,
      sessionId: ctx.session.id,
    });
    const span = trace.span({ name: toolName, input });
    try {
      const result = await execute(input, ctx);
      span.end({ output: result });
      return result;
    } catch (err) {
      span.end({ output: { error: String(err) }, level: "ERROR" });
      throw err;
    } finally {
      await langfuse.flushAsync();
    }
  };
}
```

今回の検証では、Tool側に薄いラッパーを噛ませる方式は、実装としては一番短く書けました。
一方で、Eveの設計に沿うなら`instrumentation.ts`とOTel exporterで外側から拾う構成の方が本命です。

比較のため、Tool側adapterもLangfuse SDKで実送信まで確認しました。
Eveサーバー経由で実行するとOTel traceと混ざるため、検証用に`get_weather`の`execute`を直接呼ぶ小さなスクリプトを用意しています。
このスクリプトは`LANGFUSE_TRACE_ENABLED=true`で`withLangfuseTrace`を有効にし、`traceName`を`eve-tool-call:get_weather`、`sessionId`を`tool-adapter-check-...`として送ります。

```bash
npm run verify:tool-adapter
```

実行結果は次のようになりました。

```json
{
  "ok": true,
  "traceName": "eve-tool-call:get_weather",
  "sessionId": "tool-adapter-check-2026-06-20T21:36:09.106Z"
}
```

![Tool側adapter方式で送ったLangfuse trace detailにget_weatherのinput/outputが表示されている画面](/images/posts/vercel-eve-langfuse-observability/006-langfuse-tool-adapter-trace-detail.webp)

![withLangfuseTraceのdry-runでtrace/span payloadが出ているターミナル](/images/posts/vercel-eve-langfuse-observability/002-langfuse-trace-dry-run.webp)

### ここまで試した時点の比較

最初はLangfuse認証情報の組み合わせが合わず401になりましたが、認証情報を直すとOTel経由とSDK経由の両方でtrace送信を確認できました。
短時間で送信コードを書くならTool側adapterが一番早いです。
ただし、ツールが増える前提でちゃんとやるなら、個別の`execute`に記録処理を混ぜるより、`instrumentation.ts` + OTelで外側から拾う構成の方が保守しやすそうです。

Hookの`action.result`は、Langfuse traceそのものを作る場所というより、独自ログや補助的な分析イベントを作る場所として使いやすそうでした。

## モデルIDとeval結果をmetadataへ残す

trace側のmetadataには、`model`フィールドにモデルID（`anthropic/claude-haiku-4.5`のような文字列）と、`sessionId`を入れています。これによって、Langfuse上でモデルごとの呼び出し傾向を後から絞り込めるようになりました。

eval結果については、`eve eval`の実行とTUI上の対話実行が別プロセスなので、今回はevalの成功/失敗を同じtraceに紐付けることまではできていません。evalランナー側からもLangfuseへ送るには、`evals/`配下のテストコードの中で同様のtrace呼び出しを行う必要があります。

![Langfuseのtrace一覧でEveの実行traceが並んでいる画面](/images/posts/vercel-eve-langfuse-observability/003-langfuse-trace-list.webp)

## Langfuse上で見えるようになったもの

`send:turn`実行後、Langfuseのtrace一覧にもEve由来のtraceが並びました。

ここで重要なのは、Langfuseに飛んでいるtraceが「何となく増えた」だけではなく、Eveのセッションと紐づけて探せることです。
`send:turn`の出力に出た`sessionId`は、Eveが発行した会話セッションIDです。

```text
wrun_01KVKDVH67B5RBD1ZRMVXTY64Y
```

`instrumentation.ts`では、この値を`langfuse.session.id`としてspanへ渡しています。
そのため、Langfuse側ではtrace名だけでなく、セッションIDやmetadataからも今回の実行を探せます。

trace detailを開くと、`langfuse.session.id`や`langfuse.trace.metadata.experiment`など、`step.started`で入れたruntime contextが確認できます。
今回の構成では、Hookの`action.result`はconsoleログとして扱っており、Langfuse上に`get_weather`専用のtool I/O spanを明示的に作っているわけではありません。
そのため、Langfuse detailで確認する主な対象はtool outputそのものではなく、Eve実行とLangfuse traceを結びつけるmetadataです。

![Langfuseのtrace detailでEveのsessionIdとmetadataを確認している画面](/images/posts/vercel-eve-langfuse-observability/004-langfuse-trace-detail-metadata.webp)

> ログを画面で眺めてるだけだと気づかなかったけど、こうしてtrace一覧で並べると、同じcityでも返ってくる文言が呼び出しごとに微妙に違うのが分かる。これは地味に発見。

これが今回いちばん収穫だったポイントです。TUIで1回ずつ見ているときは気づかなかった「同じ入力に対する出力のばらつき」が、複数回の実行を並べて比較することで初めて見えるようになりました。これはTUIでは原理的に難しく、Langfuseのような蓄積型の観測基盤を挟む意味そのものだと感じています。

## redactionの設計はまだ仮置きです

ユーザー入力やtool resultをどこまで記録するかは、今回は仮の設計のまま進めています。今回のダミーツール（天気取得）では機密性のある情報は扱っていませんが、実際に社内データを扱うエージェントへ同じ仕組みを適用する場合は、`span.end()`に渡す`output`をそのまま送るのではなく、フィールド単位でマスキングするレイヤーを挟む必要があります。

現時点での仮の方針としては、

- tool inputのうち、ユーザー識別子や個人情報に該当しうるフィールドは送らない
- tool outputは、構造のキー名だけ残し、値は型情報程度に丸める
- 必要に応じて、Langfuse側のフィールド単位の暗号化・マスキング機能を併用する

という整理にしていますが、これはまだ仮説の段階です。実際にPIIを含むデータを扱う場面で運用してみないと、どこまで削ってよいかの感覚はつかめないと思っています。

## 分かったこと、まだ分かっていないこと

今回の検証で分かったのは次の点です。

- Eve 0.11.9では、`agent/instrumentation.ts`で`step.started`を拾い、AI SDK telemetry spanへ`runtimeContext`を足せる
- `agent/hooks/*.ts`で`action.result`を購読でき、`toolResultFrom`で特定toolの実行結果を取り出せる
- `instrumentation.ts` + OTLP exporterの構成で、Eveの実行traceをLangfuseへ送れる
- `send:turn`の実行では、`actions.requested`と`action.result`が出ており、tool callの要求と結果まで到達できた
- Langfuse trace detailでは、`langfuse.session.id`や`langfuse.trace.metadata.experiment`からEveの実行とtraceを紐づけて確認できる
- 今回のHookはconsoleログ用途なので、Langfuse上に`get_weather`専用のtool I/O spanを作るには追加実装が必要

まだ残っている点は次の通りです。

- `action.result`の内容をLangfuseの独立したspanまたはeventとして残す方法
- `eve eval`実行とTUI実行のtraceを同じセッションとして紐付ける方法
- redactionルールの実運用での妥当性

残る実装境界は、Hookで拾った`action.result`をLangfuse側のspan/eventに変換する薄いレイヤーです。
そこまでできると、Eve本体の`instrumentation.ts`でモデル実行を拾い、Hookでtool resultを補完する構成にできます。

## 関連記事

- [VercelのAIエージェントFW「eve」を導入前に調査したメモ](https://llm-lab.dev/posts/eve-vercel-agent-framework-survey/)
- [VercelのエージェントフレームワークEveをちょっと触ってみた](https://llm-lab.dev/posts/vercel-eve-first-look/)
- [VercelのEveでツール付きエージェントを組んで、TUIから動かしてみた](https://llm-lab.dev/posts/vercel-eve-deep-dive/)
