---
title: "FlueのobserveイベントをLangfuseへ流し、IssueトリアージAgentを観測する"
description: "Flue 1.0 BetaのobserveイベントをredactionしたうえでLangfuseへ送信し、IssueトリアージWorkflowのrunId、モデル、結果を追う実験ログ。"
lang: "ja"
canonical: "https://llm-lab.dev/posts/flue-langfuse-observability-issue-triage/"
source: "https://llm-lab.dev/posts/flue-langfuse-observability-issue-triage.md"
publishedAt: "2026-06-22"
updatedAt: "2026-06-22"
category: "Flue"
tags:
  - "langfuse"
  - "observability"
  - "agent"
  - "agentops"
---

# FlueのobserveイベントをLangfuseへ流し、IssueトリアージAgentを観測する

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

> [!NOTE]
> この記事で確認したこと: Flueの`observe(...)`イベントは、Flue側の`runId`を相関キーにしてLangfuse traceへ変換でき、redaction済みIssue概要、モデル名、成功/失敗、usage、structured outputを後から比較できました。一方でraw observeログにはWorkflow payloadやモデル入力がそのまま残るため、観測設計ではredactionだけでなく、保存場所、保持期間、git管理対象外にする方針まで先に決める必要があります。

前回はFlue 1.0 Betaで、GitHub Issueのタイトルと本文を受け取り、severity、再現可否、ラベル候補、要約を返すIssueトリアージAgentを作りました。

<LinkCard
  href="https://llm-lab.dev/posts/flue-1-0-beta-issue-triage-agent/"
  title="Flue 1.0 BetaでGitHub Issueトリアージエージェントを動かしてみた"
  description="Flue 1.0 BetaのAgent・Skill・Workflowを使い、GitHub Issueのseverity、再現可否、ラベル候補を構造化して返すトリアージエージェントを作った検証ログ。"
  siteName="つれづれなる Agent OPS"
  image="/images/posts/flue-1-0-beta-issue-triage-agent/heroImage.webp"
/>

同じWorkflowをGitHub Actionsから呼ぶ構成も、先に別記事で試しています。

<LinkCard
  href="https://llm-lab.dev/posts/flue-github-actions-issue-triage-workflow/"
  title="FlueのWorkflowをGitHub Actionsから呼び、IssueトリアージをCIに寄せる"
  description="GitHub Issue作成イベントからFlue Workflowをdry-run実行し、常駐サーバーを作る前にCI上で境界を確認した検証ログ。"
  siteName="つれづれなる Agent OPS"
  image="/images/posts/flue-github-actions-issue-triage-workflow/heroImage.webp"
/>

今回はその続きとして、Flueの`observe(...)`で流れてくる実行イベントを拾い、Issue本文をredactionしたうえでLangfuseへ送信します。

やりたいことは、単に「ログを外部サービスへ送る」ことではありません。
IssueトリアージAgentを運用するなら、あとから次の問いに答えられる必要があります。

- どのIssue入力に対して、どのWorkflow runが動いたのか
- どのモデルで、structured outputまで完了したのか
- `finish`に失敗したケースと成功したケースで、どこに差があったのか
- 後から評価・改善に使うために、何を保存し、何を保存しないべきか

Flueの`observe(...)`は、これらを追うためのアプリ内イベントの入口です。`run_start`、`turn_request`、`operation`、`run_end`のような粒度で、Workflowの入力、モデル呼び出し、structured output、usageを拾えます。

ただし、`observe(...)`で得られるイベントは、Workflow payloadやモデル入力を含みます。Issue本文には内部URLやメールアドレスが混ざることがあるため、Langfuseへ送る前に、後から比較したい項目だけを選び直します。今回は本文全文ではなく、redaction済みの`bodyPreview`、文字数、`runId`、model、成功/失敗、triage結果を送る方針にしました。

