EveのTUI実行とtool callingをLangfuseで観測する
Vercelのエージェントフレームワークeveで動かしたtool calling実行を、Langfuseへtrace/span/generationとして送る方法を2パターン試して比較したログ。
TUIで満足して終われない理由
前回の記事 - 「VercelのEveでツール付きエージェントを組んで、TUIから動かしてみた」では、get_weatherツールを追加してeve devのTUI上で呼び出しを確認し、evalでincludes("partly cloudy")が一度落ちて期待値を直すところまでやりました。
TUIは思考プロセスやツール呼び出しがリアルタイムで見えるので、開発中の体験としてはかなり快適でした。
ただ、TUIで見えているものは「今、目の前で起きていること」だけです。LLMOpsの観点で言うと、これはその場の観察にすぎません。 あとから「いつ、どのモデルで、どのツールが呼ばれ、どのevalが落ちたか」を振り返って比較するには、TUIのログとは別に、外部の観測基盤に残しておく必要があります。
今回は、その記録先としてLangfuseを使い、Eveの実行をtrace/span/generationとして送る最小構成を作ってみました。

どこから記録を取るか、2つの選択肢
最初に詰まったのが、「Eveのどこに記録コードを差し込むか」という入口の問題でした。 ドキュメントを読み直すと、大きく2つの選択肢がありそうだと分かりました。
instrumentation.tsまたはHookを使う方法: Eveのセッション・ターン・ステップという実行ライフサイクルに対して、外側からイベントを観測する- 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を追加できます。
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も起動しました。
[instrumentation] Langfuse OTLP exporter started {
agentName: 'vercel-eve-langfuse-observability',
endpoint: 'https://cloud.langfuse.com/api/public/otel/v1/traces'
}

もう1つ、Hook側でもaction.resultを購読できました。
これはツール、サブエージェント、スキルの実行結果が確定したあとに流れてくるイベントです。
toolResultFromを使うと、対象のtool定義に対応する結果だけを取り出せます。
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ターン送るだけです。
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の呼び出しを要求し、その結果を受け取ったことを示しています。
"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の実装内に観測コードを置くアプローチです。
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つ作り、各ツールはそのヘルパーを通すという構成にするのが妥当そうです。
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-...として送ります。
npm run verify:tool-adapter
実行結果は次のようになりました。
{
"ok": true,
"traceName": "eve-tool-call:get_weather",
"sessionId": "tool-adapter-check-2026-06-20T21:36:09.106Z"
}


ここまで試した時点の比較
最初は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上で見えるようになったもの
send:turn実行後、Langfuseのtrace一覧にもEve由来のtraceが並びました。
ここで重要なのは、Langfuseに飛んでいるtraceが「何となく増えた」だけではなく、Eveのセッションと紐づけて探せることです。
send:turnの出力に出たsessionIdは、Eveが発行した会話セッションIDです。
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です。

ログを画面で眺めてるだけだと気づかなかったけど、こうして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を補完する構成にできます。