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

Go

アクティブなスパンは、ほかの Go ライブラリと同じく context.Context が運びます。サードパーティ依存はなく、記録の呼び出しが失敗することもありません。

インストール

Go 1.21 以降で動きます。サードパーティ依存はありません。トランスポートは 標準ライブラリの net/http と compress/gzip なので、 mcpd を足してもモジュールグラフにはほかに何も増えません。

go get github.com/mcpd/mcpd/sdks/go

クライアントの構築

クライアントは 1 プロセスに 1 つです。どの設定にもオプションと環境変数が あるので、同じコードを書き換えずに開発環境でも本番環境でも動かせます。 オプションを何も渡さない mcpd.New() は、ローカルのデーモンに 対して動きます。

接続

m := mcpd.New(
	mcpd.WithEndpoint("http://localhost:4317"),
	mcpd.WithProject("support"),
	mcpd.WithEnvironment("development"),
	mcpd.WithService("support-agent", "1.4.2"),
)
// Shutdown flushes what is still queued and stops the background worker.
// A short-lived process that skips it loses whatever had not been sent.
defer m.Shutdown(ctx)

コードが環境変数より優先され、環境変数が既定値より優先されます。

オプション環境変数既定値
WithEndpointMCPD_ENDPOINThttp://localhost:4317
WithAPIKeyMCPD_API_KEYなし
WithProjectMCPD_PROJECTdefault
WithEnvironmentMCPD_ENVIRONMENTなし
WithServiceMCPD_SERVICE_NAME, MCPD_SERVICE_VERSIONなし
WithSampleRateMCPD_SAMPLE1.0
WithBatchSizeMCPD_BATCH_SIZE128
WithFlushIntervalMCPD_FLUSH_INTERVAL_MS2s
WithMaxQueueMCPD_MAX_QUEUE8192
WithTimeoutMCPD_TIMEOUT_MS10s
WithEnabledMCPD_ENABLEDtrue
WithCapturePayloadsMCPD_CAPTURE_PAYLOADStrue
WithLogLevelMCPD_LOG_LEVELdebug

WithEnabled(false) にすると、記録は何もしなくなりますが、返る スパンと id は有効なままです。呼び出し箇所を変えずに、テストのあいだ mcpd を 止められます。読み出しのメソッドはそのまま動きます。計装を止めたことで、 クエリの答えが黙って空になってはいけないからです。

WithCapturePayloads(false) にすると、プロンプトと補完は プロセスの中に留まります。スパンは所要時間、ステータス、使用量、モデル、 相関、エラーを持ったままで、input と output は キューに入る前に捨てられます。個々のスパンは WithSpanCapturePayloads で別の扱いにでき、有効にも無効にも できます。

WithSink は、バッチの行き先を差し替えます。 RecordingSink はバッチをメモリに保持するので、テストはサーバー なしで計装が記録した内容を検証できます。ConsoleSink は、 シグナル 1 件につき JSON を 1 行書きます。失敗したシンクは Stats() に数えられ、シグナルを記録したコードまで届くことは ありません。Record は、すでに終了したスパンを id と時刻ごと、 渡されたままキューに入れます。形式の壊れたものは受け付けられずに数えられ、 エラーとして返ることはありません。WithExternalParent は、別の トレーシングシステムの親を相関のためだけに運びます。

実行の記録

Start は新しいコンテキストとスパンを返し、 defer span.End() がそれを閉じます。この組み合わせが、Go での スコープ付きの書き方です。defer は途中の return でもパニックでも実行されるので、 思いつかなかった制御フローのせいでスパンが開いたままになることはありません。

スコープ付きの書き方

ctx, span := m.Start(ctx, "retrieve_docs", mcpd.WithSpanKind(mcpd.SpanKindRetrieval))
defer span.End()

// ここから下でこの ctx を受け取るものは、すべてこのスパンの子になる。End は
// 冪等なので、defer と明示的な呼び出しが所要時間で食い違うことはない。

実行の記録

