At 2 a.m. a user reports a wrong answer and you need the prompt that produced it. So you add logger.info(request) and move on. Three weeks later your log store holds email addresses, a pasted Aadhaar number, an Authorization header, and every customer's complaint in plain text, retained forever and readable by anyone with log access.
LLM logging is a trade-off between being able to debug and holding data you shouldn't have. The way out is to decide per field: keep, hash, redact, truncate or drop. Below is a table for that decision, then a Python script I ran that applies it, with a tiered retention purge.
What should you log for an LLM call?
Start from the questions you'll need to answer later: which model and prompt version produced this, what did it cost, how slow was it, did it fail. None of those need the user's words.
| Field | Decision | Why |
|---|---|---|
| request ID, timestamp | Keep | Needed to find the call |
| model ID, prompt version | Keep | Lets you tie a regression to a change |
| input / output token counts | Keep | Cost tracking, see cost per feature |
| latency, status, error type | Keep | Operations |
| user ID | Pseudonymise (keyed hash) | You can group by user without storing the raw ID |
| prompt text, reply text | Redact, truncate, short retention | Where personal data lives; needed only for debugging |
| tool call arguments and results | Redact, short retention | Often contain record IDs, emails, amounts |
| retrieved document chunks | Log chunk IDs, not text | You can re-fetch the chunk; don't duplicate the document |
| API keys, auth headers, cookies | Never | Logs get shared; secrets must not |
| uploaded files, images | Never (log a hash and size) | Large and sensitive |
| full SDK request/response objects | Never | They carry headers and everything else |
The most useful rule in the table is the allowlist. Don't write a logger that dumps an object and then tries to scrub it. Write one that names the fields it will store, and drop everything else by default. New fields then have to be added on purpose.
For tracing-specific guidance, see the privacy section of the OpenTelemetry tracing post. For what your LLM vendor keeps on its side, which is a separate question from your own logs, see the data retention by vendor table.
How do you redact before writing the log?
Redact at the point where the record is built, before it touches disk or a log shipper. After that, copies exist in buffers, backups and dashboards that you can't reach.
Regex catches identifiers with a fixed shape. It does not catch names, addresses or free-text descriptions of a person. The longer treatment, with a comparison against Presidio and the misses counted, is in PII redaction before LLM calls. The version here is deliberately small.
PATTERNS = [
("API_KEY", re.compile(r"\b(?:sk-[A-Za-z0-9_\-]{16,}|AKIA[0-9A-Z]{16})\b")),
("EMAIL", re.compile(r"\b[\w.+-]+@[\w-]+(?:\.[\w-]+)+\b")),
("PAN", re.compile(r"\b[A-Z]{5}[0-9]{4}[A-Z]\b")),
("PHONE", re.compile(r"(?<!\d)(?:\+91[\s-]?)?[6-9]\d{4}[\s-]?\d{5}(?!\d)")),
("LONGNUM", re.compile(r"(?<!\d)\d{12,19}(?!\d)")),
]
def redact(text):
counts = {}
for label, rx in PATTERNS:
text, n = rx.subn(f"[{label}]", text)
if n:
counts[label] = n
return text, counts
LONGNUM is blunt on purpose. It swallows any 12 to 19 digit run, which covers card and Aadhaar-length numbers but will also hit long order IDs. For logs, over-redacting is the cheaper mistake.
How do you build a log record with an allowlist?
Here is the record builder. It names every stored field, pseudonymises the user with an HMAC, redacts and truncates the text, and records how many redactions fired so you can spot a prompt that leaks a lot.
def pseudonym(user_id):
return hmac.new(SALT, user_id.encode(), hashlib.sha256).hexdigest()[:16]
def make_record(raw, now):
prompt, pc = redact(raw["prompt"])
reply, rc = redact(raw["reply"])
return {
"ts": now.isoformat(),
"request_id": raw["request_id"],
"user": pseudonym(raw["user_id"]),
"model": raw["model"],
"prompt_version": raw["prompt_version"],
"input_tokens": raw["input_tokens"],
"output_tokens": raw["output_tokens"],
"latency_ms": raw["latency_ms"],
"status": raw["status"],
"redactions": {**{f"p_{k}": v for k, v in pc.items()},
**{f"r_{k}": v for k, v in rc.items()}},
"prompt_text": prompt[:500],
"reply_text": reply[:500],
}
I ran it on 2026-10-31 against one invented request: a fake refund message with an email, phone number, PAN-format string, a card-length number and a fake key, plus an Authorization header in the raw object. In the real script SALT is a demo constant. In production it belongs in a secret manager.
--- stored record ---
{
"ts": "2026-07-01T00:00:00+00:00",
"request_id": "r1",
"user": "6f415eb0a3cf6620",
"model": "stub-model",
"prompt_version": "v7",
"input_tokens": 61,
"output_tokens": 40,
"latency_ms": 820,
"status": "ok",
"redactions": {
"p_API_KEY": 1,
"p_EMAIL": 1,
"p_PAN": 1,
"p_PHONE": 1,
"p_LONGNUM": 1,
"r_EMAIL": 1
},
"prompt_text": "Refund please. Mail [EMAIL], phone [PHONE], PAN [PAN], card [LONGNUM], key [API_KEY].",
"reply_text": "Done. I will write to [EMAIL]."
}
has headers field: False | has raw user id: False
The header never reaches the record because the builder doesn't mention it. Every identifier in the test was caught. That's a small test, written by me, with values in standard formats. A name or street address in the same sentence would have passed straight through, since no pattern targets them. I did not include one in the test, so treat the "caught everything" result as limited to the five shapes above.
How do you set retention so old text disappears?
Use two tiers. Text is risky and rarely useful after a week, so clear it early. Metadata is low-risk and useful for trends, so keep it longer. The values below, 7 and 90 days, are examples to show the mechanism. The right numbers depend on your debugging needs, your contracts and your legal advice.
def purge(db, now, content_days=7, meta_days=90):
c1 = (now - timedelta(days=content_days)).isoformat()
c2 = (now - timedelta(days=meta_days)).isoformat()
a = db.execute("UPDATE llm_log SET prompt_text=NULL, reply_text=NULL "
"WHERE ts < ? AND prompt_text IS NOT NULL", (c1,)).rowcount
b = db.execute("DELETE FROM llm_log WHERE ts < ?", (c2,)).rowcount
return a, b
I inserted seven copies of the record into an in-memory SQLite table, aged 1, 6, 8, 30, 89, 91 and 200 days, and ran the purge:
before purge: rows=7, rows_with_text=7
purge: text cleared on 5 rows, 2 rows deleted
after purge : rows=5, rows_with_text=2
ages kept (days): [1, 6, 8, 30, 89]
Read it closely. Text was cleared on five rows (ages 8, 30, 89, 91, 200). Two rows (91, 200) were then deleted entirely. Five rows survive as metadata, and only the 1 and 6 day rows still carry text. The purge only works if something runs it: put it in a daily cron job or scheduler, and alert if it hasn't run.
Where else do copies of your logs hide?
The table in your database isn't the only place the data lands. Check each of these:
- Stdout and container logs. A debug print of the prompt bypasses your whole pipeline.
- Error trackers. Exceptions often include local variables or request bodies. Check what your tracker captures.
- Observability vendors. Tracing tools may store prompts by default. Check the setting and the vendor's retention.
- Backups. A 7-day purge is meaningless if nightly snapshots live for a year.
- Analytics and warehouse exports. Copies made "for analysis" are copies.
- Eval sets. Turning production logs into a test set is useful, and it also makes your retention permanent. Redact first, as in the golden dataset post.
What do the privacy rules mean for this?
I'm not a lawyer, and I haven't turned this into a compliance checklist. For India, the practical mapping of duties to engineering tasks (consent, retention, erasure, security safeguards, who counts as a Data Fiduciary) is in the DPDP Act checklist for AI app developers, which itself says what it could and couldn't read. Logs are one of the places personal data hides, so they belong in that exercise. Ask counsel what retention periods and notices your product needs.
Checklist
- Each stored field is named in an allowlist; everything else is dropped
- Secrets, auth headers and uploaded files are never logged
- User IDs are pseudonymised with a keyed hash, and the key is in a secret manager
- Text is redacted and truncated before it is written, not after
- Metadata and text have separate, written retention periods
- The purge job is scheduled and monitored
- Backups, error trackers and vendors are on the same retention clock
- You can find and delete one user's rows
What I did not verify
- The script ran only on invented data and a stub request. No real LLM or SDK was called.
- Redaction covers five identifier shapes. I did not test names, addresses, non-English text or obfuscated values here.
- The 7 and 90 day periods are examples, not recommendations from any regulator.
- I made no claims about what any specific vendor logs or retains; see the linked vendor table for that.



