Ir para o conteúdo
mcpd
sem daemon
Código

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çãoAmbientePadrão
endpointMCPD_ENDPOINThttp://localhost:4317
apiKeyMCPD_API_KEYnenhum
projectMCPD_PROJECTdefault
environmentMCPD_ENVIRONMENTnenhum
serviceNameMCPD_SERVICE_NAMEnenhum
serviceVersionMCPD_SERVICE_VERSIONnenhum
sampleMCPD_SAMPLE1.0
enabledMCPD_ENABLEDtrue
capturePayloadsMCPD_CAPTURE_PAYLOADStrue
logLevelMCPD_LOG_LEVELdebug
batchSizeMCPD_BATCH_SIZE128
flushIntervalMsMCPD_FLUSH_INTERVAL_MS2000
maxQueueMCPD_MAX_QUEUE8192
timeoutMsMCPD_TIMEOUT_MS10000
sinko 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

trace_id Store Search Export

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 onError e 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 maxQueue sinais. Quando ela enche, o mais antigo é descartado e contado em mcpd.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 camelCase e o snake_case do daemon, e a conversão para em input, output, attributes, metadata, fields e labels. 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.