Every time your application routes a user's data through an LLM API — their email address, their support ticket, their medical question, their name — you are executing a data processing operation under GDPR. Most developers do not think of it that way. They think of it as an API call.
That distinction can cost companies up to €20 million or 4% of global annual turnover, whichever is higher.
This guide explains what GDPR requires when you log LLM calls, what you must never log in its raw form, how to scrub PII before it reaches your logs or your LLM provider, and how to build a compliant logging pipeline in Python and TypeScript. It also covers how Article 12 of the EU AI Act intersects with your GDPR logging obligations.
Why every LLM call is a GDPR compliance event
GDPR applies to the processing of personal data of EU residents. Personal data is any information that relates to an identified or identifiable natural person.
When a user types their name, their medical symptoms, their financial situation, their employment history, or their email address into your product and that text is sent to an LLM — you are processing personal data. The LLM provider is a data processor under Article 28 GDPR, and you are the data controller.
Three things this means practically
Under Article 6 GDPR, you must have a lawful basis for processing personal data through your LLM. The most common bases are: contract (Article 6(1)(b)) — processing is necessary to deliver the service the user contracted for; legitimate interests (Article 6(1)(f)); or consent (Article 6(1)(a)) — explicit, specific, freely given, and withdrawable.
Article 28 GDPR requires a written contract with every processor that handles personal data on your behalf. OpenAI, Anthropic, and Mistral all offer DPAs. Sign one. If you are using a provider that does not offer a DPA, you have a compliance problem before the first line of code.
If your LLM call logs contain any personal data — even in the prompt or response — those logs are themselves subject to GDPR. They need a legal basis, appropriate security, defined retention periods, and storage in a compliant jurisdiction.
What you must NEVER log in its raw form
The following types of data must be scrubbed before they are written to any log. Logging them raw — even briefly, even in a buffer, even in a “temporary” store — is a GDPR processing event that requires its own justification.
Direct identifiers
- —Full names
- —Email addresses
- —Phone numbers
- —Physical addresses
- —National identification numbers (passport, tax ID, SSN)
- —IP addresses (these are personal data under GDPR)
- —User IDs that map to real individuals without pseudonymisation
- —Account numbers, IBAN, credit card numbers
Special category data (Article 9 — highest protection)
- —Health information and medical history
- —Racial or ethnic origin
- —Political opinions
- —Religious or philosophical beliefs
- —Trade union membership
- —Genetic data
- —Biometric data
- —Sexual orientation or sex life data
Special category data requires explicit consent or another specific Article 9 legal basis for processing. If your users are asking your LLM about their health conditions, their political views, or their relationships, that data is special category and must be handled with extra care.
The scrub-before-log rule: Implement robust real-time redaction before any data is written to disk. Logging raw data and redacting later means the raw data existed on disk — that existence is a processing event.
What you should log
After scrubbing, a GDPR-compliant LLM call log entry should contain:
{
"log_id": "log_01HWXYZ...",
"timestamp": "2026-05-23T14:32:01.847Z",
"system_id": "credit-scoring-v2",
"model": "gpt-4o",
"model_version": "2025-01-01",
"prompt_scrubbed": "Assess the creditworthiness of the applicant.
Income category: [NUMBER_REDACTED].
Employment status: self-employed.
Existing debt category: [NUMBER_REDACTED].
Credit history: 7 years.",
"response_scrubbed": "Based on the provided financial profile, the
applicant presents a moderate credit risk. Key factors: stable
employment status, adequate credit history length. Primary concern:
[NUMBER_REDACTED] debt-to-income ratio.",
"prompt_tokens": 847,
"completion_tokens": 203,
"total_tokens": 1050,
"latency_ms": 1243,
"cost_usd": 0.00892,
"finish_reason": "stop",
"pii_detections": [
{ "category": "financial_figure", "count": 2,
"placeholder": "[NUMBER_REDACTED]" }
],
"human_review": {
"reviewed": true,
"reviewer_id": "analyst_7f3a",
"override": false,
"final_decision": "approved"
},
"chain_hash": "sha256:a3f9c2847e1d...",
"previous_hash": "sha256:b2e8d1936c0f...",
"data_residency": "Hetzner FSN1, Nuremberg, Germany",
"retention_until": "2026-11-23"
}- ✓Enough context to understand what the AI was doing
- ✓Enough structure to satisfy Article 12 requirements
- ✓PII replaced with category-appropriate placeholders
- ✓Human oversight event recorded
- ✓Tamper-evident hash chain
- ✗Name
- ✗Income figure
- ✗Email address
- ✗Physical address
- ✗Any direct identifier
How to implement PII scrubbing
There are two complementary approaches. Use both.
Approach 1: Regex pattern matching (fast, catches structured PII)
Fast to implement, low latency, catches well-structured personal data reliably.
import re
from dataclasses import dataclass
from typing import List, Tuple
@dataclass
class PIIDetection:
category: str
placeholder: str
start: int
end: int
class PIIScrubber:
PATTERNS = {
'email': (
r'[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+.[a-zA-Z]{2,}',
'[EMAIL_REDACTED]'
),
'phone': (
r'(+d{1,3}[s.-])?(?d{3})?[s.-]d{3}[s.-]d{4}',
'[PHONE_REDACTED]'
),
'iban': (
r'[A-Z]{2}d{2}[A-Z0-9]{4}d{7}([A-Z0-9]?){0,16}',
'[IBAN_REDACTED]'
),
'ip_address': (
r'(?:d{1,3}.){3}d{1,3}',
'[IP_REDACTED]'
),
'national_id': (
r'd{3}[-s]?d{2}[-s]?d{4}',
'[ID_REDACTED]'
),
'credit_card': (
r'(?:d{4}[s-]?){3}d{4}',
'[CARD_REDACTED]'
),
}
def scrub(self, text: str) -> Tuple[str, List[PIIDetection]]:
detections = []
scrubbed = text
for category, (pattern, placeholder) in self.PATTERNS.items():
matches = list(re.finditer(pattern, scrubbed))
# Process in reverse to preserve position indices
for match in reversed(matches):
detections.append(PIIDetection(
category=category,
placeholder=placeholder,
start=match.start(),
end=match.end()
))
scrubbed = (
scrubbed[:match.start()] +
placeholder +
scrubbed[match.end():]
)
return scrubbed, detectionsApproach 2: Named Entity Recognition (catches unstructured PII)
Catches names, locations, and organisations that regex cannot reliably detect.
# Using spaCy — runs locally, no data leaves your infra
import spacy
nlp = spacy.load("en_core_web_lg") # or multilingual: xx_ent_wiki_sm
def scrub_named_entities(text: str) -> Tuple[str, List[dict]]:
doc = nlp(text)
detections = []
scrubbed = text
# Process entities in reverse order to preserve positions
for ent in reversed(doc.ents):
if ent.label_ in ['PERSON', 'GPE', 'LOC', 'ORG']:
placeholder = f'[{ent.label_}_REDACTED]'
detections.append({
'category': ent.label_.lower(),
'placeholder': placeholder,
})
scrubbed = (
scrubbed[:ent.start_char] +
placeholder +
scrubbed[ent.end_char:]
)
return scrubbed, detectionsCombined scrubbing pipeline
def scrub_for_logging(text: str) -> Tuple[str, List[dict]]:
"""
Run on both prompt and response BEFORE logging.
Never log the original text.
"""
# Step 1: Regex patterns (fast, structured PII)
regex_scrubber = PIIScrubber()
scrubbed, regex_detections = regex_scrubber.scrub(text)
# Step 2: Named entity recognition (unstructured PII)
scrubbed, ner_detections = scrub_named_entities(scrubbed)
all_detections = [
{'category': d.category, 'placeholder': d.placeholder}
for d in regex_detections
] + ner_detections
return scrubbed, all_detectionsRun scrubbing locally, inside your infrastructure, before any data is transmitted. The scrubbed version is what you log and what you send to external logging services. The original never leaves your application server.
The non-blocking logging pattern
Your LLM logging must never add latency to user-facing requests. Implement all logging as fire-and-forget in a background thread or event loop task.
Python implementation
import threading
import time
import hashlib
import json
import requests
from dataclasses import dataclass, asdict
from datetime import datetime, timezone
from typing import Optional
import uuid
@dataclass
class LLMLogEntry:
log_id: str
timestamp: str
system_id: str
model: str
prompt_scrubbed: str
response_scrubbed: str
prompt_tokens: int
completion_tokens: int
total_tokens: int
latency_ms: float
cost_usd: float
finish_reason: str
pii_detections: list
previous_hash: str
chain_hash: str
class GDPRCompliantLogger:
def __init__(self, api_key: str, ingest_url: str, system_id: str):
self.api_key = api_key
self.ingest_url = ingest_url
self.system_id = system_id
self._last_hash = "genesis"
self._queue = []
self._lock = threading.Lock()
def log(self, prompt: str, response: str, model: str,
usage: dict, latency_ms: float, finish_reason: str):
"""
Call this after receiving the LLM response.
Returns immediately — logging is non-blocking.
"""
# Step 1: Scrub PII — before any other processing
prompt_scrubbed, prompt_detections = scrub_for_logging(prompt)
response_scrubbed, response_detections = scrub_for_logging(response)
# Step 2: Build log entry
entry = self._build_entry(
prompt_scrubbed=prompt_scrubbed,
response_scrubbed=response_scrubbed,
model=model,
usage=usage,
latency_ms=latency_ms,
finish_reason=finish_reason,
pii_detections=prompt_detections + response_detections
)
# Step 3: Fire and forget — never block the main thread
thread = threading.Thread(
target=self._send,
args=(entry,),
daemon=True # dies with app, never blocks shutdown
)
thread.start()
def _build_entry(self, **kwargs) -> LLMLogEntry:
content = json.dumps(kwargs, sort_keys=True)
chain_hash = hashlib.sha256(
f"{self._last_hash}{content}".encode()
).hexdigest()
entry = LLMLogEntry(
log_id=str(uuid.uuid4()),
timestamp=datetime.now(timezone.utc).isoformat(),
system_id=self.system_id,
chain_hash=f"sha256:{chain_hash}",
previous_hash=self._last_hash,
**kwargs
)
with self._lock:
self._last_hash = chain_hash
return entry
def _send(self, entry: LLMLogEntry):
"""Silently fails — NEVER raises, NEVER affects the main app."""
try:
requests.post(
self.ingest_url,
json=asdict(entry),
headers={"Authorization": f"Bearer {self.api_key}"},
timeout=2 # give up after 2 seconds
)
except Exception:
with self._lock:
self._queue.append(entry)Usage in your application
import openai
import time
# Initialise once at app startup
logger = GDPRCompliantLogger(
api_key="svg_prod_xxxx",
ingest_url="https://ingest.sovergate.com/v1/log",
system_id="credit-scoring-v2"
)
def call_llm(user_prompt: str) -> str:
start = time.time()
# Call OpenAI directly — logger does not intercept
response = openai.chat.completions.create(
model="gpt-4o",
messages=[{"role": "user", "content": user_prompt}]
)
latency_ms = (time.time() - start) * 1000
content = response.choices[0].message.content
# Log asynchronously — returns immediately
logger.log(
prompt=user_prompt,
response=content,
model=response.model,
usage={
"prompt_tokens": response.usage.prompt_tokens,
"completion_tokens": response.usage.completion_tokens,
"total_tokens": response.usage.total_tokens,
},
latency_ms=latency_ms,
finish_reason=response.choices[0].finish_reason
)
# Return to user immediately — no waiting for logging
return contentTypeScript / Node.js implementation
import OpenAI from 'openai'
import { createHash } from 'crypto'
import { randomUUID } from 'crypto'
interface LogEntry {
logId: string
timestamp: string
systemId: string
model: string
promptScrubbed: string
responseScrubbed: string
promptTokens: number
completionTokens: number
totalTokens: number
latencyMs: number
finishReason: string
piiDetections: unknown[]
previousHash: string
chainHash: string
}
class GDPRCompliantLogger {
private lastHash = 'genesis'
private readonly apiKey: string
private readonly ingestUrl: string
private readonly systemId: string
constructor(config: { apiKey: string; ingestUrl: string; systemId: string }) {
this.apiKey = config.apiKey
this.ingestUrl = config.ingestUrl
this.systemId = config.systemId
}
log(prompt: string, response: string, model: string,
usage: { promptTokens: number; completionTokens: number; totalTokens: number },
latencyMs: number, finishReason: string): void {
const [promptScrubbed, promptDetections] = scrubForLogging(prompt)
const [responseScrubbed, responseDetections] = scrubForLogging(response)
const entry = this.buildEntry({
promptScrubbed,
responseScrubbed,
model,
...usage,
latencyMs,
finishReason,
piiDetections: [...promptDetections, ...responseDetections]
})
// Non-blocking — defers until after current event loop tick
setImmediate(() => this.send(entry))
}
private buildEntry(fields: Omit<LogEntry, 'logId'|'timestamp'|'systemId'|'previousHash'|'chainHash'>): LogEntry {
const content = JSON.stringify(fields)
const chainHash = createHash('sha256')
.update(this.lastHash + content)
.digest('hex')
const entry: LogEntry = {
logId: randomUUID(),
timestamp: new Date().toISOString(),
systemId: this.systemId,
previousHash: this.lastHash,
chainHash: `sha256:${chainHash}`,
...fields
}
this.lastHash = chainHash
return entry
}
private async send(entry: LogEntry): Promise<void> {
try {
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), 2000)
await fetch(this.ingestUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${this.apiKey}`
},
body: JSON.stringify(entry),
signal: controller.signal
})
clearTimeout(timeout)
} catch {
// Silent failure — never affects the main application
}
}
}
// Usage
const logger = new GDPRCompliantLogger({
apiKey: 'svg_prod_xxxx',
ingestUrl: 'https://ingest.sovergate.com/v1/log',
systemId: 'credit-scoring-v2'
})
const openai = new OpenAI()
async function callLLM(userPrompt: string): Promise<string> {
const start = Date.now()
const response = await openai.chat.completions.create({
model: 'gpt-4o',
messages: [{ role: 'user', content: userPrompt }]
})
const latencyMs = Date.now() - start
const content = response.choices[0].message.content ?? ''
logger.log(userPrompt, content, response.model, {
promptTokens: response.usage?.prompt_tokens ?? 0,
completionTokens: response.usage?.completion_tokens ?? 0,
totalTokens: response.usage?.total_tokens ?? 0
}, latencyMs, response.choices[0].finish_reason)
return content
}Logging streaming responses
Many LLM applications use streaming — the response is delivered token by token rather than all at once. Your logging must handle this without blocking the stream.
def call_llm_streaming(user_prompt: str):
start = time.time()
collected_chunks = []
# Stream tokens to the user as they arrive
with openai.chat.completions.create(
model="gpt-4o",
messages=[{"role": "user", "content": user_prompt}],
stream=True
) as stream:
for chunk in stream:
delta = chunk.choices[0].delta.content or ""
collected_chunks.append(delta)
yield delta # Return to user immediately
# After stream completes, log the full response
full_response = "".join(collected_chunks)
latency_ms = (time.time() - start) * 1000
# Fire and forget — stream is already complete
logger.log(
prompt=user_prompt,
response=full_response,
model="gpt-4o",
usage={},
latency_ms=latency_ms,
finish_reason="stop"
)Data residency — where your logs must live
Under GDPR, logs containing personal data (even pseudonymised) about EU residents must be stored in a way that complies with GDPR transfer restrictions.
The problem with US-based logging services
Sending logs to Datadog, Splunk, New Relic, or any US-based service means EU personal data is transferred to the US. Even if you select an EU data centre region, the US CLOUD Act allows US authorities to compel US-incorporated companies to produce that data regardless of physical location.
Standard Contractual Clauses reduce the legal risk of cross-border transfers but do not eliminate CLOUD Act exposure. EU data protection authorities — particularly the German and Austrian DPAs — have scrutinised SCC-based transfers to US providers and found them inadequate in specific enforcement actions.
Store LLM compliance logs with an EU-incorporated provider operating exclusively EU-based infrastructure. This eliminates CLOUD Act exposure entirely.
# Compliant — EU incorporated, EU infrastructure
INGEST_URL = "https://ingest.sovergate.com/v1/log" # Hetzner, Germany
# Non-compliant for sensitive EU data — US incorporated
# INGEST_URL = "https://api.datadoghq.com/..." # US company
# INGEST_URL = "https://logs.eu-west.datadoghq.com/..." # still US companyLog retention — how long to keep LLM logs
GDPR's data minimisation and storage limitation principles (Article 5(1)(e)) require that personal data is not retained longer than necessary for the purpose for which it was collected.
| Purpose | Retention basis | Recommended period |
|---|---|---|
| EU AI Act Article 12 (general) | Legal obligation | 6 months minimum |
| Financial services regulation | Legal obligation | Per sector rules (5–7 years) |
| Incident investigation | Legitimate interests | Until investigation closes |
| Service improvement | Legitimate interests | 12 months maximum |
| Debugging | Legitimate interests | 30–90 days |
Define retention explicitly. Do not let logs accumulate indefinitely. Set a retention period, implement automatic deletion at the end of that period, and document the period in your Record of Processing Activities.
The intersection with EU AI Act Article 12
If your LLM is used in a high-risk AI system under Annex III of the EU AI Act, you have a dual obligation:
Log only what is necessary, scrub PII, store in EU, delete when retention period expires.
Log automatically, log everything, retain for at least six months, maintain tamper-evident records.
These are not in conflict. They are satisfied simultaneously by the same approach: pseudonymise before logging, log comprehensively, store in EU, implement hash chain integrity, retain for six months minimum.
Consent logging
If your legal basis for LLM processing is consent (Article 6(1)(a)), you must keep records of that consent — who consented, when they consented, and what they consented to.
from dataclasses import dataclass
from typing import Optional
@dataclass
class ConsentRecord:
user_id: str # pseudonymised identifier
timestamp: str # ISO 8601
purpose: str # specific purpose consented to
policy_version: str # privacy policy version in effect
channel: str # how consent was obtained
withdrawn_at: Optional[str] = None
def log_consent(user_id: str, purpose: str, policy_version: str) -> str:
"""
Log consent at the point it is given.
Returns a consent receipt ID.
"""
record = ConsentRecord(
user_id=pseudonymise(user_id),
timestamp=datetime.now(timezone.utc).isoformat(),
purpose=purpose,
policy_version=policy_version,
channel="web_ui"
)
# Store to consent log (separate from LLM call log)
return store_consent_record(record)The DPIA requirement
A Data Protection Impact Assessment (DPIA) is required under Article 35 GDPR where processing is likely to result in high risk to the rights and freedoms of individuals.
LLM systems used in high-risk AI contexts — credit scoring, hiring, healthcare — almost certainly trigger the DPIA requirement. The processing involves systematic evaluation of individuals using automated means, and it is large-scale.
Conduct a DPIA before deploying your LLM application if it:
- —Makes or influences significant decisions about individuals
- —Processes special category data
- —Involves systematic monitoring of individuals
- —Processes data of vulnerable individuals (children, patients)
The DPIA documents the risks and the measures taken to mitigate them. Your logging implementation is one of those measures. Document it.
Common GDPR logging mistakes
The most common mistake. A prompt containing a user's name, email, and medical question is logged raw to a database or log aggregation service. Every character of that log entry is personal data — potentially special category personal data. Fix: scrub before logging. Never write raw prompts to disk.
Developer installs LangSmith, Helicone, or Datadog. Routes all LLM call data through it. The LLM call data contains EU user information. It is now on US infrastructure. Fix: use EU-incorporated logging services or self-hosted logging on EU infrastructure.
Logs accumulate indefinitely. Two years later, you have 24 months of LLM call logs containing pseudonymised data about millions of users with no legal basis for keeping them beyond 6 months. Fix: define retention periods, implement automatic deletion, document in your ROPA.
Logging adds 200ms to every LLM call because the log is written synchronously before the response is returned. Developer removes logging to fix performance. Now there are no logs at all. Fix: always log asynchronously. Logging must never be in the critical path.
You are sending personal data to OpenAI, Anthropic, or Mistral on behalf of your users. Under Article 28 GDPR, you need a written DPA. Most developers skip this. Fix: sign the DPA your LLM provider offers.
"Our prompts do not contain personal data because we tell users not to include it." Users include personal data anyway. Your system prompt may reference individual user attributes from your database. Always scrub — do not assume prompts are clean.
A GDPR-compliant LLM logging checklist
Use this before deploying any LLM application that processes EU user data:
Summary
Every LLM API call that processes EU user data is a GDPR compliance event. The compliant approach:
This is not a significant engineering investment. The scrubbing pipeline is two functions. The non-blocking logger is one class. The hash chain is two lines of cryptography code. The compliance benefit is substantial.
This guide is maintained by Sovergate. We build EU AI Act Article 12 logging infrastructure for companies using LLMs in high-risk contexts. This guide is for informational purposes only and does not constitute legal advice.
Last updated June 2026.
Want a managed implementation of all of this?
Two lines of code. PII scrubbed locally inside your infrastructure. Data stored in Germany. Monthly Article 12 compliance reports ready for your legal team.