TypeScript
Um pacote sem dependências. Ele registra spans, logs, métricas, notas e feedback, e nunca lança uma exceção a partir de uma chamada de instrumentação.
Instalação
Node 20 ou posterior, Deno, Bun, navegadores e runtimes edge. ESM primeiro,
com um build CommonJS para require. As únicas partes
específicas do Node, async_hooks e zlib, são
carregadas em tempo de execução e ignoradas onde não existem.
npm install @mcpd/sdk Construir o cliente
Um cliente por processo. Toda opção também lê uma variável de ambiente, então
o mesmo código roda em desenvolvimento e em produção sem nenhuma edição. Sem
argumento nenhum, ele fala com um daemon em
http://localhost:4317.
conectar
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',
}) O código vence o ambiente, e o ambiente vence o padrão.
| Opção | Ambiente | Padrão |
|---|---|---|
endpoint | MCPD_ENDPOINT | http://localhost:4317 |
apiKey | MCPD_API_KEY | nenhum |
project | MCPD_PROJECT | default |
environment | MCPD_ENVIRONMENT | nenhum |
serviceName | MCPD_SERVICE_NAME | nenhum |
serviceVersion | MCPD_SERVICE_VERSION | nenhum |
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 | o transporte HTTP |
enabled: false não registra nada e não envia nada, e mesmo
assim toda chamada devolve um objeto válido e as leituras de lista devolvem
uma página vazia. Uma suíte de testes pode desligar o mcpd sem tocar em uma
linha de instrumentação.
capturePayloads: false mantém prompts e respostas dentro do
processo. O span continua sendo registrado com o seu tempo, o seu status, o
seu uso, o seu modelo, a sua correlação e os seus erros;
input e output são descartados antes de chegar à
fila, então nada sensível pode sair em um flush. Um span sobrescreve isso
para si mesmo com capturePayloads nas suas opções de início,
nos dois sentidos.
Registrar uma execução
withSpan é a forma com escopo. Ela abre o span, o mantém
ativo para tudo que o callback iniciar, e o encerra quando o callback
termina. Se o callback lançar, o span é marcado como error com
a mensagem lançada e o erro é relançado sem alteração, então o seu próprio
tratamento de erros não muda por estar sendo observado.
registrar uma execução
/**
* 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 }
} Contexto
No Node e no Bun o span ativo vive em um AsyncLocalStorage.
Tudo que começa dentro de um callback de withSpan aninha abaixo
dele sem receber nenhuma referência, e duas execuções intercaladas em um
mesmo event loop nunca enxergam os spans uma da outra.
aninhamento
// 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)
})
Navegadores e a maioria dos runtimes edge não têm
AsyncLocalStorage. Ali o SDK guarda um único valor salvo no
lugar. Isso aninha corretamente e sobrevive a um await dentro
de uma execução, mas não consegue distinguir duas execuções simultâneas, e o
resultado seria a chamada de ferramenta de uma execução aparecendo dentro da
árvore de outra. Quando você roda agentes ao mesmo tempo nesses runtimes,
passe o pai explicitamente.
pai explícito
// 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'
Entre serviços, o contexto viaja em um cabeçalho traceparent do
W3C. É só isso que faz um trace sobreviver a um salto de rede. Um cabeçalho
malformado é ignorado e um novo trace raiz é iniciado, porque um cabeçalho
que ninguém consegue ler não é motivo para falhar a requisição que o
carregou.
através de uma fronteira de serviço
// 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'),
}) Modelo, uso e erros
As contagens de tokens são o que o mcpd precifica na ingestão, então o custo
no trace é o custo que foi cobrado de você. Informe o que o provedor relatou, em vez de contar os tokens por conta
própria. Um erro sempre carrega
uma mensagem, e definir uma coloca o status do span em error no
mesmo movimento, então os dois nunca podem discordar.
o que um span carrega
generation.setModel('claude-sonnet-4', 'anthropic')
generation.setUsage({
inputTokens: 812,
outputTokens: 37,
inputDetails: { cacheRead: 640 },
})
// 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' }) Execuções duráveis
O mesmo cliente serve as execuções duráveis do daemon.
workflow() constrói a definição e guarda os corpos das etapas;
um Worker a registra, faz long polling por leases e serve cada
um, e mcpd.runs, mcpd.events e
mcpd.definitions espelham a superfície de execuções uma a uma.
Um corpo lança RetryableError para ser repetido dentro do
orçamento da etapa, e registra spans abaixo da sua etapa através do
StepContext. Um span registrado assim, e todo span aninhado
abaixo dele, soma o seu uso ao da etapa quando ela termina, então o daemon
precifica o que o trace mostra e o corpo nunca soma tokens na mão;
ctx.setUsage é uma sobrescrita explícita. Quatro hooks ficam ao
lado de onError: onLease,
onStepStart, onStepEnd e
onConflict, e worker.stats() conta tomadas,
conclusões, falhas por classe e conflitos por motivo.
um 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}' }) O que cada construção faz, o protocolo de lease e a API de execuções estão em Workflows, Workers e Execuções.
Notas e feedback
min e max são exigidos pelo tipo. Uma nota de 0,7
em uma escala não declarada não pode ser comparada com nada, e quando
alguém estiver lendo o gráfico já é tarde para perguntar o que ela queria
dizer. O feedback é o julgamento de uma pessoa e é mantido separado de uma
nota, porque os dois respondem perguntas diferentes e nunca devem entrar na
mesma média.
nota e feedback
// A machine evaluation of the run. `min` and `max` are required: 0.86 on an
// undeclared scale cannot be compared to anything.
mcpd.score({
traceId,
scorerId: 'answer_relevance',
value: 0.86,
min: 0,
max: 1,
reason: 'Answered the question and gave the delivery date.',
})
// A human evaluation. Kept separate from a score, and never averaged with
// one, because the two answer different questions.
mcpd.feedback({
traceId,
kind: 'thumbs',
value: 'up',
comment: 'Exactly what the customer asked for.',
authorKind: 'end_user',
})
// Anything you already count can travel with the trace.
mcpd.metric('support.tickets_handled', 1, 'counter', { labels: { queue: 'tier-1' } })
Os dois se ligam ao trace do span ativo quando você não nomeia nenhum, então
um controle de aprovar ou reprovar dentro do seu próprio produto pode chamar
feedback só com o id do trace que você guardou.
Os cinco sinais, um único conjunto de ids
Spans, logs, métricas, notas e feedback passam pela mesma fila e carregam os mesmos ids de trace e de span. É isso que permite a um pico em um gráfico abrir a execução que o causou.
-
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 e encerramento
Registrar é acrescentar à fila. Nada saiu do processo até o worker enviar, o
que ele faz a cada dois segundos ou quando um lote enche. Tanto
flush() quanto shutdown() podem ser aguardados e
são idempotentes, e shutdown() também para o worker.
Em uma função serverless isso não é opcional. O runtime congela o processo no instante em que você devolve uma resposta, e o worker em segundo plano nunca mais roda, então o último lote se perde. Aguarde um flush antes de a resposta sair.
um handler serverless
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)
}
No Node o cliente também faz flush no beforeExit, então um
script que esquece o shutdown() ainda recebe o seu último lote.
Esse hook não dispara em um process.exit() explícito nem em um
sinal fatal, então ele é uma rede de segurança e não um substituto.
Verifique o que você registrou
Um RecordingSink troca a rede por memória, então um teste lê de
volta os spans, logs, métricas, notas e feedback que o código sob teste
produziu, sem servidor e sem porta. Um ConsoleSink imprime uma
linha JSON por sinal no lugar disso, e qualquer objeto com um método
export(batch, ingestKey) é um sink. Um que lance é um lote
falho e um diagnóstico, nunca uma exceção no seu código.
mcpd.stats() responde queued,
dropped, capacity, exported,
failed e rejected.
um teste sem servidor
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') Registrar um span já concluído
mcpd.record(span) enfileira um span cujos ids, pai e tempos
você já tem em mãos: o que um adaptador para outro sistema de tracing, uma
importação em lote ou um replay já carrega. Ele é enfileirado como veio,
nunca reaninhado, nunca com os tempos refeitos nem reamostrado, com a
correlação que ficou vazia preenchida a partir do cliente. Um registro cujos
ids não tenham o formato do OpenTelemetry, cujo nome esteja vazio ou cujo
horário de início esteja faltando é descartado e contado em
stats().rejected. Em qualquer span,
externalParentSpanId carrega um pai de um sistema de tracing
que não é o mcpd, só para correlação.
um span de outro lugar
// 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 }) Com o que você pode contar
-
A instrumentação nunca lança. Uma exportação que falha, um daemon
inacessível ou um payload que não serializa são reportados no máximo uma
vez por intervalo através de
onErrore depois deixados de lado. A observabilidade nunca pode derrubar aquilo que ela observa. - Registrar não bloqueia. A chamada acrescenta à fila e retorna; a rede acontece em um worker em segundo plano, então a latência do seu agente nunca depende de o mcpd ser rápido, nem de estar no ar.
-
A memória é limitada. A fila guarda
maxQueuesinais. Quando ela enche, o mais antigo é descartado e contado emmcpd.stats(), porque um buffer sem limite transforma uma queda de rede em uma morte por falta de memória. - A decisão de amostragem é tomada uma vez, na raiz, e herdada. Um trace registrado pela metade se lê como um defeito no seu sistema.
- Um span aberto também é enviado. Um span é enfileirado quando começa e de novo quando termina, então uma execução que quebra no meio do caminho fica visível. Se o registro de início ainda não saiu, o registro de fim toma o lugar dele, então um span rápido custa um registro e não dois.
-
O seu payload nunca é reescrito. As chaves são convertidas entre
camelCasee osnake_casedo daemon, e a conversão para eminput,output,attributes,metadata,fieldselabels. O seu prompt chega exatamente como você escreveu.
Ler de volta
O mesmo cliente lê. Cada método espelha um endpoint HTTP e devolve os tipos
gerados. As leituras lançam um McpdError quando falham: uma
consulta que você aguardou e que não pode ser respondida é notícia, ao
contrário de um span que não conseguiu ser exportado.
buscar e analisar
// 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}`)
} Encontrar uma execução cobre os filtros por completo. O modelo de dados lista cada campo que esses tipos carregam.