// handleTicket is the agent. It drafts an answer with a model, tries to send
// it with a tool, and fails on the send so the error path is recorded too.
func handleTicket(ctx context.Context, m *mcpd.Client, question string) string {
	ctx, run := m.Start(ctx, "support_agent",
		mcpd.WithSpanKind(mcpd.SpanKindAgentRun),
		mcpd.WithEntity(mcpd.EntityKindAgent, "support-agent", "Support agent"),
		mcpd.WithSession("sess-4821"),
		mcpd.WithUser("u-317"),
		mcpd.WithTags("support", "billing"),
		mcpd.WithInput(question),
	)
	defer run.End()

	m.Log(ctx, mcpd.LogLevelInfo, "ticket received", map[string]any{"channel": "email"})

	answer := draftAnswer(ctx, m, question)
	run.SetOutput(answer)

	if err := sendReply(ctx, m, answer); err != nil {
		// The span is now an error with a message, which is what makes it
		// worth reading. End below records the failed status.
		run.RecordError(err, mcpd.ErrorDomain("email"), mcpd.ErrorRetryable(true))
	}
	return run.TraceID()
}

コンテキスト

Start は、コンテキストに入っているスパンの子を作り、何も入って いなければルートを作ります。Trace は必ずルートを開始し、 span.Child(ctx, name) は、コンテキストで持ち回っていない特定の スパンの子を開始します。ここで 2 つ目の伝播の仕組みを作ってはいません。親は、 もともと渡していた ctx の中にあります。

入れ子

func handle(ctx context.Context, m *mcpd.Client, q string) string {
    ctx, run := m.Start(ctx, "support_agent", mcpd.WithSpanKind(mcpd.SpanKindAgentRun))
    defer run.End()

    // draft に渡すのはスパンではなく ctx。仕組みはそれだけ。
    return draft(ctx, m, q)
}

func draft(ctx context.Context, m *mcpd.Client, q string) string {
    _, span := m.Start(ctx, "draft_answer", mcpd.WithSpanKind(mcpd.SpanKindModelGeneration))
    defer span.End()
    return "..."
}

サービスの境界をまたぐときは、Inject が送信するヘッダーに W3C の traceparent を書き、Extract が受信したヘッダーから それを読んでコンテキストに入れます。traceparent のないヘッダーは そのまま返るので、読めないヘッダーは、それを運んできたリクエストを失敗させる のではなく、新しいトレースを始めます。上流のサンプリングの判断は引き継がれる ので、トレースの前半と後半が食い違うことはありません。

サービス境界をまたぐ

// 送信側: アクティブなトレースコンテキストをリクエストに書く。
req, _ := http.NewRequestWithContext(ctx, http.MethodPost, url, body)
mcpd.Inject(ctx, req.Header)

// 受信側: それを読み返して同じトレースを続ける。
func handler(w http.ResponseWriter, r *http.Request) {
    ctx, span := m.Start(mcpd.Extract(r.Context(), r.Header), "handle_request")
    defer span.End()
}

モデル、使用量、エラー

mcpd は取り込み時にトークン数から料金を計算するので、トレースに出るコストは 実際に請求されたコストになります。RecordError は文字列ではなく error を受け取ります。記録された失敗に必ずメッセージが付くのは、 これによります。そのあと End は、ステータスとして ok ではなく error を記録します。

スパンが持つもの

span.SetModel("gpt-4o-mini", "openai")
span.SetTokens(812, 96)

// あるいは内訳ごと。キャッシュされたプロンプトは、そのまま
// キャッシュされたプロンプトとして値付けされる。
span.SetUsage(mcpd.TokenUsage{
    InputTokens:  ptr(uint64(812)),
    OutputTokens: ptr(uint64(96)),
    InputDetails: &mcpd.InputTokenDetails{CacheRead: ptr(uint64(640))},
})

// RecordError は error を取るので、記録された失敗には必ずメッセージが付く。
span.RecordError(err,
    mcpd.ErrorKind("SMTPTemporaryFailure"),
    mcpd.ErrorRetryable(true),
    mcpd.ErrorStack(mcpd.Stack()),
)

そのほかのスパンのメソッドは SetInput、 SetOutput、SetMetadata、 SetAttributes、SetTags、SetStatus、 SetSession、SetThread、SetUser、 Event、EndWithStatus です。同じフィールドは、 With... オプションでスパンの開始時にも設定できます。

永続実行

