Skip to content
mcpd
no daemon
Source

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

trace_id Store Search Export

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