![Flueのrun IDとLangfuse payloadのtraceIdが対応しているターミナル](/images/posts/flue-langfuse-observability-issue-triage/001-flue-run-langfuse-payload.webp)

## IssueトリアージWorkflowに観測層を足す

今回の検証では、前回作ったIssueトリアージWorkflowの構造は大きく変えず、観測用の薄い層だけを追加しました。見るべきポイントは、Flueの実行イベントをどの単位で拾い、Langfuseへ送る前にどこでredactionするかです。

追加した主なファイルは次の通りです。

```txt
src/
├─ observability/
│  ├─ langfuse-adapter.ts  # FlueEventをLangfuse向けpayloadへ変換
│  ├─ redact.ts            # Issue本文のURL、メール、キーらしき文字列を伏せる
│  └─ register.ts          # observe(...)の登録
├─ app.ts                  # observability/registerを読み込む
└─ workflows/
   └─ triage-issue.ts      # log.infoに安全なIssue概要を追加
```

## observeをapp.tsに登録する

Flueのドキュメントでは、`observe(...)`はアプリケーションのentrypointで登録します。今回の実験場では`src/app.ts`で`flue()`をrouteする前に、観測登録ファイルをimportしました。

```ts
import './providers';
import './observability/register';
import { flue } from '@flue/runtime/routing';
import { Hono } from 'hono';

const app = new Hono();

app.get('/health', (c) =>
	c.json({
		ok: true,
		model: process.env.FLUE_MODEL ?? 'sakura/gpt-oss-120b',
	}),
);

app.route('/', flue());

export default app;
```

`register.ts`では、`FLUE_OBSERVE_JSONL`が指定されていればrawイベントをJSONLへ保存し、同時にLangfuse向けpayloadへ変換して送信します。

```ts
import { observe } from '@flue/runtime';
import { sendToLangfuse, toLangfuseEnvelope, writeJsonl } from './langfuse-adapter';

const observeJsonl = process.env.FLUE_OBSERVE_JSONL;

if (observeJsonl || process.env.FLUE_LANGFUSE_DRY_RUN || process.env.LANGFUSE_PUBLIC_KEY) {
	observe((event) => {
		if (observeJsonl) {
			writeJsonl(observeJsonl, event);
		}

		const envelope = toLangfuseEnvelope(event);
		if (!envelope) return;

		void sendToLangfuse(envelope).catch((error) => {
			console.warn('[observability] failed to export event', event.type, error);
		});
	});
}
```

ここで重要なのは、rawイベントと外部送信用payloadを分けたことです。
rawイベントはFlueの挙動確認には便利ですが、外部サービスへそのまま送るものではありません。今回はdry-runでも実送信でも、外部送信用payloadを`logs/langfuse-payloads.jsonl`へ残し、`npm run triage:langfuse`では同じpayloadをLangfuseへ投げます。

## 何をLangfuseへ送るかを先に決める

最初に決めたのは、Flueの`runId`とLangfuse側のtraceを対応させることです。
Issueトリアージでは、同じ入力をモデル違いで再実行したり、失敗ケースだけ後から見返したりします。そのときに、Flue側の`run_...`とLangfuse側のtraceが対応していないと、CLIログ、アプリログ、Langfuse画面が別々の記録になってしまいます。

今回のadapterでは、`run_...`を使って`traceId`を作りました。

```ts
const runId = event.runId ?? event.instanceId ?? 'no-run-id';
const traceId = `flue-${runId}`;
```

対象イベントは、いったん次だけに絞りました。

```ts
if (!['run_start', 'run_end', 'operation', 'turn_request', 'turn', 'log'].includes(event.type)) {
	return null;
}
```

`message_start`、`message_end`、`thinking_delta`まで全部送ると情報量は増えますが、Issue本文や推論途中の内容も入りやすくなります。今回は「後から比較したい単位」に寄せて、run、operation、turn、logだけを見ることにしました。

この絞り込みで見たいのは、主に次の対応関係です。