同じクライアントがデーモンの永続実行も扱います。NewWorkflow が 定義を組み立ててステップの本体を保持し、Worker がそれを登録し、 リースをロングポーリングで受け取って 1 件ずつゴルーチンの上で処理します。 StartRun、WatchRun、PublishEvent なども、 実行の API を 1 対 1 で写しています。本体が mcpd.Retryable(err, wait) を返すと、ステップの予算の範囲でリトライ されます。ハンドラーの ctx から開始したスパンは、実行のトレースの 中で自分のステップの下に入ります。そうしたスパンが使った分が、そのステップの 使用量です。カウントは各スパンの終了時に合計され、最後に見えたモデルが採用され、 SetUsage は設定したフィールドだけを上書きします。ワーカーの中の 様子は、WorkerOnLease、WorkerOnStepStart、 WorkerOnStepEnd、WorkerOnConflict、 worker.Stats() から見られます。

ワークフロー

support := mcpd.NewWorkflow("support_ticket").
	Step("classify", func(ctx context.Context, step *mcpd.StepContext) (any, error) {
		var in struct {
			Ticket string `json:"ticket"`
		}
		if err := step.Input(&in); err != nil {
			return nil, err
		}
		kind := "bug"
		if strings.HasPrefix(in.Ticket, "Q") {
			kind = "question"
		}
		return map[string]any{"kind": kind}, nil
	}).
	Agent("draft", func(ctx context.Context, step *mcpd.StepContext) (any, error) {
		// Your own model call. A span started from ctx lands under this step,
		// and what it spent is the step's usage: the daemon prices the sum.
		_, span := m.Start(ctx, "model_call", mcpd.WithModel("claude-sonnet-4", "anthropic"))
		defer span.End()
		span.SetTokens(812, 37)
		return "an answer", nil
	}, mcpd.Input(map[string]any{"kind": "${steps.classify.output.kind}"})).
	WaitFor("approval", "approved", "${input.ticket}", 7*24*time.Hour,
		mcpd.Guard(mcpd.When("steps.classify.output.kind", "eq", "bug"))).
	Step("send", func(ctx context.Context, step *mcpd.StepContext) (any, error) {
		if err := deliver("the draft", step.IdempotencyKey); err != nil {
			return nil, mcpd.Retryable(err, 2*time.Second)
		}
		return map[string]any{"sent": true}, nil
	}, mcpd.Input(map[string]any{"text": "${steps.draft.output}"}), mcpd.AtMostOnce(),
		mcpd.Retry(mcpd.RetryPolicy{
			MaxAttempts: 3, Backoff: mcpd.BackoffExponential, Initial: 500 * time.Millisecond,
		})).
	Output(map[string]any{"sent": "${steps.send.output.sent}"})

それぞれの構成要素の働き、リースのプロトコル、実行の API は ワークフロー、 ワーカー、 実行のページにあります。

スコアとフィードバック

Score は min と max を普通の位置引数 として取るので、スケールを省くことはできません。0.86 という数値だけでは、 どの範囲の上にあるのかが分からないかぎり何の意味もありません。フィードバックは 人の判断であり、スコアとは別に保たれます。2 つは別の問いに答えるものなので、 まとめて平均してはいけません。

スコアとフィードバック

// A machine evaluation of the run. min and max are required: 0.86 means
// nothing until you know the scale it sits on.
m.Score(ctx, mcpd.TraceTarget(traceID), "answer_relevance", 0.86, 0, 1,
	mcpd.ScoreReason("Answered the billing question and cited the invoice."),
	mcpd.ScoreVersion("2"),
)

// A person's judgement of the same run. Wire this to your product's
// thumbs-up control and it lands on the exact trace that produced the
// answer.
m.Feedback(ctx, mcpd.TraceTarget(traceID), mcpd.FeedbackKindThumbs, "up",
	mcpd.FeedbackComment("Fixed it first time."),
	mcpd.FeedbackAuthor("u-317", "end_user"),
)

判断の対象を指すには mcpd.TraceTarget(id) か mcpd.SpanTarget(id, spanID) を渡します。空の mcpd.Target{} を渡すと、コンテキストに 入っているスパンが使われます。

5 つのシグナル、1 組の id

