Elixir
A GenServer for the worker, the process for the context, and nothing added to your dependency tree.
Install
Add it to mix.exs. It brings nothing with it.
defp deps do
[{:mcpd, "~> 0.1"}]
end
That is the whole dependency list, and it is deliberate. OTP 27 ships a JSON
codec (:json) and an HTTP client (:inets), so a
library whose job is to watch your system does not have to become a reason
your system's build changes. The other five SDKs make the same promise in
their own languages.
Construct the client
Every option falls back to its environment variable, and code wins.
Mcpd.start_link() with no arguments talks to a local daemon.
connect
# Every option also reads an environment variable (MCPD_ENDPOINT, MCPD_PROJECT,
# MCPD_API_KEY, ...), so `Mcpd.start_link()` on its own works too.
{:ok, mcpd} =
Mcpd.start_link(
endpoint: "http://localhost:4317",
project: "support-desk",
environment: "development",
service_name: "support-agent",
service_version: "1.4.0"
) Put it in your supervision tree and it behaves like any other worker:
children = [
{Mcpd, project: "support-desk", environment: "production"}
]
Supervisor.start_link(children, strategy: :one_for_one) Record a run
span is a macro, so a run is a block and its value is the
block's value.
record a run
question = "where is my order 41?"
# The block's value is the value of `span`. Inside it, this span is the active
# one for the process, so the helpers below find their parent without being
# handed it.
trace_id =
Mcpd.span mcpd, "support_agent",
kind: :agent_run,
input: question,
session_id: "session-8f21",
user_id: "customer-4477",
tags: ["support", "email"] do
Mcpd.log(mcpd, :info, "handling a question", %{"channel" => "email"})
plan = plan_answer.(mcpd, question)
order = look_up_order.(mcpd, 41)
estimate = estimate_delivery.(mcpd, 41)
_answer =
[plan, order, estimate && "It should arrive #{estimate}."]
|> Enum.reject(&is_nil/1)
|> Enum.join(" ")
Mcpd.metric(mcpd, "support.answers", 1, kind: :counter, unit: "{answer}")
Mcpd.Context.current().trace_id
end
Macros have to be required before they can be called, which is a rule of the
language rather than a choice here. use Mcpd does it for you:
defmodule MyAgent do
use Mcpd
def answer(mcpd, question) do
Mcpd.span mcpd, "agent", kind: :agent_run, input: question do
do_the_work(question)
end
end
end
If the body is already a function rather than a block, call
Mcpd.with_span/4 instead. It is the same thing without the
macro.
The tree assembles itself
The active span lives in the process dictionary, and that is the right store in Elixir specifically rather than a shortcut. A process is the unit of concurrency here, so two requests handled at the same time cannot see each other's span, and nothing has to be threaded through every function to keep the tree together.
A child started three calls deep finds its parent on its own:
Mcpd.span mcpd, "agent", kind: :agent_run do
plan(mcpd) # opens a child of "agent" without being told
end
defp plan(mcpd) do
Mcpd.span mcpd, "plan_answer", kind: :model_generation, model: "gpt-4o" do
call_the_model()
end
end
The consequence worth knowing: a span opened inside Task.async
starts a new trace, because that is a different process. Pass the trace
across with Mcpd.traceparent/0 and
Mcpd.continue/2, the same pair you would use across an HTTP
boundary.
-
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
Nothing blocks, and nothing raises
Recording a span sends a message to the worker and returns. The network happens on that worker, so your agent's latency never depends on mcpd being fast, or up, or running at all.
A raise inside a block is recorded on the span as an error and then re-raised unchanged. mcpd's job is to see what happened, never to decide what your code does about it.
The queue is bounded, and when it fills the oldest signals are dropped. That
direction is chosen: when the daemon is unreachable, the newest signals
describe what is happening now, and those are the ones somebody will want
when it comes back. Mcpd.stats/1 answers what the client has
done: queued, dropped, capacity,
exported, failed and rejected.
log_level:, or MCPD_LOG_LEVEL, sets a floor under
Mcpd.log/4. The default is debug, which keeps
everything. A line below the floor is dropped before the record is built,
so a debug line in a production deployment costs one comparison. The floor
covers logs only; spans, metrics, scores and feedback are unaffected.
Keep payloads in the process
capture_payloads: false, or
MCPD_CAPTURE_PAYLOADS=false, keeps prompts and completions
where they are. Spans still go out with their timing, status, usage, model,
model parameters, correlation and errors; input:,
output: and a block's captured value are dropped before they
reach the queue, so nothing sensitive is queued and nothing sensitive leaves
on a flush. One span can say otherwise for itself with
capture_payloads: true.
How the model was called
model_parameters: records the settings a model call was made
with: temperature, top_p, top_k,
max_tokens, seed, stop_sequences and
has_response_format. Give it a keyword list, or a
%Mcpd.ModelParameters{} when you already hold one.
Mcpd.span mcpd, "answer",
kind: :model_generation,
model_parameters: [temperature: 0.0, seed: 0, max_tokens: 1024] do
call_the_model()
end
They are settings rather than payload, so they survive
capture_payloads: false, which is the case that matters: they
are what somebody reproducing the run has to match. A field you leave out is
absent and one you give is kept whatever it is, so seed: 0 is a
seed. The schema behind has_response_format is not recorded,
only whether one was in force. It is an option rather than a setter because
an Elixir span is described by the options it was opened with, the same way
model:, provider: and usage: already
are.
Assert what you recorded
A sink is where a drained batch goes. The HTTP transport is the default;
Mcpd.Sink.Recorder keeps every batch in memory, so a test reads
back the spans, logs, metrics, scores and feedback the instrumentation
recorded without a daemon, and Mcpd.Sink.Console prints one
JSON line per signal to stderr. A sink of your own implements
Mcpd.Sink; one that raises is a failed batch, counted and
reported, never raised to the caller.
Mcpd.record/3 queues a span that already ended, as given: its
own ids, parent, start and end, never re-parented, re-timed or re-sampled.
That is what an adapter for another tracing system or a batch import holds.
A record whose ids are not OpenTelemetry-shaped is refused and counted, and
never raises. external_parent_span_id: on any span carries a
parent from a tracing system that is not mcpd, for correlation only.
Durable runs
The same client serves the daemon's durable runs. Mcpd.Workflow
builds the definition and keeps the step bodies as three-arity functions;
a Mcpd.StepWorker registers it, long-polls for leases and
serves each one on a process of its own, and Mcpd.start_run/3,
Mcpd.watch_run/3, Mcpd.publish_event/4 and the
rest mirror the run surface one for one. A body raises
Mcpd.StepException with class: :retryable to be
retried within the step's budget, and records spans under its step through
Mcpd.StepContext.span/4. Every span the body closes adds its
usage to the step's, and the last model seen wins, so the daemon prices
what the trace shows; set_usage/2 is the explicit override.
on_lease, on_step_start,
on_step_end and on_conflict sit beside
on_error on the worker, and StepWorker.stats/1
counts what it has claimed, completed, failed by class and abandoned by
reason.
a workflow
support =
Workflow.new("support_ticket")
|> Workflow.step("classify", fn _ctx, input, _prior ->
kind = if String.starts_with?(input["ticket"], "Q"), do: "question", else: "bug"
%{"kind" => kind}
end)
|> Workflow.agent(
"draft",
fn ctx, input, _prior ->
# Your own model call, recorded under this step in the run's trace. Its
# usage is the step's: the daemon prices what the trace shows.
opts = [kind: :model_generation, model: "claude-sonnet-4", provider: "anthropic",
usage: %{"input_tokens" => 812, "output_tokens" => 37}]
StepContext.span(ctx, "model_call", opts, fn -> :ok end)
"an answer to a #{input["kind"]}"
end,
input: %{"kind" => "${steps.classify.output.kind}"}
)
|> Workflow.wait_for("approval",
event: "approved",
key: "${input.ticket}",
timeout_ms: 7 * 24 * 3_600_000,
when: where("steps.classify.output.kind", :eq, "bug")
)
|> Workflow.step(
"send",
fn ctx, input, _prior ->
unless deliver.(input["text"], ctx.idempotency_key) do
raise Mcpd.StepException,
message: "mail relay busy", class: :retryable, retry_after_ms: 2_000
end
%{"sent" => true}
end,
input: %{"text" => "${steps.draft.output}"},
effect: :at_most_once,
retry: [max_attempts: 3, backoff: :exponential, initial_ms: 500]
)
|> Workflow.output(%{"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 and feedback
# A machine evaluation. `min` and `max` are required: 7 is excellent out of 10
# and poor out of 100, and a score with no scale cannot be compared.
Mcpd.score(mcpd, trace_id, "answer_relevance", 0.86,
min: 0,
max: 1,
reason: "answered the question asked, without the delivery estimate"
)
# A person's judgement, from the thumbs-up control in your own product.
Mcpd.feedback(mcpd, trace_id, :thumbs, "up",
comment: "fast and clear",
author_id: "customer-4477"
)
# Nothing above touched the network. This is where it goes.
:ok = Mcpd.flush(mcpd) Reading it back
read the run
{:ok, detail} = Mcpd.get_trace(mcpd, trace_id, include: ["scores", "feedback"])
IO.puts("#{detail.trace.name}: #{length(detail.spans)} spans")
for span <- detail.spans do
ms = div(span.duration_ns || 0, 1_000_000)
error = (span.error && span.error.message) || ""
IO.puts(" #{span.kind} #{span.name} #{ms}ms #{error}")
end Short-lived processes must flush
A script or a task that exits quickly loses whatever had not been sent.
Mcpd.shutdown/1 flushes and stops the worker, and is safe to
call twice.
run the example against a local daemon
mcpd serve --data ./mcpd.db
elixir examples/elixir/quickstart.exs Next
- Every behaviour this SDK shares with the other five: the SDK contract.
- What a span carries: Data model.
- Finding the run you care about afterwards: Finding a run.