How to track LLM cost per user
mcpd prices every model call when it arrives. Put the end user's id on each run, and one API call gives you cost and tokens per user over any window.
Last updated
When you finish, you can see what each of your customers cost you in model calls this month, and open the runs behind any figure. You need runs in mcpd whose model calls carry token counts: the OpenAI guide and the framework guides set them up.
1. Put the user's id on every run
Set the user id on the root span of each run. With an SDK it is a
correlation field. With OpenTelemetry, mcpd reads user.id,
enduser.id or mcpd.user_id.
tag the run
await mcpd.withSpan('answer_question', async (run) => {
run.setCorrelation({ userId: customer.id })
// ... your model calls, recorded as model_generation spans
}) with mcpd.trace("answer_question", user_id=customer.id) as run:
... # your model calls, recorded as model_generation spans with OpenTelemetry
# On the root span of the run, with any OpenTelemetry SDK.
span.set_attribute("user.id", customer.id) Use an id that does not change, such as your customer id. Do not use an email address: run names and log messages are not redacted, and a stable id is all the report needs.
2. Set the key mcpd hashes user ids with
mcpd does not store the id you send. At ingest it replaces it with a keyed
digest, sha256: and 24 hex characters. The same id always gives
the same digest under one key, so grouping and filtering still work, and the
stored data does not say who the person is.
The built-in key is in the public source, so set your own in any real deployment:
start the daemon
# The key mcpd hashes user ids with. Keep it as secret as a password.
mcpd --redact-key "$MCPD_REDACT_KEY" 3. Get cost per user
GET /v1/api/analytics/cost with group_by=user_id
returns one row per user: the number of runs, the cost, and the input and
output tokens. Runs sent with no user id share the row
(none).
cost per user, last 30 days
curl -s --get http://localhost:4317/v1/api/analytics/cost \
--data-urlencode 'project=support' \
--data-urlencode 'from=-30d' \
--data-urlencode 'group_by=user_id' \
| jq -c '.groups[] | {user: .key, runs: .count, cost: .total_cost, input_tokens, output_tokens}' const res = await fetch(
'http://localhost:4317/v1/api/analytics/cost?' +
new URLSearchParams({ project: 'support', from: '-30d', group_by: 'user_id' }),
)
const { groups, currency } = await res.json()
for (const g of groups) console.log(g.key, g.count, g.total_cost, currency) import requests
res = requests.get(
"http://localhost:4317/v1/api/analytics/cost",
params={"project": "support", "from": "-30d", "group_by": "user_id"},
)
body = res.json()
for g in body["groups"]:
print(g["key"], g["count"], g["total_cost"], body["currency"]) $ curl -s --get http://localhost:4317/v1/api/analytics/cost --data-urlencode 'project=support' --data-urlencode 'from=-30d' --data-urlencode 'group_by=user_id' | jq -c '.groups[] | {user: .key, runs: .count, cost: .total_cost, input_tokens, output_tokens}'{"user":"sha256:9c23932e466b80d09d174f67","runs":5,"cost":0.00038399999999999996,"input_tokens":1624,"output_tokens":74}{"user":"(none)","runs":2,"cost":0.00038399999999999996,"input_tokens":1624,"output_tokens":74}{"user":"sha256:dcce23461766dc17e1d4c11d","runs":1,"cost":0.0,"input_tokens":0,"output_tokens":0} | Parameter | Values |
|---|---|
from, to | RFC 3339 or relative, such as -30d. The default window is the last seven days. |
group_by | user_id here. Also model, provider, session_id, environment and more. |
bucket | Add 1d for a daily series per user next to the totals. |
The same breakdown is in the interface: open Spend and group by user.
4. Turn digests back into your users
The report shows digests. Your application knows the ids and the key, so it can compute the same digest for each customer and look the rows up. The digest is the first 12 bytes of SHA-256 over the redact key followed by the user id:
the digest mcpd stores
import hashlib
def mcpd_digest(user_id: str, redact_key: str) -> str:
"""The value mcpd stores for user_id under that redact key."""
raw = hashlib.sha256(redact_key.encode() + user_id.encode()).hexdigest()
return "sha256:" + raw[:24]
# Map the report back to your own customers.
names = {mcpd_digest(c.id, REDACT_KEY): c.email for c in customers} import { createHash } from 'node:crypto'
/** The value mcpd stores for userId under that redact key. */
function mcpdDigest(userId: string, redactKey: string): string {
const raw = createHash('sha256').update(redactKey).update(userId).digest('hex')
return 'sha256:' + raw.slice(0, 24)
}
// Map the report back to your own customers.
const names = new Map(customers.map((c) => [mcpdDigest(c.id, REDACT_KEY), c.email])) printf '%s%s' "$MCPD_REDACT_KEY" "customer-7" | sha256sum | cut -c1-24 | sed 's/^/sha256:/'
For one customer you do not need the digest at all. The trace list hashes a
user_id filter the same way before it matches, so you pass the
plain id:
one customer's runs
# One customer's most expensive runs this month. Pass the plain id.
curl -s --get http://localhost:4317/v1/api/traces \
--data-urlencode 'project=support' \
--data-urlencode 'user_id=customer-7' \
--data-urlencode 'from=-30d' \
--data-urlencode 'sort=cost' \
| jq -r '.traces[] | [.trace_id, .name, .cost.total_cost] | @tsv' Troubleshooting
| What you see | Why, and what to do |
|---|---|
| Your digests do not match the report |
The daemon runs with a different --redact-key than the one you hash with, or the
key changed since those runs were stored. A daemon started with --no-redact stores
plain ids.
|
| A user's cost is lower than the provider's bill |
Some calls have no cost: their model is not in mcpd's price book. mcpd leaves an unknown model
unpriced rather than at zero. Check with mcpd prices show <model> and see
Cost accounting.
|
A large share of the cost is in the row (none) | Those runs were sent without a user id. Set it on the root span of every run. |
Related guides
- How to log OpenAI API calls with tokens and cost
- How to group LLM traces into conversations
- How to send LLM alerts to Slack
mcpd is free and open source. Start free at mcpd.sh , or run the daemon yourself.