Rust
スパンはドロップされたときに終了するガードなので、途中の return やエラーで開いたままになることはありません。コンテキストは tokio の task-local が運びます。
インストール
SDK はバックグラウンドの tokio タスクでエクスポートするので、ランタイムが
あることを前提にしています。ランタイムがなくても動きますが、その場合は
flush() を呼ぶまで何も送られません。同期的なツールにはこれが
正しい動きですが、長く動き続けるサービスが求めるものではありません。
cargo add mcpd-sdk クライアントの構築
クライアントは 1 プロセスに 1 つです。Mcpd::new() は環境変数を
読み、何も設定しなくてもローカルの mcpd に接続します。構築が失敗することは
ありません。mcpd が使えない設定はクライアントを無効にし、その理由をログに
出します。可観測性の呼び出しは、プログラムがエラー処理を強いられるもので
あってはならないからです。
接続
// Nothing here is required. `Mcpd::new()` reads MCPD_ENDPOINT, MCPD_API_KEY
// and the rest from the environment and talks to a local mcpd by default.
// Anything set in code wins over the environment.
let mcpd = Mcpd::with_config(
Config::from_env()
.with_project("support")
.with_environment("local")
.with_service("support-agent", Some("1.0.0")),
); Config::from_env() のあとにビルダーで設定したものが、環境変数より優先されます。
| フィールド | 環境変数 | 既定値 |
|---|---|---|
endpoint | MCPD_ENDPOINT | http://localhost:4317 |
api_key | MCPD_API_KEY | なし |
project | MCPD_PROJECT | default |
environment | MCPD_ENVIRONMENT | なし |
service_name | MCPD_SERVICE_NAME | なし |
service_version | MCPD_SERVICE_VERSION | なし |
sample | MCPD_SAMPLE | 1.0 |
batch_size | MCPD_BATCH_SIZE | 128 |
flush_interval | MCPD_FLUSH_INTERVAL_MS | 2s |
max_queue | MCPD_MAX_QUEUE | 8192 |
timeout | MCPD_TIMEOUT_MS | 10s |
enabled | MCPD_ENABLED | true |
capture_payloads | MCPD_CAPTURE_PAYLOADS | true |
log_level | MCPD_LOG_LEVEL | debug |
解釈できない設定は警告とともに読み飛ばされ、既定値が使われます。デプロイ先の
環境変数のタイプミスで、プログラムが止まってはいけないからです。
enabled = false にすると、どの呼び出しも何もしなくなりますが、
返るオブジェクトは有効なままです。id の形は正しく、traceparent の
伝播も続くので、テスト対象のコードに触れずに mcpd を止められます。
実行の記録
Span は RAII のガードです。ドロップされた時点で終了します。これが
Rust でのスコープ付きの書き方で、ブロック、スコープ、関数の終わりが区切りに
なります。end() を自分で呼んでも同じです。ガードになっているのは、
途中の return やパニックでスパンが開いたまま、終わらないまま残らないように
するためです。
スパンはガード
let run = mcpd.trace("support agent");
{
let step = run.child_of("retrieve", SpanKind::Retrieval);
step.set_input("order 5512");
// このステップが何をするにせよ、スパンは閉じ括弧で終了する。途中の return
// でも、? でも、パニックでも終了する。
}
run.end(); 実行の記録
// The root span is the run. The sampling decision is made here, once, and
// every child inherits it.
let run = mcpd.trace("support agent");
run.set_input(question);
run.set_session("session-4f21");
run.set_user("customer-91");
run.set_tags(["support", "orders"]);
// A tool call. `run.child_of` nests it under the run.
let lookup = run.child_of("lookup_order", SpanKind::ToolCall);
lookup.set_input(json!({ "order_id": "5512" }));
let order = json!({ "status": "shipped", "carrier": "DHL", "eta": "Tuesday" });
lookup.set_output(&order);
lookup.end();
// A tool that fails. Recording the error sets the span's status to `error`,
// and a message is required: "something failed" is not actionable.
let notify = run.child_of("notify_customer", SpanKind::ToolCall);
notify.set_input(json!({ "channel": "sms" }));
notify.set_error("sms gateway timed out after 5s");
notify.end();
// The failure did not stop the run: the agent falls back to email, and the
// trace shows both the attempt and the recovery.
run.event_with("fell back to email", json!({ "after": "sms gateway timeout" })); コンテキスト
プロセスの中では、コンテキストは tokio の task-local で運ばれます。スパンを
持っているだけでは、それがカレントにはなりません。Span::scope は
future に対して、Span::sync_scope はクロージャに対して
task-local を設定します。どちらの中でも Mcpd::span は進行中の
実行につながるので、何層か下のヘルパー関数でも、親を渡されずに正しく入れ子に
なります。親をすでに持っている場合は、Span::child_of が明示的な
書き方です。
task-local
// スパンを開いても、それがカレントにはならない。カレントにするのは scope()。
let run = mcpd.trace("support agent");
run.scope(async {
lookup(&mcpd).await;
})
.await;
run.end();
async fn lookup(mcpd: &Mcpd) {
// この関数に親は渡していない。それでも実行の下に入れ子になる。task-local が
// 親を運んでいるから。
let step = mcpd.span("lookup_order", SpanKind::ToolCall);
step.set_output(serde_json::json!({ "status": "shipped" }));
}
プロセスの間では、コンテキストは W3C の traceparent ヘッダーで
運ばれます。サンプリングのフラグも一緒に運ばれるので、そのトレースを残すか
どうかについて、トレースの前半と後半が食い違うことはありません。
サービス境界をまたぐ
// 送信側: ヘッダーをリクエストに付ける。
let header = span.traceparent();
// 受信側: 新しいトレースを始めず、呼び出し側のトレースを続ける。ヘッダーがない、
// または形式が壊れている場合は、失敗させずに新しいトレースを始める。
let run = mcpd.trace_from("charge card", inbound_header, SpanKind::AgentRun); モデル、使用量、エラー
mcpd は取り込み時にトークン数から料金を計算するので、トレースに出るコストは
実際に請求されたコストになります。エラーを記録すると、同時にスパンのステータスも
error になるので、この 2 つが食い違うことはありません。メッセージは
省略できる引数ではなく、必須の引数です。
スパンが持つもの
generation.set_model("claude-sonnet-4", "anthropic");
generation.set_usage(TokenUsage {
input_tokens: Some(412),
output_tokens: Some(96),
..TokenUsage::default()
});
// あるいは、内訳が要らないときは:
generation.set_tokens(412, 96);
// メッセージは必須。「何かが失敗した」では手の打ちようがない。
tool.set_error("sms gateway timed out after 5s");
// あるいは、すでに持っているエラーを記録する。メッセージと種類はそこから読む。
tool.record_error(&err);
そのほかのセッターは set_input、set_output、
set_attributes、set_metadata、
set_tags、set_entity、set_session、
set_user、set_status、
set_error_detail です。どれも &self を取るので、
スパンは借用できる場所ならどこからでも埋められます。
ペイロード、シンク、完了済みスパン
Config::with_capture_payloads(false) にすると、プロンプトと補完
はプロセスの中に留まります。スパンは所要時間、ステータス、使用量、モデル、
相関、エラーを持ったままで、input と
output はキューに入る前に捨てられます。個々のスパンは
span.capture_payloads(..) でクライアントの設定を上書きでき、
有効にも無効にもできます。
エクスポーターは、すべてのバッチをクライアントのシンクに渡します。既定は
HTTP トランスポートです。Config::with_sink には
RecordingSink を渡せます。これはバッチをメモリに保持するので、
テストはサーバーなしで記録された内容を検証できます。ConsoleSink を
渡せば、シグナル 1 件につき JSON が 1 行出力されます。失敗したシンクは
エクスポートの失敗として 1 間隔につき 1 回報告されるだけで、例外にはなりません。
mcpd.stats() は queued、dropped、
capacity、exported、failed、
rejected を返します。
mcpd.record(span) は、すでに終了したスパンを、id、親、開始時刻、
終了時刻ごと、アダプターやインポートが持っているままキューに入れます。親を
付け替えたり、時刻を打ち直したり、サンプリングをやり直したりはしません。形式の
壊れたレコードは捨てられ、rejected に数えられます。
span.set_external_parent は、mcpd 以外のトレーシングシステムの
親を、相関のためだけに運びます。
永続実行
同じクライアントがデーモンの永続実行も扱います。Workflow::new が
定義を組み立て、ステップの本体を非同期クロージャとして保持します。
Worker がそれを登録し、リースをロングポーリングで受け取って
1 件ずつタスクの上で処理します。start_run、
watch_run、publish_event なども、実行の API を
1 対 1 で写しています。本体が StepFailure::retryable を返すと
ステップの予算の範囲でリトライされ、StepContext を通して自分の
ステップの下にスパンを記録します。そうして開いたスパンと、その下に入れ子に
なったすべてのスパンは、終了時に使用量をステップに足し込みます。デーモンは
トレースに出ているとおりに料金を計算し、本体がトークンを手で合計する必要は
ありません。set_usage は、それを明示的に上書きします。ビルダーの
on_lease、on_step_start、on_step_end、
on_conflict の各フックと worker.stats() によって、
ワーカーの中の様子が見えます。
ワークフロー
let support = Workflow::new("support_ticket")
.step(
"classify",
|ctx: StepContext| async move {
let input: Value = ctx.input()?;
let ticket = input["ticket"].as_str().unwrap_or("");
let kind = if ticket.starts_with('Q') { "question" } else { "bug" };
Ok::<_, StepFailure>(json!({ "kind": kind }))
},
StepOptions::new(),
)
.agent(
"draft",
|ctx: StepContext| async move {
// Your own model call, recorded under this step in the run's trace.
// Its usage is the step's usage: the daemon prices what the span spent.
let span = ctx.span("model_call", SpanKind::ModelGeneration);
span.set_model("claude-sonnet-4", "anthropic");
span.set_tokens(812, 37);
span.end();
Ok::<_, StepFailure>("an answer")
},
StepOptions::new().input(draft_input),
)
.wait_for(
"approval",
"approved",
"${input.ticket}",
Duration::from_secs(7 * 24 * 3600),
StepOptions::new().when(when("steps.classify.output.kind", "eq", "bug")),
)
.step(
"send",
|ctx: StepContext| async move {
let input: Value = ctx.input()?;
let text = input["text"].as_str().unwrap_or("");
deliver(text, ctx.idempotency_key()).await.map_err(busy)?;
Ok::<_, StepFailure>(json!({ "sent": true }))
},
StepOptions::new()
.input(json!({ "text": "${steps.draft.output}" }))
.at_most_once()
.retry(backoff),
)
.output(json!({ "sent": "${steps.send.output.sent}" })); それぞれの構成要素の働き、リースのプロトコル、実行の API は ワークフロー、 ワーカー、 実行のページにあります。
スコアとフィードバック
Scale は省略できる引数ではなく、必須の引数です。範囲の宣言がない
0.9 という数値は何とも比べられませんし、誰かがチャートを見ている時点では、
それが何を意味していたのかを聞くには遅すぎます。よく使うのは
Scale::UNIT、0.0 から 1.0 です。フィードバックは人の判断であり、
スコアとは別に保たれます。2 つは別の問いに答えるものなので、まとめて平均しては
いけません。
スコアとフィードバック
// A score is a machine judgement. The scale is not optional: a bare 0.9 with
// no declared range cannot be compared to anything.
mcpd.score(
Target::trace(&trace_id),
"answer_relevance",
0.9,
Scale::UNIT,
Some("Names the carrier and the delivery day the customer asked about."),
);
// Feedback is a person's judgement, kept separate from a score because the
// two answer different questions and must never be averaged together.
mcpd.feedback(Target::trace(&trace_id), FeedbackKind::Thumbs, "up", Some("fast and correct")); フラッシュとシャットダウン
記録はメモリ上のキューに追加して戻ります。flush() はキューにある
ものを送り、shutdown() はフラッシュしてワーカーを止めます。2 回
呼んでも問題ありません。どちらも await でき、最後のエクスポートの失敗を返すので、
ログに出せます。
短命なプロセスでは、どちらかを必ず呼んでください。シャットダウンせずに終了した バイナリは、スパンをメモリに残したまま終わります。サーバーレス関数では、 ハンドラーが戻った瞬間にランタイムがプロセスを凍結するので、バックグラウンドの タスクに次の順番は回ってきません。
シャットダウン
#[tokio::main]
async fn main() {
let mcpd = Mcpd::new();
// ... 実行の中身 ...
// これを無視するのは妥当な既定の振る舞い。可観測性の失敗は、アプリケーション
// の失敗ではない。ログに出すなら 1 行で済む。
if let Err(e) = mcpd.shutdown().await {
eprintln!("mcpd could not export: {e}");
}
} フラッシュの失敗で失われたシグナルが、キューに戻されることはありません。 トランスポートはすでにリトライしており、失敗のたびに自分でキューを埋め直す作りは、 障害をメモリ不足による強制終了に変えてしまいます。
保証されること
デーモンを止めた状態でも、この例は最後まで走り、できなかったことを出力して、 終了コード 0 で終わります。
$ cargo run -p mcpd-sdk --example quickstart Finished `dev` profile [unoptimized + debuginfo] Running `target/debug/examples/quickstart`mcpd could not export: export failed: mcpd could not be reachedcould not read the trace back: mcpd could not be reached$ echo $?0 -
計装の呼び出しは、エラーを返すこともパニックすることもありません。mcpd に
到達できない、シリアライズに失敗した、キューがいっぱいだといったことは、
tracingを通してフラッシュ間隔ごとに多くても 1 回ログに出て、 飲み込まれます。可観測性が、観測している相手を落としてはいけません。 - 記録がブロックすることはありません。エクスポートはバックグラウンドの tokio タスクで行われるので、エージェントのレイテンシが、mcpd の速さや、mcpd が 動いているかどうかに左右されることはありません。
-
キューの上限は
max_queueです。いっぱいになると古いシグナルから 捨てられ、mcpd.stats().droppedがその数を数えます。古いものを 捨てるほうが、メモリ不足による強制終了よりは小さな失敗です。 - 1.0 未満のサンプリングは、乱数ではなくトレース id から決まります。そのため、 いくつのプロセスが動いていても、同じトレースについてはどれも同じ判断をし、 サービスの境界をまたぐトレースが半分だけ記録されることはありません。
- スパンは開始時にエクスポートされ、終了時に更新されるので、途中でクラッシュ した実行も読めます。
読み出し
読み出しも同じクライアントで行います。各メソッドは HTTP エンドポイント 1 つに
対応し、生成された型を返します。その型は仕様から mcpd-core を経て
作られ、mcpd_sdk::model として再エクスポートされています。
読み出しは Result を返します。await して答えの得られないクエリは、
知らせるべきことだからです。
検索と分析
// The same client reads. This is the trace you just wrote.
match mcpd.get_trace(&trace_id, Include::all()).await {
Ok(detail) => {
println!("{}: {} spans", detail.trace.name, detail.spans.len());
for span in &detail.spans {
println!(
" {:<22} {:>8} {}",
span.name,
span.duration_ms().map_or("open".to_owned(), |ms| format!("{ms:.1}ms")),
span.status,
);
}
if let Some(cost) = detail.trace.cost.as_ref().and_then(|c| c.total_cost) {
println!(" cost: {cost:.6} USD");
}
for score in detail.scores.iter().flatten() {
println!(" score {}: {}", score.scorer_id, score.value);
}
}
Err(e) => eprintln!("could not read the trace back: {e}"),
}