Skip to content
mcpd
no daemon
Source

Go

context.Context carries the active span, the way every other Go library does it. No third-party dependencies, and no recording call that can fail.

Install

Go 1.21 or later. There are no third-party dependencies: the transport is net/http and compress/gzip from the standard library, so adding mcpd adds nothing else to your module graph.

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

Construct the client

One client per process. Every setting has an option and an environment variable, so the same code runs in development and in production without an edit. mcpd.New() with no options at all works against a local daemon.

connect

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)

Code wins over the environment, and the environment wins over the default.

OptionEnvironmentDefault
WithEndpointMCPD_ENDPOINThttp://localhost:4317
WithAPIKeyMCPD_API_KEYnone
WithProjectMCPD_PROJECTdefault
WithEnvironmentMCPD_ENVIRONMENTnone
WithServiceMCPD_SERVICE_NAME, MCPD_SERVICE_VERSIONnone
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) turns recording into a no-op that still returns valid spans and ids, so a test can disable mcpd without changing a call site. The read methods keep working, because turning instrumentation off should not quietly turn a query into an empty answer.

WithCapturePayloads(false) keeps prompts and completions in the process: spans still carry their timing, status, usage, model, model parameters, correlation and errors, and input and output are dropped before they reach the queue. One span can say otherwise for itself with WithSpanCapturePayloads, in either direction.

WithSink replaces where batches go. A RecordingSink keeps them in memory, so a test asserts what the instrumentation recorded without a server; a ConsoleSink writes one JSON line per signal. A sink that fails is counted in Stats() and never reaches the code that recorded the signals. Record queues a span that already ended, ids and times included, as given; a malformed one is refused and counted, never returned as an error. WithExternalParent carries the parent from another tracing system for correlation only.

Record a run

Start returns a new context and a span, and defer span.End() closes it. That pair is the scoped form in Go: the defer runs on an early return and on a panic, so a span cannot be left open by a control-flow path you did not think about.

the scoped form

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

// Everything that takes this ctx from here down becomes a child of the span,
// and End is idempotent, so a defer and an explicit call cannot disagree about
// the duration.

record a run

// 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()
}

Context

Start makes a child of whatever span is in the context, and a root when there is none. Trace always starts a root, and span.Child(ctx, name) starts a child of a specific span when you are not carrying it in a context. Nothing here invents a second propagation mechanism: the parent is in the ctx you were already passing.

nesting

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 is handed the ctx, not the span. That is the whole mechanism.
    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 "..."
}

Across a service boundary, Inject writes a W3C traceparent onto outbound headers and Extract reads one from inbound headers into a context. Headers without one are returned unchanged, so an unreadable header starts a new trace instead of failing the request that carried it. The upstream sampling decision is inherited, so the two halves of a trace agree.

across a service boundary

// Outbound: write the active trace context onto the request.
req, _ := http.NewRequestWithContext(ctx, http.MethodPost, url, body)
mcpd.Inject(ctx, req.Header)

// Inbound: read it back and continue the same trace.
func handler(w http.ResponseWriter, r *http.Request) {
    ctx, span := m.Start(mcpd.Extract(r.Context(), r.Header), "handle_request")
    defer span.End()
}

Model, usage and errors

Token counts are what mcpd prices at ingest, so the cost on the trace is the cost you were billed. RecordError takes an error rather than a string, which is what guarantees a recorded failure has a message on it. End then records error as the status rather than ok.

SetModelParameters records the settings the call was made with: Temperature, TopP, TopK, MaxTokens, Seed, StopSequences and HasResponseFormat. They are settings rather than payload, so they survive WithCapturePayloads(false), which is the case that matters: they are what somebody reproducing the run has to match. A nil field is absent and a pointer to zero is a value, so a zero Seed is a seed. The schema behind HasResponseFormat is not recorded, only whether one was in force.

what a span carries

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

// How the model was called. Settings, not payload: kept with capture off.
span.SetModelParameters(mcpd.ModelParameters{
    Temperature: ptr(0.0),
    Seed:        ptr(int64(0)),
    MaxTokens:   ptr(uint64(1024)),
})