| 見たいもの | Flue側のイベント | Langfuse側で見たい情報 |
| --- | --- | --- |
| Workflowの開始 | `run_start` | traceの開始、redaction済み入力概要 |
| モデル呼び出し | `turn_request`、`turn` | model、provider、入力概要、成功/失敗 |
| structured output | `operation` | triage結果、usage、duration |
| Workflowの終了 | `run_end` | 最終結果、status |

## Issue本文はそのまま送らない

検証用payloadには、わざと内部URLとメールアドレスを混ぜました。

実行確認では、同じサンプルIssueを繰り返しWorkflowへ渡せるように、検証用のnpm scriptを用意しました。これはFlueの標準コマンドではなく、中では`flue run triage-issue --target node --payload ...`を呼び出しています。

dry-runでpayloadだけ確認する場合は、次を実行します。

```sh
npm run triage:observe
```

Langfuseへ実送信する場合は、`.env`に`LANGFUSE_PUBLIC_KEY`、`LANGFUSE_SECRET_KEY`、`LANGFUSE_BASE_URL`を入れたうえで、こちらを実行します。

```sh
npm run triage:langfuse
```

標準CLIだけで同じことを確認するなら、上記scriptの代わりに`flue run triage-issue --target node --payload ...`へ同じJSON payloadを渡せばよいです。

実行すると、Flue側ではrun IDが出ます。

```txt
run       run_01KVNW45DM0EGDHVA92D86V935
```

Langfuseへ送るpayloadの先頭は、次のようになりました。

```json
{
  "traceId": "flue-run_01KVNW45DM0EGDHVA92D86V935",
  "eventType": "run_start",
  "runId": "run_01KVNW45DM0EGDHVA92D86V935",
  "model": "openai/preview/Kimi-K2.6",
  "status": "running",
  "input": {
    "title": "Dashboard is blank after login",
    "bodyPreview": "Steps: log in, open /dashboard. Expected widgets. Actual blank white screen in Chrome 126. See [redacted] and contact [redacted].",
    "bodyCharacters": 166
  }
}
```

ここでは本文そのものではなく、redaction済みの`bodyPreview`と文字数だけを送っています。
この粒度でも、どのIssue入力がどのrunIdに対応したかは追えます。

![伏せ字化したLangfuse payload JSONL](/images/posts/flue-langfuse-observability-issue-triage/002-redacted-langfuse-payload.webp)

## raw observeログには生データが残る

一方、`FLUE_OBSERVE_JSONL=logs/flue-observe-events.jsonl`で保存したrawイベントには、元のIssue本文が入ります。
これはFlueが悪いというより、`observe(...)`がアプリ内の実行イベントをそのまま見る仕組みだからです。

たとえば`run_start`には、Workflow payloadがそのまま入ります。

```json
{
  "type": "run_start",
  "runId": "run_01KVNW45DM0EGDHVA92D86V935",
  "workflowName": "triage-issue",
  "payload": {
    "title": "Dashboard is blank after login",
    "body": "Steps: ... See https://internal.example.test/ticket/123 and contact alice@example.com."
  }
}
```

さらに`turn_request`や`message_end`には、モデルに渡した入力文も入ります。
つまり、外部送信用のredactionを入れても、rawイベントのローカル保管場所には機微情報が残りえます。

今回の教訓は単純で、観測ログは「後から見られる便利な記録」ではなく、入力データのコピーです。
Issue本文を扱うなら、rawログの保管場所、保持期間、git管理対象外にすることまで含めて決める必要があります。

![raw observeログには元のIssue本文が残ることを確認したターミナル](/images/posts/flue-langfuse-observability-issue-triage/003-raw-observe-contains-original-input.webp)

## 成功ケースはoperationとrun_endで追える

今回の成功ケースでは、`operation`イベントにstructured outputとusageが入りました。

