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() from mcpd import Mcpd, SpanKind
from openai import OpenAI
# Reads MCPD_ENDPOINT and MCPD_API_KEY. With neither set it talks to
# http://localhost:4317 with no key.
mcpd = Mcpd(project="support", service_name="support-api")
client = OpenAI() Each option can also come from the environment, so the same code runs everywhere:
| Variable | Default | What it sets |
|---|---|---|
MCPD_ENDPOINT | http://localhost:4317 | The daemon the SDK sends to. |
MCPD_API_KEY | none | An ingest key, when the daemon runs with --ingest-key. |
MCPD_PROJECT | default | The project, when the code does not name one. |
MCPD_CAPTURE_PAYLOADS | true | Set 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
})
} def answer(question: str, user_id: str) -> str:
with mcpd.trace("answer_question", user_id=user_id) as run:
run.set_input({"question": question})
messages = [
{"role": "system", "content": "You answer questions about orders."},
{"role": "user", "content": question},
]
with mcpd.span("chat", kind=SpanKind.MODEL_GENERATION) as generation:
generation.set_input(messages)
generation.set_model_parameters(temperature=0.2, max_tokens=300)
response = client.chat.completions.create(
model="gpt-4.1-mini",
messages=messages,
temperature=0.2,
max_tokens=300,
)
# Model, provider, token counts, response id and finish reason.
generation.record_response(response)
text = response.choices[0].message.content
generation.set_output(text)
run.set_output({"answer": text})
return text What the span carries after the call:
| From the OpenAI response | Stored on the span as |
|---|---|
model | model, with provider set to openai |
usage.prompt_tokens | usage.input_tokens |
usage.prompt_tokens_details.cached_tokens | usage.input_details.cache_read, priced at the cached rate |
usage.completion_tokens | usage.output_tokens |
id, finish_reason | the 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' },
)
} def stream_answer(question: str) -> str:
with mcpd.span("chat", kind=SpanKind.MODEL_GENERATION) as generation:
messages = [{"role": "user", "content": question}]
generation.set_input(messages)
stream = client.chat.completions.create(
model="gpt-4.1-mini",
messages=messages,
stream=True,
# Without this, OpenAI sends no token counts on a stream.
stream_options={"include_usage": True},
)
text = ""
for chunk in generation.record_stream(stream):
if chunk.choices:
text += chunk.choices[0].delta.content or ""
return text
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() print(answer("Where is order 4417?", "customer-7"))
print(stream_answer("Where is order 4417?"))
# Sends what is still queued. A server that keeps running does not need it.
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}' $ 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 see | Why, 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. |
Related guides
- How to group LLM traces into conversations
- How to track LLM cost per user
- How to collect thumbs-up and thumbs-down feedback on LLM answers
mcpd is free and open source. Start free at mcpd.sh , or run the daemon yourself.