// Or the full breakdown, so a cached prompt is priced as a cached prompt.
span.SetUsage(mcpd.TokenUsage{
    InputTokens:  ptr(uint64(812)),
    OutputTokens: ptr(uint64(96)),
    InputDetails: &mcpd.InputTokenDetails{CacheRead: ptr(uint64(640))},
})

// RecordError takes an error, so a recorded failure always has a message.
span.RecordError(err,
    mcpd.ErrorKind("SMTPTemporaryFailure"),
    mcpd.ErrorRetryable(true),
    mcpd.ErrorStack(mcpd.Stack()),
)

The rest of the span methods are SetInput, SetOutput, SetMetadata, SetAttributes, SetTags, SetStatus, SetSession, SetThread, SetUser, Event and EndWithStatus. The same fields can be set when the span starts through the With... options.

Durable runs

The same client serves the daemon's durable runs. NewWorkflow builds the definition and keeps the step bodies; a Worker registers it, long-polls for leases and serves each one on a goroutine, and StartRun, WatchRun, PublishEvent and the rest mirror the run surface one for one. A body returns mcpd.Retryable(err, wait) to be retried within the step's budget, and a span started from the handler's ctx lands under its step in the run's trace. What such a span spent is the step's usage: the counts are summed as the spans end, the last model seen wins, and SetUsage overrides the fields it sets. The worker can be watched from inside through WorkerOnLease, WorkerOnStepStart, WorkerOnStepEnd, WorkerOnConflict and worker.Stats().

a workflow

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}"})

What each construct does, the lease protocol and the run API are on Workflows, Workers and Runs.

Scores and feedback

Score takes min and max as ordinary positional arguments, so the scale cannot be left out. A bare 0.86 means nothing until you know the range it sits on. Feedback is a person's judgement and stays separate from a score, because the two answer different questions and must never be averaged together.

score and feedback

// 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"),
)

Pass mcpd.TraceTarget(id) or mcpd.SpanTarget(id, spanID) to name what is being judged, or an empty mcpd.Target{} to use whatever span is in the context.

All five signals, one set of ids

Log, Metric, Score and Feedback all take the context, so all five signals carry the same trace and span ids. That is what lets a spike on a chart open the run that caused it.

  • 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

Flush and shutdown

Start and End append to an in-memory queue and return; a background goroutine batches and sends. Flush(ctx) sends what is queued without stopping anything. Shutdown(ctx) flushes, stops the worker and is idempotent, which is why defer m.Shutdown(ctx) next to New is the right habit.

In a serverless function the runtime freezes the process the moment the handler returns, and the worker goroutine never runs again, so a batch that was still queued is lost. Flush before you return.

a serverless handler

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()

    // The batch is still in memory here. The runtime freezes this process the
    // moment the handler returns, and the worker goroutine never runs again.
    if err := m.Flush(ctx); err != nil {
        log.Printf("mcpd flush: %v", err)
    }
    return Response{Body: answer}, nil
}

What you can rely on

  • No method that records anything returns an error or panics, including on a nil client. A failed export is logged at most once per flush interval and dropped. Observability must never take down the thing it observes.
  • Recording does not block, so your agent's latency never depends on mcpd being fast, or up.
  • Memory is bounded. The queue holds WithMaxQueue signals; past that the oldest are dropped and counted, visible in Stats() alongside what was exported, what was given up on and what Record refused. An unbounded buffer turns a network outage into an out-of-memory kill.
  • The sampling decision is made once, at the root, by hashing the trace id, and inherited by every child. Every process in a fleet therefore makes the same decision about the same trace.
  • An unfinished span is still exported. Long-running spans go out when they start and are updated when they end, so a run that crashes mid-flight is visible. A span that ends before the next flush is sent once, finished.
  • A value that will not encode is dropped and logged rather than failing your call, because a serialisation error in a payload must not surface as a failure in your agent.

Reading it back

The read methods mirror the HTTP API and return the generated types. These are the only methods that return errors, because the caller is waiting for an answer.

search and analyse

// 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)

Finding a run covers the filters in full. The data model lists every field these types carry.