```json
{
  "eventType": "operation",
  "runId": "run_01KVNW45DM0EGDHVA92D86V935",
  "status": "success",
  "output": {
    "data": {
      "severity": "high",
      "reproducible": true,
      "labels": ["bug", "dashboard", "frontend"],
      "summary": "ログイン後に `/dashboard` を開くと..."
    },
    "usage": {
      "input": 258,
      "output": 958,
      "cacheRead": 2368,
      "totalTokens": 3584
    }
  }
}
```

`run_end`にも最終結果が残ります。
Langfuse側では、同じ`traceId`の中に`run_start`、`turn_request`、`operation`、`run_end`を並べれば、入力の概要、モデル、usage、最終結果を後から見直せます。

実際にLangfuse側で見ると、traceの中にモデル名、イベント種別、triage結果がまとまります。ターミナルの一時ログだけを見る場合と違い、後から「このrunではどのモデルが使われ、どのstructured outputが返ったか」を画面上で確認できます。

![Langfuse上でモデル名とtriage結果が確認できる成功trace](/images/posts/flue-langfuse-observability-issue-triage/005-langfuse-trace-success.webp)

## Langfuseへ実送信する

`npm run triage:langfuse`は、dry-runではなく`FLUE_LANGFUSE_DRY_RUN=0`で同じWorkflowを実行します。
送信に成功すると、ターミナルには次のようなログが出ます。

```txt
[langfuse] ingested run_start flue-run_...
[langfuse] ingested operation flue-run_...
[langfuse] ingested run_end flue-run_...
```

失敗した場合は、`[langfuse] ingestion failed`としてHTTP statusとレスポンス本文を出すようにしました。
ここが出た場合は、LangfuseのAPIキー、base URL、ingestion payloadの形式を疑います。

![Langfuseへingestedログが出ているターミナル](/images/posts/flue-langfuse-observability-issue-triage/004-langfuse-ingested-terminal.webp)

## finishに到達しない失敗もtraceで追う

もう一つ見たかったのは、成功ケースだけではありません。
前回のIssueトリアージ検証では、モデルによってはFlueが期待する`finish` tool callに到達できず、Agentが同じような思考を繰り返すケースがありました。

今回も失敗ケースでは、`turn_request`と`turn`はLangfuseへ送られ続けている一方で、最終的にはWorkflowが次のエラーで止まりました。

```txt
Workflow failed
The agent gave up: Agent did not call `finish` or `give_up` after 33 attempts.
```

ここで重要なのは、モデルが「何もしていない」わけではないことです。
traceを見ると、モデルは何度も`activate_skill`や`finish`に言及していますが、Flueの実行プロトコルとして必要なtool callには到達できていません。
自然文としてはそれらしいことを言っているのに、Agentフレームワークの完了条件を満たせない。
これは、単発のエラーログだけでは見落としやすい差です。

![finish tool callに到達できずWorkflowが失敗したケースのtrace](/images/posts/flue-langfuse-observability-issue-triage/006-langfuse-success-failure-compare.webp)

## Flueだけか、Langfuseを足すかではない

ここまで触ると、Flueの`observe(...)`だけでもかなり見えることが分かります。
runId、event type、operation、turn、usage、structured outputはFlue側で拾えます。

ただ、AgentOpsとして後から失敗ケースを比較したい場合、外部の観測基盤に載せる意味があります。
たとえば、同じIssue payloadを`gpt-oss`と別モデルで流し、片方は`finish`に失敗し、片方はstructured outputまで完了したとします。
この差を単発ログで読むより、traceとして並べた方が、どこで崩れたかを追いやすくなります。

なので、「Flue標準で見えるから外部Observabilityは不要」でも、「Langfuseを使うからFlueのobserveは不要」でもありません。
Flueの`observe(...)`は実行イベントを見る土台で、Langfuseは失敗ケースを比較・評価・改善サイクルへ載せる場所。
この役割分担で考えるのが、IssueトリアージAgentのような実務寄りのAgentには合っていそうです。
