Kotlin
コルーチンが前提です。アクティブなスパンはコルーチンコンテキストに入り、キャンセルは失敗ではなくキャンセルとして記録され、ここから例外がエージェントに飛ぶことはありません。
インストール
Kotlin 2.4 でビルドされ、JVM 17 以降で動きます。依存は kotlinx の coroutines と serialization だけです。HTTP は JDK 自身のクライアントを使うので、 HTTP について既に方針のあるサービスに、何かを足すことはありません。
build.gradle.kts
dependencies {
implementation("dev.mcpd:mcpd-kotlin:0.1.0")
} クライアントの構築
インスタンスは 1 プロセスに 1 つです。McpdConfig のどの
フィールドも、既定値として環境変数を読み、コードからも設定できます。
同じコードを書き換えずに開発環境でも本番環境でも動かせます。引数なしの
Mcpd() は、ローカルの mcpd に対して動きます。
接続
// Every field also reads an environment variable (MCPD_ENDPOINT,
// MCPD_PROJECT, MCPD_API_KEY, ...), so `Mcpd()` on its own works too.
val mcpd = Mcpd(
McpdConfig(
endpoint = "http://localhost:4317",
project = "support-desk",
environment = "development",
serviceName = "support-agent",
serviceVersion = "1.4.0",
)
) コードが環境変数より優先され、環境変数が既定値より優先されます。
| フィールド | 環境変数 | 既定値 |
|---|---|---|
endpoint | MCPD_ENDPOINT | http://localhost:4317 |
apiKey | MCPD_API_KEY | なし |
project | MCPD_PROJECT | default |
environment | MCPD_ENVIRONMENT | なし |
serviceName | MCPD_SERVICE_NAME | なし |
serviceVersion | MCPD_SERVICE_VERSION | なし |
sample | MCPD_SAMPLE | 1.0 |
enabled | MCPD_ENABLED | true |
capturePayloads | MCPD_CAPTURE_PAYLOADS | true |
logLevel | MCPD_LOG_LEVEL | debug |
batchSize | MCPD_BATCH_SIZE | 128 |
flushInterval | MCPD_FLUSH_INTERVAL_MS | 2s |
maxQueue | MCPD_MAX_QUEUE | 8192 |
timeout | MCPD_TIMEOUT_MS | 10s |
enabled = false にすると、記録の呼び出しはすべて何もしなくなり
ますが、返るスパンは有効なままです。テスト対象のコードに触れずに、テストの
あいだ mcpd を止められます。読み出しは、それでもサーバーに届きます。クエリは
呼び出し側が待っているものであり、空の答えを返すのは嘘になるからです。
実行の記録
span が Kotlin らしい書き方です。スパンを終了し、ブロックが呼ぶ
すべての処理に対してそれをアクティブなスパンにし、ブロックが返した値を
そのまま返します。ブロックが例外を投げた場合は、その例外を持つ
error のスパンが残り、例外はそのまま再送出されます。
スコープ付きの書き方
// ブロックの値がそのまま span の値になるので、計装のためにまわりのコードを
// 組み替える必要はない。
val answer = mcpd.span("retrieve_context", SpanKind.RETRIEVAL) { span ->
span.setInput(query)
val docs = index.search(query)
span.setOutput(docs.map { it.id })
span.setAttribute("retrieval.hits", docs.size)
docs
}
キャンセルされたブロックは別扱いで、記録も変わります。呼び出し側やタイムアウトに
よって打ち切られたコルーチンは、スパンを error ではなく
cancelled として終了します。意図して打ち切ったことは処理の失敗
ではないので、トレースの一覧で失敗のように見えてはいけないからです。
実行の記録
val question = "where is my order 41?"
// `span` starts the operation, ends it, and records anything it throws.
// Inside the block the span is the active one, so the helpers below find
// their parent through the coroutine context rather than being handed it.
val traceId = mcpd.span(
"support_agent",
SpanKind.AGENT_RUN,
input = question,
sessionId = "session-8f21",
userId = "customer-4477",
tags = listOf("support", "email"),
) { run ->
mcpd.log(LogLevel.INFO, "handling a question", mapOf("channel" to "email"))
val plan = planAnswer(mcpd, question)
val order = lookUpOrder(mcpd, 41)
val estimate = estimateDelivery(mcpd, 41)
val answer = buildString {
append(plan).append(' ').append(order)
if (estimate != null) append(" It should arrive ").append(estimate).append('.')
}
run.setOutput(answer)
mcpd.metric("support.answers", 1.0, MetricKind.COUNTER, unit = "{answer}")
// The block's value is the value of `span`.
run.traceId
} ブロックの形が合わないときは、スパンの開始と終了を自分で書きます。
ブロックを使わない場合
val run = mcpd.trace("nightly_reindex", SpanKind.WORKFLOW_RUN, tags = listOf("batch"))
try {
reindex()
run.end()
} catch (e: Exception) {
run.setError(e)
run.end()
} コンテキスト
アクティブなスパンはコルーチンコンテキストの要素なので、スレッドではなく
コルーチンに付いていきます。何層か下の suspend 関数でも、呼び出し側から
渡されずに正しい親の子を開けますし、同じディスパッチャーで動く 2 つの実行が
互いのスパンを見ることもありません。読むには mcpd.currentSpan()、
そのヘッダーを取るには mcpd.traceparent() を使います。
入れ子
mcpd.span("support_agent", SpanKind.AGENT_RUN) { run ->
// draft には何も渡していない。何段 suspend を下っても、コルーチン
// コンテキストから実行を見つける。
run.setOutput(draft(question))
}
suspend fun draft(question: String): String =
mcpd.span("draft_answer", SpanKind.MODEL_GENERATION) { generation ->
generation.setInput(question)
"..."
}
サービスの間では、コンテキストは W3C の traceparent ヘッダーで
運ばれます。ヘッダーはサンプリングの判断も一緒に運ぶので、トレースが半分だけ
記録されることはありません。形式の壊れたヘッダーは、それを運んできた
リクエストを失敗させるのではなく、新しいトレースを始めます。
サービス境界をまたぐ
// 送信側: ヘッダーを次のサービスに渡す。
request.header("traceparent", span.traceparent())
// 受信側: 新しいトレースを始めず、そのトレースを続ける。
mcpd.span("checkout", traceparent = headers["traceparent"]) { span ->
charge(span)
} モデル、使用量、エラー
mcpd は取り込み時にトークン数をコストに変えるので、トレースに出る数字は実際に
請求された数字になります。エラーを記録すると、同時にスパンのステータスも
error になり、メッセージは必ず入ります。「何かが失敗した」では、
打つ手が決まりません。
スパンが持つもの
generation.setModel("gpt-4o", provider = "openai")
generation.setUsage(inputTokens = 1_180, outputTokens = 96)
// setError はすでに持っている throwable を取るので、メッセージ、クラス、
// スタックはすべてそこから取れる。
tool.setError(e, retryable = true)
// あるいはメッセージを直接。どちらにしてもメッセージは必須。
tool.setError("the shipping service returned 503")
そのほかは setInput、setOutput、
setMetadata、setAttribute、
setAttributes、setTags、
setCorrelation、addLink と、明示的に子を作る
child、時点を記録する event です。どれもどの
スレッドから呼んでも安全で、例外を投げるものはありません。別のトレーサーの
ツリーに属するスパンは、externalParentSpanId でそれを指します。
これは相関のために運ばれるだけで、mcpd 自身のツリーの中で親として使われる
ことはありません。
ペイロード、シンク、完了済みスパン
capturePayloads = false にすると、input と
output はプロセスの中に留まります。レコードがキューに入る前に
捨てられるので、フラッシュで機密が外に出ることはありません。個々のスパンは、
自分の分だけこれを上書きできます。エクスポーターは、すべてのバッチを
Sink に渡します。既定は HTTP トランスポート、テスト用に
バッチをメモリに保持する RecordingSink、シグナル 1 件につき
JSON を 1 行書く ConsoleSink があります。例外を投げるシンクは
バッチの失敗として 1 間隔につき 1 回報告されるだけで、呼び出し側に例外が
届くことはありません。
取得、記録、検証
// プロンプトと補完はプロセスの中に留める。スパンは所要時間、ステータス、
// 使用量、モデル、相関、エラーをそのまま持つ。
val quiet = Mcpd(McpdConfig(capturePayloads = false))
quiet.span("draft_answer", input = question, capturePayloads = true) { } // これだけは残す
// 別のトレーサーがすでに終了させたスパン。渡されたままキューに入り、親の
// 付け替えも、時刻の打ち直しも、サンプリングのやり直しもしない。形式の壊れた
// id は捨てられて数えられるだけで、例外にはならない。
mcpd.record(finishedSpan)
// サーバーなしで、記録された内容を確かめる。
val recorder = RecordingSink()
val local = Mcpd(McpdConfig(sink = recorder))
local.span("support_agent") { run -> run.setUsage(inputTokens = 12, outputTokens = 3) }
local.flush()
check(recorder.spans.single().usage?.inputTokens == 12L)
// queued, dropped, capacity, exported, failed, rejected.
val stats = local.stats() 永続実行
同じクライアントがデーモンの永続実行も扱います。Workflow が定義を
組み立て、ステップの本体を suspend するラムダとして保持します。
Worker がそれを登録し、リースをロングポーリングで受け取って
1 件ずつコルーチンの上で処理します。startRun、
watchRun、publishEvent などはクライアントの拡張関数で、
実行の API を 1 対 1 で写しています。本体は RetryableException を
投げるとステップの予算の範囲でリトライされ、StepContext を通して
自分のステップの下にスパンを記録します。このコンテキストから開いたスパンは、
終了するたびに使用量をステップの使用量に足し込むので、モデルの呼び出しを
記録している本体がトークンを手で合計することはありません。
setUsage は、スパンが合計した値を上書きします。
Worker は onError のほかに 4 つのフック
(onLease、onStepStart、onStepEnd、
onConflict)を任意で取り、stats() は取得数、実行中の
本体数、完了数、クラス別の失敗数、理由別の競合数、処理できなかったリース数を
返します。
ワークフロー
val support = Workflow("support_ticket")
.step("classify") { ctx ->
val ticket = ctx.input<JsonObject>()["ticket"]!!.jsonPrimitive.content
mapOf("kind" to if (ticket.startsWith("Q")) "question" else "bug")
}
.agent("draft", StepOptions(input = mapOf("kind" to "\${steps.classify.output.kind}"))) {
ctx ->
// Your own model call, recorded under this step in the run's trace.
// Its usage is summed into the step, so the daemon prices the run.
ctx.span("model_call", SpanKind.MODEL_GENERATION) { span ->
span.setModel("claude-sonnet-4", "anthropic")
span.setUsage(inputTokens = 812, outputTokens = 37)
}
"an answer to a " + ctx.input<JsonObject>()["kind"]!!.jsonPrimitive.content
}
.waitFor(
"approval", "approved", "\${input.ticket}", 7.days,
StepOptions(guard = predicate("steps.classify.output.kind", "eq", "bug")),
)
.step(
"send",
StepOptions(
input = mapOf("text" to "\${steps.draft.output}"),
effect = Effect.AT_MOST_ONCE,
retry = RetryPolicy(
maxAttempts = 3, backoff = Backoff.EXPONENTIAL, initial = 500.milliseconds,
),
),
) { ctx ->
val text = ctx.input<JsonObject>()["text"]!!.jsonPrimitive.content
if (!deliver(text, ctx.idempotencyKey)) {
throw RetryableException("mail relay busy", retryAfter = 2.seconds)
}
mapOf("sent" to true)
}
.output(mapOf("sent" to "\${steps.send.output.sent}")) それぞれの構成要素の働き、リースのプロトコル、実行の API は ワークフロー、 ワーカー、 実行のページにあります。
スコアとフィードバック
min と max は必須のパラメーターです。7 は 10 点満点
なら優秀で、100 点満点なら低い点です。スケールの宣言がない数値は、何とも
比べられません。フィードバックは人の判断であり、スコアとは別に保たれます。
2 つは別の問いに答えるものなので、まとめて平均してはいけません。
スコアとフィードバック
// A machine evaluation. `min` and `max` are required: 7 is excellent out of
// 10 and poor out of 100, and a score with no scale cannot be compared.
mcpd.score(
traceId = traceId,
scorerId = "answer_relevance",
value = 0.86,
min = 0.0,
max = 1.0,
reason = "answered the question asked, without the delivery estimate",
)
// A person's judgement, from the thumbs-up control in your own product.
mcpd.feedback(
traceId = traceId,
kind = FeedbackKind.THUMBS,
value = "up",
comment = "fast and clear",
authorId = "customer-4477",
)
// Nothing above touched the network. This is where it goes.
mcpd.flush() ログとメトリクスはアクティブなスパンに付くので、チャート上のスパイクから、 それを起こした実行を開けます。
フラッシュとシャットダウン
記録は、上限のあるメモリ上のキューに追加して戻ります。バッチにまとめ、圧縮して
送るのはワーカーのコルーチンです。flush() はキューにあるものを
送って待ちます。shutdown() はワーカーを止め、残っているものを
送ります。2 回呼んでも問題ありません。最後のエクスポートはキャンセル不能で
動きます。シャットダウンにたどり着くのは、たいていキャンセル中の
finally からであり、最後のバッチこそが、なぜこのプロセスが消える
のかを説明するものだからです。
Mcpd は AutoCloseable なので、use でも
シャットダウンフックでも構いません。どちらも呼ばないサーバーレス関数や短命な
ワーカーは、最後のバッチを失います。ハンドラーが戻った瞬間に、ランタイムが
スケジューリングを止めるからです。
シャットダウン
// JVM のサービス: 終了時にシャットダウンする。
Runtime.getRuntime().addShutdownHook(Thread { mcpd.close() })
// 短命なワーカー: use を使えば、閉じ括弧でシャットダウンされる。
Mcpd(McpdConfig(project = "batch")).use { mcpd ->
mcpd.span("reindex") { run -> reindex(run) }
} 返ってくるもの
読み出しのメソッドは HTTP API を 1 対 1 で写したもので、生成された型を返します。
例外を投げるのはこれらのメソッドだけで、投げるのは McpdException
です。待っているクエリは、飲み込んでよいものではありません。
$ gradle run> Task :runsupport_agent: 4 spans, ok agent_run support_agent 79ms model_generation plan_answer 41ms tool_call lookup_order 16ms tool_call estimate_delivery 11ms shipping service returned 503 for order 41 検索と分析
val detail = mcpd.getTrace(traceId, include = listOf("scores", "feedback"))
println("${detail.trace.name}: ${detail.spans.size} spans, ${detail.trace.status}")
for (span in detail.spans) {
val ms = (span.durationNs ?: 0) / 1_000_000
println(" ${span.kind} ${span.name} ${ms}ms ${span.error?.message ?: ""}")
}
val summary = mcpd.analyticsSummary(
from = Clock.System.now() - 1.hours,
to = Clock.System.now(),
)
println(
"last hour: ${summary.traceCount} runs, " +
"${summary.errorCount} failed, ${summary.totalCost} USD",
) 保証されること
-
計装は例外を投げません。エクスポートの失敗、到達できないサーバー、
シリアライズできない値は、
java.util.loggingを通して 30 秒に 多くても 1 回報告され、あとは飲み込まれます。可観測性が、観測している相手を 落としてはいけません。 - 記録はブロックしないので、サービスのレイテンシが、mcpd の速さや、mcpd が 動いているかどうかに左右されることはありません。
-
メモリには上限があります。キューが保持するシグナルは
maxQueue件で、それを超えると古いものから捨てられ、数えられます。その数はdroppedSignalsとstats()で読めます。上限のない バッファは、ネットワーク障害をメモリ不足による強制終了に変えてしまいます。 - サンプリングの判断はルートで一度だけ行われ、以降は引き継がれます。
- スパンは開始時と終了時の 2 回エクスポートされるので、途中でクラッシュした 実行も見えます。開始のレコードが送られる前にスパンが終了した場合は、その レコードがその場で更新され、送られるのは 1 件だけです。
- バッチは 1 KiB を超えると gzip で圧縮され、リトライしても変わらない冪等キーを 持ちます。サーバーが保存したあとにタイムアウトしたリクエストが、二重に数え られることはありません。
- 所要時間は単調増加のクロックで測るので、NTP による時刻の飛びで負の値になる ことはありません。