Log、Metric、Score、 Feedback はどれもコンテキストを取るので、5 つのシグナルはすべて 同じトレース id とスパン id を持ちます。だからこそ、チャート上のスパイクから、 それを起こした実行を開けます。

  • Spans

    What ran, nested, with timings

  • Logs

    Lines the run emitted

  • Metrics

    Numbers, with exemplars back to a run

  • Scores

    Machine evaluation

  • Feedback

    What a person thought

  • Aspect notes

    A sentence a model wrote about the run

trace_id Store Search Export

フラッシュとシャットダウン

Start と End はメモリ上のキューに追加して戻るだけで、 バッチにまとめて送るのはバックグラウンドのゴルーチンです。 Flush(ctx) は、何も止めずにキューにあるものを送ります。 Shutdown(ctx) はフラッシュしてワーカーを止め、何度呼んでも結果は 同じです。New のすぐ隣に defer m.Shutdown(ctx) を 書くのが良い習慣なのは、そのためです。

サーバーレス関数では、ハンドラーが戻った瞬間にランタイムがプロセスを凍結し、 ワーカーのゴルーチンは二度と動かないので、キューに残っていたバッチは失われます。 戻る前にフラッシュしてください。

サーバーレスのハンドラー

func Handler(ctx context.Context, event Event) (Response, error) {
    ctx, run := m.Start(ctx, "answer")
    answer := agent(ctx, event.Body)
    run.SetOutput(answer)
    run.End()

    // この時点でバッチはまだメモリの中。ハンドラーが戻った瞬間にランタイムが
    // このプロセスを凍結し、ワーカーのゴルーチンは二度と動かない。
    if err := m.Flush(ctx); err != nil {
        log.Printf("mcpd flush: %v", err)
    }
    return Response{Body: answer}, nil
}

保証されること

  • 何かを記録するメソッドは、エラーを返すこともパニックすることもありません。 nil のクライアントに対して呼んだ場合も同じです。エクスポートの失敗は、 フラッシュ間隔ごとに多くても 1 回ログに出て、捨てられます。可観測性が、 観測している相手を落としてはいけません。
  • 記録はブロックしないので、エージェントのレイテンシが、mcpd の速さや、 mcpd が動いているかどうかに左右されることはありません。
  • メモリには上限があります。キューが保持するシグナルは WithMaxQueue 件で、それを超えると古いものから捨てられ、 数えられます。その数は、エクスポートできた数、あきらめた数、 Record が受け付けなかった数とともに Stats() で 見られます。上限のないバッファは、ネットワーク障害をメモリ不足による強制 終了に変えてしまいます。
  • サンプリングの判断は、ルートでトレース id のハッシュから一度だけ行われ、 すべての子に引き継がれます。そのため、いくつのプロセスが動いていても、 同じトレースについてはどれも同じ判断をします。
  • 終わっていないスパンもエクスポートされます。長く動くスパンは開始時に出て いき、終了時に更新されるので、途中でクラッシュした実行も見えます。次の フラッシュより前に終わったスパンは、完了した状態で 1 回だけ送られます。
  • エンコードできない値は、呼び出しを失敗させるのではなく、捨ててログに出します。 ペイロードのシリアライズの失敗が、エージェントの失敗として表に出てはいけない からです。

読み出し

読み出しのメソッドは HTTP API を写したもので、生成された型を返します。 エラーを返すのはこれらのメソッドだけです。呼び出し側が答えを待っているから です。

検索と分析

// Recording is asynchronous, so wait for the batch to land before reading
// it back. Outside an example you would never need this.
if err := m.Flush(ctx); err != nil {
	log.Printf("flush: %v", err)
}

detail, err := m.GetTrace(ctx, traceID, "scores", "feedback")
if err != nil {
	log.Fatalf("read the run back: %v", err)
}
fmt.Printf("%s: %s in %s, %d spans\n",
	detail.Trace.TraceID, detail.Trace.Status, duration(detail.Trace.DurationNs), len(detail.Spans))
for _, span := range detail.Spans {
	fmt.Printf("  %-18s %-18s %s\n", span.Kind, span.Name, span.Status)
}

summary, err := m.AnalyticsSummary(ctx, time.Now().Add(-time.Hour), time.Now(), "model")
if err != nil {
	log.Fatalf("read the summary: %v", err)
}
fmt.Printf("last hour: %d runs, %.1f%% failed, %.4f %s\n",
	summary.TraceCount, summary.ErrorRate*100, summary.TotalCost, summary.Currency)

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