Sovergate
← Back to Blog
Technical9 min read · 2 June 2026

How to Log LLM Calls for GDPR Compliance

Every LLM API call that processes EU user data is a GDPR compliance event. What to log, what never to log, how to scrub PII before logging, and how to build a compliant logging pipeline in Python and TypeScript.

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

1. You need a lawful basis

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.

2. You need a Data Processing Agreement with your LLM provider

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.

3. Your logs are also personal data

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:

JSON — compliant log entry
{
  "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"
}
Present
  • 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
Absent
  • 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.

Python — regex scrubber
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, detections

Approach 2: Named Entity Recognition (catches unstructured PII)

Catches names, locations, and organisations that regex cannot reliably detect.

Python — NER scrubber (spaCy, runs locally)
# 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, detections

Combined scrubbing pipeline

Python — full 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_detections

Run 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

Python — GDPR-compliant logger
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

Python — usage
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 content

TypeScript / Node.js implementation

TypeScript — GDPR-compliant logger
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.

Python — streaming
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.

Python — data residency
# 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 company

Log 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.

PurposeRetention basisRecommended period
EU AI Act Article 12 (general)Legal obligation6 months minimum
Financial services regulationLegal obligationPer sector rules (5–7 years)
Incident investigationLegitimate interestsUntil investigation closes
Service improvementLegitimate interests12 months maximum
DebuggingLegitimate interests30–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:

GDPR

Log only what is necessary, scrub PII, store in EU, delete when retention period expires.

Article 12

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.

Python — consent record
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

Mistake 1: Logging prompts verbatim

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.

Mistake 2: Logging to US-based services

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.

Mistake 3: No retention policy

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.

Mistake 4: Logging in the request path

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.

Mistake 5: No DPA with the LLM provider

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.

Mistake 6: Treating all prompts as non-personal

"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:

Legal basis identified and documented for LLM processing
DPA signed with LLM provider (OpenAI, Anthropic, Mistral, etc.)
PII scrubbing implemented — runs before any logging
Special category data handling defined
Logs stored with EU-incorporated provider on EU infrastructure
Retention periods defined per log category
Automatic deletion implemented at retention period end
Logs documented in Record of Processing Activities (ROPA)
DPIA conducted if processing is high-risk
Consent logging implemented if consent is the legal basis
Hash chain integrity implemented for Article 12 compliance
Breach detection and 72-hour notification process defined

Summary

Every LLM API call that processes EU user data is a GDPR compliance event. The compliant approach:

1.
Scrub PII locally before any loggingRegex patterns for structured data, NER for unstructured data
2.
Log asynchronouslyNever in the request path
3.
Log comprehensivelyEnough to satisfy Article 12 requirements for high-risk AI systems
4.
Store logs in the EUEU-incorporated provider, EU infrastructure, no CLOUD Act exposure
5.
Define and enforce retentionMinimum 6 months for Article 12, no longer than necessary for GDPR
6.
Implement tamper-evidenceHash chain on every entry
7.
Document everythingROPA, DPIA, DPA with LLM provider

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.