You can’t improve what you can’t measure, so the day we wanted to know whether the chatbot on this site was actually being used — by how many people, from where, asking what — we hit the oldest bad habit in web analytics. Log everything, including the visitor’s IP address, and sort it out later. It’s the default, it looks responsible, and it quietly turns your analytics into a surveillance system you now have to defend.
We didn’t want that, and we didn’t need it. You can measure a chatbot — count conversations, recognize a returning visitor, see which country traffic comes from, and catch abuse — without ever storing a single IP address. The move isn’t discipline about deleting logs later. It’s designing so the identifying data never lands in the first place.

What we actually wanted to know
Write down the real questions and the design falls out of them:
- Is anyone using it — how many conversations, how many messages?
- Is a run of messages one person talking, so we can read a conversation instead of scattered turns?
- Are people coming back?
- Roughly where is the traffic from — which country?
- Is it being probed, scraped, or cost-attacked?
Notice what none of those need: they don’t need to know who. They need to distinguish and count, not identify. A returning visitor is “the same one as before,” not a name. A country is reach, not an address. That gap — between telling two visitors apart and knowing who either of them is — is the entire design.
The IP address is the liability, not the signal
The raw IP is the one field that turns measurement into surveillance. Under GDPR and its relatives it’s personal data. It’s the first thing a subpoena asks for. It’s a breach magnet. And you almost never need the address itself — you need “same visitor or not,” which the address answers only as a side effect of also being able to identify someone.
So we don’t keep it. The edge reads the client IP for exactly two things: the in-memory rate limiter, where it lives for a minute and is never written down, and the input to a one-way hash. The address never reaches a log line, never reaches a usage trace, and never reaches the workflow engine that runs the retrieval. It is used and discarded in the same breath.
A salt is cheaper than a subpoena
In place of the address we store a keyed hash of it — an HMAC with a secret salt, truncated to a short token. The same visitor always produces the same token, so we can count returning visitors and stitch a conversation together. But the token is a dead end: you can’t run it backwards to the IP, and because the salt is secret, nobody can even pre-compute the hash of every possible address to build a reverse lookup. (This is safe to describe, by the way — the security is the secret salt, not the obscurity of the method. A design that only holds up while the technique is hidden isn’t a design.)
What we hold, then, is a value that says this is the same visitor as before and holds nothing that says who. There’s nothing in it to leak, and nothing in it to hand over. The cheapest way to protect data you might be compelled to produce is to have never been able to produce it.
Two salts, two jobs
One hash can’t do two jobs, though, and this is where a lazy design quietly overreaches. We use two, and the difference between them is the point.
The visitor id uses a salt unique to each of our sites. So the same person reading two of our properties gets two unrelated tokens — we can’t build a cross-site profile of an ordinary reader, and we didn’t want the ability to. Unlinkability across sites isn’t a promise in a policy; it’s a property of the arithmetic.
The threat id is the deliberate exception. An attacker throwing injection payloads at several of our sites is exactly the thing we do want to see. So our security events — and only our security events — also carry a threat id computed from a salt shared across the sites. If one source is probing two of our properties, it lights up. An ordinary visitor who happens to read two of our articles does not.
| Visitor id | Threat id | |
|---|---|---|
| Salt | per site | shared across sites |
| Rides | every ordinary usage trace | only security-event logs |
| Lets us | count visitors, group a conversation | see one attacker across our sites |
| Cannot | link a reader across sites | appear on an ordinary trace |
The property, in one sentence: ordinary visitors are unlinkable across our sites and an attacker is not — because we chose which correlation to enable and which to make impossible. And the threat id lives only on the security log, never on the everyday usage trace; the two ids are never written into the same record. The surveillance-shaped capability is walled into the abuse path, where it belongs, and can’t bleed into ordinary analytics.
The abuse trail is a byproduct, not a project
When the chat refuses something — an injection attempt, a rate-limit trip, a malformed request, a blocked question — it writes one structured line: what kind of event, which site, the threat id, the country, and a short reason capped so no user’s text leaks. No address. Those lines flow to the log store we already run, and a small job correlates threat ids and raises an alert the moment one appears on more than one site.
We didn’t stand up a security-monitoring product to get this. The handler that serves an answer emits the event as a side effect of doing its job, and the platform we already operate does the correlation. The forensic trail is exhaust — which is the governance point we keep arriving at from every direction: the evidence should be a byproduct of enforcement, not a compliance project bolted on afterward.
Blocking is enforcement, and enforcement leaves a record
The last piece ties the abuse trail to the thing making it necessary. A hostile question — the kind that tries to talk the assistant into ignoring its own rules and leaking its internal configuration — never reaches the model. It’s refused at the gateway, at the point where the question is turned into an embedding, before the agent that answers ever runs.
For a while that refusal looked, from inside our own service, like a generic failure, and the visitor got an unhelpful “something went wrong.” We fixed both halves at once: the visitor now gets a plain, honest refusal, and the block is recorded as a security event with the category that tripped it. That is the shape we argue for everywhere — the control lives in the infrastructure, it denies the action at the moment it happens, and the denial is the evidence, tied to what the request was allowed to do rather than reconstructed later from a story. It’s the same argument as a control nobody tests is a belief: a guardrail you can’t see fire is one you’re trusting, not one you’re running.
The reusable part
Most teams reach for surveillance-grade logging because it’s the default and it feels prudent — capture everything, we might need it. But “we might need it” is precisely how you end up holding a pile of personal data you can’t fully protect and didn’t actually require. The governed move is the opposite: decide up front the minimum you have to be able to distinguish, then design so you can distinguish exactly that and nothing more.
Counting is not identifying. Correlating an attacker is not profiling a reader. Separate those on purpose and you get three things at once — analytics you can defend, an abuse trail an auditor would respect, and nothing in your logs that a breach or a subpoena could turn against the people who trusted you enough to type a question.
Takeaways
- The raw IP is the liability, not the signal. You almost always need “same visitor or not,” and a keyed hash gives you that without the address.
- Salt per purpose. A per-site salt keeps ordinary visitors unlinkable across your properties; a salt shared only on security events makes an attacker visible.
- Keep the correlation-capable id off the everyday trace. The two ids should never share a record.
- Emit structured security events and let your platform correlate them. The abuse trail should be a byproduct of handling the request, not a second system.
- Block at the infrastructure, turn the block into an honest refusal, and record it. Enforcement and evidence in one motion.
This is our own chat, instrumented the way we’d instrument yours — the same governance we ship in a governed chatbot build, and the reason the assistant on this page can now answer “how do you handle my privacy here?” honestly. If you’re shipping an AI feature and staring at the question of how to measure and secure it without becoming a data-retention liability, that’s the work we do. Talk to us — or ask the assistant, and watch it decline to tell you anything it shouldn’t.