TypeScript
One package with no dependencies. It records spans, logs, metrics, scores and feedback, and it never throws out of an instrumentation call.
Install
Node 20 or later, Deno, Bun, browsers and edge runtimes. ESM first, with a
CommonJS build for require. The only Node-specific pieces,
async_hooks and zlib, are loaded at runtime and
skipped where they do not exist.
npm install @mcpd/sdk Construct the client
One client per process. Every option also reads an environment variable, so
the same code runs in development and in production without an edit. With no
arguments at all it talks to a daemon on http://localhost:4317.
connect
import { Mcpd } from '@mcpd/sdk'
// Every field here also reads an environment variable, so the same code runs
// unchanged in development and in production. With no arguments at all this
// talks to a daemon on http://localhost:4317.
const mcpd = new Mcpd({
project: 'support',
environment: 'development',
serviceName: 'support-agent',
serviceVersion: '1.4.0',
}) Code wins over the environment, and the environment wins over the default.
| Option | Environment | Default |
|---|---|---|
endpoint | MCPD_ENDPOINT | http://localhost:4317 |
apiKey | MCPD_API_KEY | none |
project | MCPD_PROJECT | default |
environment | MCPD_ENVIRONMENT | none |
serviceName | MCPD_SERVICE_NAME | none |
serviceVersion | MCPD_SERVICE_VERSION | none |
sample | MCPD_SAMPLE | 1.0 |
enabled | MCPD_ENABLED | true |
capturePayloads | MCPD_CAPTURE_PAYLOADS | true |
logLevel | MCPD_LOG_LEVEL | debug |
batchSize | MCPD_BATCH_SIZE | 128 |
flushIntervalMs | MCPD_FLUSH_INTERVAL_MS | 2000 |
maxQueue | MCPD_MAX_QUEUE | 8192 |
timeoutMs | MCPD_TIMEOUT_MS | 10000 |
sink | the HTTP transport |
enabled: false records nothing and sends nothing, while every
call still returns a valid object and the list reads return an empty page. A
test suite can switch mcpd off without touching a line of instrumentation.
capturePayloads: false keeps prompts and completions in the
process. A span is still recorded with its timing, status, usage, model,
model parameters, correlation and errors; input and output are
dropped before they reach the queue, so nothing sensitive can leave on a
flush. A span overrides it for itself with capturePayloads in
its start options, in either direction.
Record a run
withSpan is the scoped form. It opens the span, keeps it active
for everything the callback starts, and ends it when the callback settles. If
the callback throws, the span is marked error with the thrown
message and the error is rethrown unchanged, so your own error handling is
not altered by being observed.
record a run
/**
* One ticket, one trace.
*
* `withSpan` opens the span, keeps it active for everything the callback
* starts, and ends it when the callback settles. Nothing inside has to be
* passed a handle to be nested under it.
*/
async function handleTicket(ticket: Ticket): Promise<{ answer: string; traceId: string }> {
let traceId = ''
const answer = await mcpd.withSpan('support_agent', async (run) => {
// Keep the id: a score or a piece of feedback that arrives later attaches
// to this run by it.
traceId = run.traceId
// Tags label the whole run, so the trace list can filter on them.
run.setTags(['support', 'tier-1'])
run.setCorrelation({ sessionId: ticket.id, userId: ticket.customerId })
run.setInput({ question: ticket.question })
const order = await lookUpOrder(ticket)
const answer = await draftAnswer(ticket, order)
run.setOutput({ answer })
return answer
})
return { answer, traceId }
} Context
On Node and Bun the active span lives in an AsyncLocalStorage.
Anything started inside a withSpan callback nests under it
without being handed a reference, and two runs interleaved on one event loop
never see each other's spans.
nesting
// Anything the callback starts nests under it. No handle is passed down.
await mcpd.withSpan('support_agent', async (run) => {
const order = await mcpd.withSpan(
'lookup_order',
async (tool) => {
tool.setInput({ orderId: 'A-1042' })
mcpd.log('info', 'this line belongs to lookup_order')
return { status: 'in transit' }
},
{ kind: 'tool_call' },
)
run.setOutput(order)
})
Browsers and most edge runtimes have no AsyncLocalStorage. There
the SDK keeps a single saved value instead. That nests correctly and survives
an await within one run, but it cannot tell two concurrent runs
apart, and the result would be one run's tool call appearing inside another
run's tree. When you run agents concurrently on those runtimes, pass the
parent explicitly.
explicit parent
// Correct on every runtime, including ones with no AsyncLocalStorage.
const run = mcpd.trace('support_agent')
const step = run.child('call_model', { kind: 'model_generation' })
step.end()
await run.withChild('call_tool', async (tool) => {
tool.setOutput({ ok: true })
})
run.end()
// Which mechanism is in use, at runtime.
mcpd.stats().context // 'async-local-storage' | 'fallback'
Between services, context travels in a W3C traceparent header.
This is the whole of what makes one trace survive a network hop. A malformed
header is ignored and a fresh root trace is started, because a header nobody
can read is not a reason to fail the request that carried it.
across a service boundary
// Outbound: put the active span on the request.
await fetch(url, {
headers: { traceparent: span.traceparent() },
})
// Inbound: continue that trace instead of starting a new one.
const run = mcpd.trace('handle_request', {
traceparent: request.headers.get('traceparent'),
}) Model, usage and errors
Token counts are what mcpd prices at ingest, so the cost on the trace is the
cost you were billed. Report what the provider told you rather than counting
tokens yourself. An error always carries a message, and setting one sets the
span's status to error in the same move, so the two can never
disagree.
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 capturePayloads: 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 a field you pass is kept whatever it is, so
seed: 0 is a seed. The schema behind
hasResponseFormat is not recorded, only whether one was in
force.
what a span carries
generation.setModel('claude-sonnet-4', 'anthropic')
generation.setUsage({
inputTokens: 812,
outputTokens: 37,
inputDetails: { cacheRead: 640 },
})
// How the model was called. Settings, not payload: kept with capture off.
generation.setModelParameters({ temperature: 0, seed: 0, maxTokens: 1024 })
// A failure you caught and handled yourself.
generation.setError('the provider timed out', {
kind: 'TimeoutError',
retryable: true,
})
// A point in time inside the span, with no duration of its own.
generation.event('first_token', { kind: 'model_chunk' }) Durable runs
The same client serves the daemon's durable runs. workflow()
builds the definition and keeps the step bodies; a Worker
registers it, long-polls for leases and serves each one, and
mcpd.runs, mcpd.events and
mcpd.definitions mirror the run surface one for one. A body
throws RetryableError to be retried within the step's budget,
and records spans under its step through the StepContext. A
span recorded that way, and every span nested under it, adds its usage to
the step's when it ends, so the daemon prices what the trace shows and the
body never sums tokens by hand; ctx.setUsage is an explicit
override. Four hooks sit beside onError:
onLease, onStepStart, onStepEnd and
onConflict, and worker.stats() counts claims,
completions, failures by class and conflicts by reason.
a workflow
const support = workflow({ id: 'support_ticket' })
.step('classify', async (ctx, input: { ticket: string }) => ({
kind: input.ticket.startsWith('Q') ? 'question' : 'bug',
}))
.agent(
'draft',
async (ctx, input: { kind: string }) => {
// Your own model call, recorded under this step in the run's trace. Its
// usage adds up into the step's, which the daemon prices.
return await ctx.withSpan('model_call', async (span) => {
span.setModel('claude-sonnet-4', 'anthropic')
span.setUsage({ inputTokens: 812, outputTokens: 37 })
return `an answer to a ${input.kind}`
})
},
{ input: { kind: '${steps.classify.output.kind}' } },
)
.waitFor('approval', {
event: 'approved',
key: '${input.ticket}',
timeoutMs: 7 * 24 * 3_600_000,
when: when('steps.classify.output.kind', 'eq', 'bug'),
})
.step(
'send',
async (ctx, input: { text: string }) => {
if (!(await deliver(input.text, ctx.idempotencyKey))) {
throw new RetryableError('mail relay busy', { retryAfterMs: 2_000 })
}
return { sent: true }
},
{
input: { text: '${steps.draft.output}' },
effect: 'at_most_once',
retry: { maxAttempts: 3, backoff: 'exponential', initialMs: 500 },
},
)
.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
min and max are required by the type. A score of
0.7 on an undeclared scale cannot be compared to anything, and by the time
someone is reading the chart it is too late to ask what it meant. Feedback is
a person's judgement and is kept separate from a score, because the two
answer different questions and must never be averaged together.
score and feedback
Both attach to the active span's trace when you do not name one, so a thumbs
control in your own product can call feedback with nothing but
the trace id you kept.
All five signals, one set of ids
Spans, logs, metrics, scores and feedback go through the same queue and 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
Recording is a queue append. Nothing has left the process until the worker
sends it, which it does every two seconds or when a batch fills. Both
flush() and shutdown() are awaitable and
idempotent, and shutdown() also stops the worker.
In a serverless function this is not optional. The runtime freezes the process the moment you return a response, and the background worker never runs again, so the last batch is lost. Await a flush before the response goes out.
a serverless handler
export default async function handler(request: Request): Promise<Response> {
const answer = await mcpd.withSpan('answer', async (run) => {
run.setInput(await request.text())
return 'ok'
})
// The batch is still in memory here. Send it before the response goes out.
await mcpd.flush()
return new Response(answer)
}
On Node the client also flushes on beforeExit, so a script that
forgets shutdown() still gets its last batch. That hook does not
fire on an explicit process.exit() or on a fatal signal, so it
is a safety net and not a replacement.
Assert what you recorded
A RecordingSink replaces the network with memory, so a test
reads back the spans, logs, metrics, scores and feedback the code under
test produced, with no server and no port. A ConsoleSink
prints one JSON line per signal instead, and any object with an
export(batch, ingestKey) method is a sink. One that throws is
a failed batch and a diagnostic, never an exception in your code.
mcpd.stats() answers queued,
dropped, capacity, exported,
failed and rejected.
a test with no server
import { Mcpd, RecordingSink } from '@mcpd/sdk'
const sink = new RecordingSink()
const mcpd = new Mcpd({ sink })
await answerQuestion(mcpd)
await mcpd.flush()
expect(sink.spans.map((s) => s.name)).toEqual(['answer_question', 'model_call'])
expect(sink.scores[0]?.scorerId).toBe('answer_relevance') Record a finished span
mcpd.record(span) queues a span whose ids, parent and times
you already hold: what an adapter for another tracing system, a batch
import or a replay has in hand. It is queued as given, never re-parented,
re-timed or re-sampled, with the correlation it left empty filled from the
client. A record whose ids are not OpenTelemetry-shaped, whose name is
empty or whose start time is missing is dropped and counted in
stats().rejected. On any span,
externalParentSpanId carries a parent from a tracing system
that is not mcpd, for correlation only.
a span from elsewhere
// Ids, parent and times the caller already holds. Queued as given.
mcpd.record({
traceId: otel.traceId,
spanId: otel.spanId,
parentSpanId: otel.parentSpanId,
name: 'GET /orders',
kind: 'http_request',
status: 'ok',
startTime: otel.startTime,
endTime: otel.endTime,
isRoot: false,
isEvent: false,
correlation: { project: 'support' },
receivedAt: otel.endTime,
})
// A parent from a tracing system that is not mcpd, for correlation only.
const run = mcpd.trace('handle_request', { externalParentSpanId: otel.spanId }) What you can rely on
-
Instrumentation never throws. A failed export, an unreachable daemon or a
payload that will not serialise is reported at most once per interval
through
onErrorand then let go. Observability must never take down the thing it observes. - Recording does not block. It appends to a queue and returns; the network happens on a background worker, so your agent's latency never depends on mcpd being fast, or up.
-
Memory is bounded. The queue holds
maxQueuesignals. When it is full the oldest is dropped and counted inmcpd.stats(), because an unbounded buffer turns a network outage into an out-of-memory kill. - The sampling decision is made once, at the root, and inherited. A half-recorded trace reads as a bug in your system.
- An open span is still sent. A span is queued when it starts and again when it ends, so a run that crashes mid-flight is visible. If the start record has not left yet, the end record replaces it, so a fast span costs one record and not two.
-
Your payload is never rewritten. Keys are converted between
camelCaseand the daemon'ssnake_case, and the conversion stops atinput,output,attributes,metadata,fieldsandlabels. Your prompt arrives exactly as you wrote it.
Reading it back
The same client reads. Every method mirrors one HTTP endpoint and returns the
generated types. Reads throw an McpdError when they fail: a
query you awaited and cannot answer is news, unlike a span that could not be
exported.
search and analyse
// The same client reads. This is the query that finds bad answers: every run
// that a scorer put below the bar.
const suspect = await mcpd.listTraces({
hasScore: 'answer_relevance',
scoreBelow: 0.5,
limit: 10,
})
console.log(`runs scoring under 0.5: ${suspect.traces.length}`)
const recent = await mcpd.listTraces({ limit: 5, sort: 'start_time', order: 'desc' })
for (const trace of recent.traces) {
const seconds = ((trace.durationNs ?? 0) / 1e9).toFixed(2)
console.log(
`${trace.traceId.slice(0, 8)} ${trace.name.padEnd(16)} ${trace.status.padEnd(6)} ` +
`${seconds}s ${trace.spanCount} spans $${(trace.cost?.totalCost ?? 0).toFixed(6)}`,
)
}
// One run, with every span in it, ordered so a waterfall renders directly.
const detail = await mcpd.getTrace(traceId, { include: ['scores', 'feedback'] })
for (const span of detail.spans) {
console.log(` ${span.kind.padEnd(18)} ${span.name}`)
} Finding a run covers the filters in full. The data model lists every field these types carry.