Skip to content
mcpd
no daemon

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 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}'
cost per user
$ 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}
ParameterValues
from, toRFC 3339 or relative, such as -30d. The default window is the last seven days.
group_byuser_id here. Also model, provider, session_id, environment and more.
bucketAdd 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}

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 seeWhy, 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.

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