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.
| Option | Environment | Default |
|---|---|---|
WithEndpoint | MCPD_ENDPOINT | http://localhost:4317 |
WithAPIKey | MCPD_API_KEY | none |
WithProject | MCPD_PROJECT | default |
WithEnvironment | MCPD_ENVIRONMENT | none |
WithService | MCPD_SERVICE_NAME, MCPD_SERVICE_VERSION | none |
WithSampleRate | MCPD_SAMPLE | 1.0 |
WithBatchSize | MCPD_BATCH_SIZE | 128 |
WithFlushInterval | MCPD_FLUSH_INTERVAL_MS | 2s |
WithMaxQueue | MCPD_MAX_QUEUE | 8192 |
WithTimeout | MCPD_TIMEOUT_MS | 10s |
WithEnabled | MCPD_ENABLED | true |
WithCapturePayloads | MCPD_CAPTURE_PAYLOADS | true |
WithLogLevel | MCPD_LOG_LEVEL | debug |
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
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
WithMaxQueuesignals; past that the oldest are dropped and counted, visible inStats()alongside what was exported, what was given up on and whatRecordrefused. 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.