本文へスキップ
mcpd
デーモンなし
ソース

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() のあとにビルダーで設定したものが、環境変数より優先されます。

フィールド環境変数既定値
endpointMCPD_ENDPOINThttp://localhost:4317
api_keyMCPD_API_KEYなし
projectMCPD_PROJECTdefault
environmentMCPD_ENVIRONMENTなし
service_nameMCPD_SERVICE_NAMEなし
service_versionMCPD_SERVICE_VERSIONなし
sampleMCPD_SAMPLE1.0
batch_sizeMCPD_BATCH_SIZE128
flush_intervalMCPD_FLUSH_INTERVAL_MS2s
max_queueMCPD_MAX_QUEUE8192
timeoutMCPD_TIMEOUT_MS10s
enabledMCPD_ENABLEDtrue
capture_payloadsMCPD_CAPTURE_PAYLOADStrue
log_levelMCPD_LOG_LEVELdebug

解釈できない設定は警告とともに読み飛ばされ、既定値が使われます。デプロイ先の 環境変数のタイプミスで、プログラムが止まってはいけないからです。 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}"),
}

実行を探すに、フィルターの 全体があります。データモデル には、これらの型が持つすべてのフィールドが並んでいます。