Skip to content
mcpd
no daemon

How to log OpenAI API calls with tokens and cost

Wrap each chat completion in a span and hand the response to the mcpd SDK. mcpd stores the prompt, the answer, the token counts and the cost of every call.

Last updated

When you finish, every OpenAI call your code makes is stored inside the run that made it, with its input messages, its answer, the tokens it used and what it cost. You need a running mcpd daemon (the Quickstart starts one on port 4317), the official openai package, and the mcpd SDK for TypeScript or Python.

1. Create the clients

Create one mcpd client per process, next to your OpenAI client. The project groups the runs in mcpd, and the service name tells you which program sent them.

clients

import { Mcpd } from '@mcpd/sdk'
import OpenAI from 'openai'

// Reads MCPD_ENDPOINT and MCPD_API_KEY. With neither set it talks to
// http://localhost:4317 with no key.
const mcpd = new Mcpd({ project: 'support', serviceName: 'support-api' })
const openai = new OpenAI()

Each option can also come from the environment, so the same code runs everywhere:

VariableDefaultWhat it sets
MCPD_ENDPOINThttp://localhost:4317The daemon the SDK sends to.
MCPD_API_KEYnoneAn ingest key, when the daemon runs with --ingest-key.
MCPD_PROJECTdefaultThe project, when the code does not name one.
MCPD_CAPTURE_PAYLOADStrueSet false to keep prompts and answers in the process and send only timings, tokens and cost.

2. Wrap each call in a model span

Open a root span for the request, and a model_generation span around the OpenAI call. Pass the response to recordResponse (record_response in Python). It reads the model, the provider, the token counts with their cached and reasoning parts, the response id and the finish reason. It never records the answer text, so set that with setOutput.

one chat completion, recorded

async function answer(question: string, userId: string): Promise<string> {
  return await mcpd.withSpan('answer_question', async (run) => {
    run.setCorrelation({ userId })
    run.setInput({ question })
    const messages = [
      { role: 'system' as const, content: 'You answer questions about orders.' },
      { role: 'user' as const, content: question },
    ]
    const text = await mcpd.withSpan(
      'chat',
      async (generation) => {
        generation.setInput(messages)
        generation.setModelParameters({ temperature: 0.2, maxTokens: 300 })
        const response = await openai.chat.completions.create({
          model: 'gpt-4.1-mini',
          messages,
          temperature: 0.2,
          max_tokens: 300,
        })
        // Model, provider, token counts, response id and finish reason.
        generation.recordResponse(response)
        const text = response.choices[0]?.message.content ?? ''
        generation.setOutput(text)
        return text
      },
      { kind: 'model_generation' },
    )
    run.setOutput({ answer: text })
    return text
  })
}

What the span carries after the call:

From the OpenAI responseStored on the span as
modelmodel, with provider set to openai
usage.prompt_tokensusage.input_tokens
usage.prompt_tokens_details.cached_tokensusage.input_details.cache_read, priced at the cached rate
usage.completion_tokensusage.output_tokens
id, finish_reasonthe attributes gen_ai.response.id and gen_ai.response.finish_reasons

mcpd prices the call when it arrives, from the token counts and its own price book, and stores the figure on the span. The run's total is the sum of its spans. Cost accounting explains the rates and how to add your own.

3. Record streamed calls

For a streamed completion, read the stream through recordStream (record_stream). Every chunk comes through unchanged. When the stream ends, the span gets the full text as its output, the usage from the last chunk, and the time to the first token as the attribute mcpd.time_to_first_token_ms.

a streamed completion

async function streamAnswer(question: string): Promise<string> {
  return await mcpd.withSpan(
    'chat',
    async (generation) => {
      const messages = [{ role: 'user' as const, content: question }]
      generation.setInput(messages)
      const stream = await openai.chat.completions.create({
        model: 'gpt-4.1-mini',
        messages,
        stream: true,
        // Without this, OpenAI sends no token counts on a stream.
        stream_options: { include_usage: true },
      })
      let text = ''
      for await (const chunk of generation.recordStream(stream)) {
        text += chunk.choices[0]?.delta.content ?? ''
      }
      return text
    },
    { kind: 'model_generation' },
  )
}

Keep stream_options: { include_usage: true }. OpenAI sends the token counts of a stream only when you ask for them, and a span with no token counts has no cost.

4. Send it and read it back

The SDK queues spans and sends them in batches every two seconds. A script that exits straight after its work has to flush first; a long-running server does not.

run it

console.log(await answer('Where is order 4417?', 'customer-7'))
console.log(await streamAnswer('Where is order 4417?'))
// Sends what is still queued. A server that keeps running does not need it.
await mcpd.shutdown()

Open localhost:4317 and select the run in Runs, or read it from the HTTP API:

read the run

# The newest run named answer_question.
TRACE=$(curl -s "http://localhost:4317/v1/api/traces?project=support&name=answer_question&limit=1" \
  | jq -r '.traces[0].trace_id')

# Every step of it, with the model, the tokens and the cost.
curl -s "http://localhost:4317/v1/api/traces/$TRACE?project=support" \
  | jq -c '.spans[] | {name, kind, model, usage, cost: .cost.total_cost}'
the run, step by step
$ curl -s "http://localhost:4317/v1/api/traces/$TRACE?project=support" | jq -c '.spans[] | {name, kind, model, usage, cost: .cost.total_cost}'{"name":"answer_question","kind":"agent_run","model":null,"usage":null,"cost":null}{"name":"chat","kind":"model_generation","model":"gpt-4.1-mini","usage":{"input_tokens":812,"output_tokens":37,"total_tokens":849,"input_details":{"text":172,"cache_read":640}},"cost":0.00019199999999999998}

In that run, 640 of the 812 prompt tokens came from OpenAI's prompt cache, so mcpd charged them at the cached rate.

Troubleshooting

What you seeWhy, and what to do
No runs arrive from a script The process exited before the batch was sent. Call shutdown() (or flush()) before it exits.
No runs arrive from a server The SDK never throws from instrumentation, so a refused batch does not stop your code. If the daemon runs with --ingest-key, set MCPD_API_KEY to one of those keys. mcpd.stats().failed counts the batches the daemon refused.
A streamed call has no tokens and no cost The request did not set stream_options.include_usage.
Tokens are stored, but the cost is empty The model id is not in mcpd's price book. mcpd leaves an unknown model unpriced rather than at zero. Check a model with mcpd prices show gpt-4.1-mini, and add rates as Cost accounting shows.
Prompts and answers are missing MCPD_CAPTURE_PAYLOADS is false in that process.

mcpd is free and open source. Start free at mcpd.sh , or run the daemon yourself.