Python
同期のコードにも非同期のコードにも使えるコンテキストマネージャー、contextvars で持つアクティブなスパン、そして必須の依存は 1 つもありません。
インストール
Python 3.9 以降で動きます。必須の依存は、意図的に 1 つもありません。
エクスポーターは標準ライブラリの urllib と gzip だけで
動くので、mcpd を入れてもロックファイルには何も増えず、エージェントがすでに
依存しているものと衝突することもありません。
pip install mcpd httpx エクストラが足すのは、コネクションの再利用だけです。
なくても SDK の動きは変わりません。
クライアントの構築
クライアントは 1 プロセスに 1 つです。どの引数も環境変数から読めるので、
同じコードを書き換えずに開発環境でも本番環境でも動かせます。引数なしの
Mcpd() は http://localhost:4317 のデーモンに接続
します。
接続
from mcpd import FeedbackKind, Mcpd, SpanKind
# Everything here has an MCPD_* environment variable behind it, so the same code
# runs in development and in production with no edit. `Mcpd()` with no arguments
# talks to a local daemon.
mcpd = Mcpd(
endpoint=os.environ.get("MCPD_ENDPOINT", "http://localhost:4317"),
project="support",
environment="development",
service_name="support-agent",
service_version="1.4.2",
) コードが環境変数より優先され、環境変数が既定値より優先されます。
| 引数 | 環境変数 | 既定値 |
|---|---|---|
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 |
enabled | MCPD_ENABLED | true |
capture_payloads | MCPD_CAPTURE_PAYLOADS | true |
log_level | MCPD_LOG_LEVEL | debug |
batch_size | MCPD_BATCH_SIZE | 128 |
flush_interval | MCPD_FLUSH_INTERVAL_MS | 2.0 秒 |
max_queue | MCPD_MAX_QUEUE | 8192 |
timeout | MCPD_TIMEOUT_MS | 10.0 秒 |
enabled=False にすると、記録の呼び出しはすべて何もしなくなり
ますが、返るオブジェクトは有効なままです。テスト対象のコードを変えずに、
テストのあいだ mcpd を止められます。capture_payloads=False に
すると、プロンプトと補完はプロセスの中に留まります。スパンは所要時間、
ステータス、使用量、モデル、相関、エラーとともに記録され、
input と output はキューに入る前に捨てられます。
個々のスパンは開始時のフィールドに capture_payloads=True を
書けば別の扱いにでき、逆も同じです。
実行の記録
mcpd.trace(name) は実行を開始し、mcpd.span(name) は
そのときアクティブなものの下にステップを開始します。どちらもコンテキスト
マネージャーです。ブロックを抜けるときにスパンを終了し、例外が出ていれば
それを記録したうえで、例外はそのまま通します。観測されることで、あなた自身の
エラー処理が変わることはありません。
実行の記録
# The root span is the run. Everything opened inside it becomes a child,
# because the active span lives in a `contextvars.ContextVar`.
with mcpd.trace("support_agent", session_id="sess-8121", user_id=user_id,
tags=["support", "orders"]) as run:
run.set_input({"question": question})
# A model call. `set_usage` is what mcpd prices the run from, so the
# cost on the trace is the cost you were actually billed.
with mcpd.span("plan", kind=SpanKind.MODEL_GENERATION) as generation:
generation.set_model("gpt-4o-mini", provider="openai")
generation.set_input([
{"role": "system", "content": "You are a support agent."},
{"role": "user", "content": question},
])
time.sleep(0.12) # stand-in for the provider round trip
generation.set_output("I should look the order up first.")
generation.set_usage(input_tokens=812, output_tokens=96, cache_read=128)
# A tool call that fails. The `with` block records the exception on the
# span, marks it as an error, and lets the exception through unchanged.
order = None
try:
with mcpd.span("lookup_order", kind=SpanKind.TOOL_CALL) as tool:
tool.set_entity("tool", entity_id="orders.lookup", name="Order lookup")
tool.set_input({"order_id": "A-7741"})
order = lookup_order("A-7741")
tool.set_output(order)
except TimeoutError as exc:
# The span already carries the error. This is the agent deciding
# what to do about it, which is a different question.
mcpd.log("warn", "order lookup failed, answering from context", {"error": str(exc)})
mcpd.metric("support.tool_failures", 1, "counter", labels={"tool": "orders.lookup"})
reply = ("I could not reach the order system just now. Your order A-7741 "
"shipped on Tuesday and tracking should update within the hour.")
run.set_output(reply)
run.set_metadata({"used_fallback": order is None})
return reply, run.trace_id with ブロックを使わない場合は、run.end() を自分で
呼んでください。どちらの書き方でもスパンは開始時にエクスポートされるので、
終わらなかったスパンは、終わらなかったスパンとして見えます。たいていは
それが探しているものです。
同期と非同期
# 同期も非同期も同じオブジェクト。どちらも抜けるときにスパンを終了し、
# ブロックが例外を出していればそれを記録する。
with mcpd.trace("nightly_summariser") as run:
with mcpd.span("fetch", kind=SpanKind.TOOL_CALL) as step:
step.set_output({"documents": 42})
async with mcpd.trace("agent") as run:
async with mcpd.span("generate", kind=SpanKind.MODEL_GENERATION) as generation:
generation.set_output(await model.complete(prompt))
await mcpd.aflush() コンテキスト
アクティブなスパンは contextvars.ContextVar です。
await をまたいでタスクの中まで付いていき、スレッドをまたいで
漏れることはありません。3 フレーム下のヘルパー関数でも、呼び出し側が引数を
引き回さずに、正しい親の子を開けます。
入れ子
def draft(question: str) -> str:
# 何も渡していない。それでもスパンは実行の下に入る。アクティブなスパンは
# contextvars.ContextVar で、この呼び出しがブロックの中にあるから。
with mcpd.span("draft", kind=SpanKind.MODEL_GENERATION) as generation:
generation.set_input(question)
return "..."
with mcpd.trace("support_agent") as run:
run.set_output(draft("where is my order?")) 出てくるのは、所要時間とペイロードの付いたツリーです。
- agent support_agent fail
- model chat
- tool returns_policy
- model chat
- tool returns_policy
- model chat
- tool returns_policy
The model called one tool three times and never answered. That is visible in four seconds here, and invisible in a log file.
サービスの間では、コンテキストは W3C の traceparent ヘッダーで
運ばれます。形式の壊れたヘッダーは、それを運んできたリクエストを失敗させる
のではなく、新しいトレースを始めます。受け取った側は、サンプリングしないと
いう判断にも従います。あるサービスは残し、別のサービスは捨てるようなものは、
トレースとは呼べないからです。
サービス境界をまたぐ
# 送信側: アクティブなスパンを次のサービスに渡す。
requests.post(url, headers={"traceparent": mcpd.traceparent()}, json=payload)
# 受信側: 新しいトレースを始めず、そのトレースを続ける。
with mcpd.trace("handler", traceparent=request.headers.get("traceparent")) as run:
... モデル、使用量、エラー
mcpd は取り込み時にトークン数から料金を計算するので、トレースに出るコストは
実際に請求されたコストになります。プロバイダーが返した値をそのまま報告して
ください。set_error は文字列でも例外でも受け取り、同時に
status を error にし、メッセージを必ず埋めます。
「何かが失敗した」では、打つ手が決まりません。
スパンが持つもの
generation.set_model("gpt-4o-mini", provider="openai")
generation.set_usage(input_tokens=812, output_tokens=96, cache_read=128)
# 自分で捕まえて、自分で扱いを決めた失敗。
generation.set_error("the provider timed out", kind="TimeoutError", retryable=True)
# スパンの中の一点。それ自体は長さを持たない。
generation.event("first_token")
ほかのセッターは set_input、set_output、
set_metadata、set_attributes、
set_tags、set_entity、set_cost、
set_status、add_link です。どれもスパンを返し、
どれもスパンの開始時にキーワード引数として渡せます。
external_parent_span_id= は mcpd 以外のトレーシングシステムの
親を指しますが、相関のために運ばれるだけで、親子関係には使われません。
ペイロード、シンク、完了済みスパン
シンクは、吐き出されたバッチの行き先です。既定は HTTP トランスポート
です。RecordingSink はすべてのバッチをメモリに保持するので、
テストはサーバーなしで計装が記録した内容を検証できます。
ConsoleSink は、シグナル 1 件につき JSON を 1 行、標準エラー
出力に書きます。例外を投げるシンクはバッチの失敗として数えられ、報告される
だけで、あなたのコードに例外が出ることはありません。stats() は、
クライアントが作られてから何をしたかを返します。
記録内容の検証
from mcpd import Mcpd, RecordingSink
sink = RecordingSink()
mcpd = Mcpd(sink=sink)
with mcpd.trace("answer") as run:
run.set_usage(input_tokens=12)
mcpd.flush()
assert sink.spans[0].usage.input_tokens == 12
print(mcpd.stats()) # queued, dropped, capacity, exported, failed, rejected mcpd.record(span) は、すでに終了した SpanRecord を、
手元にある id、親、時刻のままキューに入れます。別のトレーシングシステムの
アダプターや一括インポートが持っているのが、これです。親を付け替えたり、
時刻を打ち直したり、サンプリングをやり直したりはしません。形式の壊れた
レコードは捨てられ、stats().rejected に数えられます。例外は
投げません。
永続実行
同じクライアントがデーモンの永続実行も扱います。workflow() が
定義を組み立て、ステップの本体を呼び出しとして、あるいはデコレーターとして
保持します。Worker がそれを登録し、スレッドプール上でリースを
ロングポーリングして 1 件ずつ処理します。mcpd.runs、
mcpd.events、mcpd.definitions は実行の API を
1 対 1 で写しています。本体は class_="retryable" を付けた
StepFailure を投げるとステップの予算の範囲でリトライされ、
StepContext を通して自分のステップの下にスパンを記録します。
それらのスパンが使った分が、そのステップの使用量です。トークンは各スパンの
終了時に合計され、最後に見えたモデルが採用されるので、デーモンはトレースに
出ているとおりにステップの料金を計算します。ctx.set_usage は
それをフィールドごとに上書きします。Worker は
on_error のほかに 4 つのフック(on_lease、
on_step_start、on_step_end、
on_conflict)を取り、worker.stats() は取得した
リース数、実行中の本体数、完了数、クラス別の失敗数、理由別の競合数、処理
できなかったリース数を数えます。
ワークフロー
support = workflow("support_ticket")
@support.step("classify")
def classify(ctx, input, prior):
return {"kind": "question" if input["ticket"].startswith("Q") else "bug"}
@support.agent("draft", input={"kind": "${steps.classify.output.kind}"})
def draft(ctx, input, prior):
# Your own model call, recorded under this step in the run's trace. What
# the span spends is the step's usage: the daemon prices it from here.
with ctx.span("model_call", kind="model_generation") as span:
span.set_model("claude-sonnet-4", "anthropic")
span.set_usage(input_tokens=812, output_tokens=37)
text = f"an answer to a {input['kind']}"
return text
support.wait_for("approval", event="approved", key="${input.ticket}",
timeout_ms=7 * 24 * 3_600_000,
when=when("steps.classify.output.kind", "eq", "bug"))
@support.step("send", input={"text": "${steps.draft.output}"}, effect="at_most_once",
retry=RetryPolicy(max_attempts=3, backoff="exponential", initial_ms=500))
def send(ctx, input, prior):
if not deliver(input["text"], ctx.idempotency_key):
raise StepFailure("mail relay busy", class_="retryable", retry_after_ms=2_000)
return {"sent": True}
support.output({"sent": "${steps.send.output.sent}"}) それぞれの構成要素の働き、リースのプロトコル、実行の API は ワークフロー、 ワーカー、 実行のページにあります。
スコアとフィードバック
min と max はキーワード専用で、既定はあり
ません。スケールの宣言がない裸の数値は何とも比べられないので、あとから
推測するのではなく、呼び出し側でスケールを書かせます。フィードバックは人の
判断であり、スコアとは別に保たれます。2 つは別の問いに答えるものなので、
まとめて平均してはいけません。
スコアとフィードバック
# A machine evaluation. `min` and `max` are required: a bare number with no
# declared scale cannot be compared to anything.
mcpd.score(
"answer_relevance",
value=round(random.uniform(0.6, 0.95), 2),
min=0.0,
max=1.0,
trace_id=trace_id,
reason="named the order and gave a delivery window, without the live lookup",
)
# A human evaluation. Kept separate from a score, because a person's
# judgement and a model's must never be averaged together.
mcpd.feedback(
FeedbackKind.THUMBS,
"up",
trace_id=trace_id,
comment="fine given the outage",
author_kind="end_user",
) どちらもトレースを指定しなければ、アクティブなスパンのトレースに付きます。 ログとメトリクスも同じなので、チャート上のスパイクから、それを起こした実行を 開けます。
フラッシュとシャットダウン
記録はメモリ上のキューに追加して戻るだけで、HTTP はデーモンスレッドが担い
ます。flush() は待っているものを送り、タイムアウトまでに送れ
なければ False を返します。shutdown() はフラッシュ
してワーカーを止め、トランスポートを解放します。どちらも何度呼んでも結果は
同じです。イベントループの中では aflush() と
ashutdown() を呼びます。同じ処理を、ループを止めずに行います。
サーバーレス関数では、これがどこよりも重要になります。ハンドラーが戻った 瞬間にランタイムがプロセスを凍結し、ワーカースレッドは二度と動かないので、 キューに残っていたバッチは失われます。
サーバーレスのハンドラー
def handler(event, context):
with mcpd.trace("answer") as run:
run.set_input(event["body"])
answer = agent(event["body"])
run.set_output(answer)
# この時点でバッチはまだメモリの中。ハンドラーが戻った瞬間にランタイムが
# このプロセスを凍結し、ワーカースレッドは二度と動かない。
mcpd.flush()
return {"statusCode": 200, "body": answer}
クライアントは atexit フックも登録するので、シャットダウンを
忘れた普通のスクリプトでも最後のバッチは届きます。このフックは
os._exit() や致命的なシグナルでは動かないので、安全網であって
代わりにはなりません。
保証されること
- 計装は例外を投げません。記録の呼び出しはすべて保護されており、失敗は 1 間隔につき多くても 1 回ログに出て、そのまま飲み込まれます。可観測性が、 観測している相手を落としてはいけません。例外は読み出しの呼び出しだけで、 こちらは例外を投げます。待っているクエリは、話が別だからです。
- 記録はブロックしません。エージェントのレイテンシが、mcpd の速さや、mcpd が 動いているかどうかに左右されることはありません。
-
メモリには上限があります。保持するシグナルは
max_queue件、 既定は 8192 件です。いっぱいになると古いものから捨てられ、mcpd.droppedがその数を数え、1 間隔につき 1 回警告をログに 出します。上限のないバッファは、ネットワーク障害をメモリ不足による強制 終了に変えてしまいます。 -
サンプリングの判断はルートでトレース id から一度だけ行われ、すべての子に
引き継がれます。受け取った
traceparentが常に優先されます。 - スパンは開始時にエクスポートされ、終了時に更新されるので、途中でクラッシュ した実行も見えます。
-
タイムスタンプは
time.time_ns()から取り、9 桁の UTC として 送ります。ローカル時刻では、分散トレースは読めなくなります。
読み出し
読み出しのメソッドは HTTP API を写したもので、生成された型を返します。
同期的に動き、失敗すると McpdError を投げます。
from_ と to は datetime のほか、
-15m、-1h、-7d のような短縮形も
受け取ります。非同期のコードでは、asyncio.to_thread でループの
外に出して呼んでください。
検索と分析
# Nothing has been sent yet: recording appends to an in-memory queue and a
# background worker does the I/O. Flush before you read your own writes.
mcpd.flush()
detail = mcpd.get_trace(trace_id, include=["logs", "scores", "feedback"])
print(f"\n{detail.trace.name} {detail.trace.duration_ns / 1e6:.0f} ms "
f"{detail.trace.span_count} spans {detail.trace.error_count} errors")
for span in detail.spans:
cost = f" ${span.cost.total_cost:.6f}" if span.cost else ""
print(f" {span.kind.value:<18} {span.name:<14} {span.status.value}{cost}")
summary = mcpd.analytics_summary("-1h", "-0s")
print(f"\nlast hour traces={summary.trace_count} errors={summary.error_count} "
f"p95={summary.latency.p95:.0f}ms cost=${summary.total_cost:.4f}")