Skip to content

Why Filtering Salesforce Event Logs by IP Misses Most of the Incident

Salesforce splits one actor's activity across event types with different identifying fields. Why IP filtering misses the SOQL, and how to correlate safely.

TL;DR

  • Salesforce splits one actor's activity across many event types, and each carries a different subset of identifying fields. No single log answers "what did this actor do".
  • The query log has no CLIENT_IP column. Filter your event logs by address and you find the requests while missing every SOQL execution, which is the part that tells you what data was read.
  • Filtering by user instead of address sounds like the fix. On an Experience Cloud site it is not, because the guest user is one identity shared by everyone who visited.
  • Correlating a seed across every captured event type is the answer, but two joins have to be refused or the output stops being evidence: a blank field must never be a join key, and a shared identity must not be expanded.
  • "No rows matched" is ambiguous and dangerous. It means either the actor did nothing, or nobody captured the hour they did it in. Any honest output has to say which.
  • sf audit timeline does this offline against captures on disk, so it still works after the org's retention window has expired or its credentials have been revoked.

What You'll Learn

  • Which identifying fields each event type actually carries, and where the gaps are
  • Why the two obvious filters (by address, by user) each miss a different half of the picture
  • The two correlations that must be refused, and what happens when they are not
  • How to read a coverage statement, and why it belongs above the results
  • How to reconstruct a defensible timeline when you no longer have access to the org

The Problem

You have an address. Someone flagged traffic from 203.0.113.50 against your Experience Cloud site, and the question you have to answer is the ordinary one: what did they do, and did they get anything.

So you filter the event logs by that address. You get a list of requests. It looks like a complete answer, and it is not, because the log that records what was queried does not carry an address column at all. Every SOQL execution that actor triggered is invisible to the filter you just ran. You are looking at which doors were tried, with no view of which drawers were opened.

The natural correction is to pivot: find the user associated with those requests and filter by user instead. On an internal org that works. On a community it produces the opposite failure, because the guest user is a single identity standing in for every anonymous visitor. Filtering by it returns the whole crowd's activity presented as one actor's.

Both filters are reasonable. Both are wrong in different directions, and neither announces that it is wrong.

Common questions this article answers:

  • Why does filtering by IP miss query activity?
  • How do I attribute activity to one visitor when they share the guest user with everyone else?
  • How do I know whether an empty result means nothing happened or nothing was captured?

Quick Answer

Salesforce distributes one actor's activity across many event types, each carrying a different subset of identifying fields, so no single log can answer what an actor did. Filtering by CLIENT_IP misses every SOQL execution because the query log does not have that column, and several other event types carry no address either. Filtering by user id collapses on Experience Cloud sites, where the guest user represents every anonymous visitor at once. The fix is to seed on one identifier and correlate it across every captured event type, following links between them. Two correlations must be refused to keep the output defensible: a blank field used as a join key matches every other blank row and silently attributes strangers' sessions to your actor, and expanding through a shared identity presents a crowd as an individual. sf audit timeline --window yesterday --seed ip:203.0.113.50 does this against captures already on disk, runs entirely offline with no org connection, and writes timeline.csv, timeline.json and summary.md. It reports coverage first, because an empty result means either the actor did nothing or the hour was never captured.

Why no single log answers the question

The event types are not variations on one schema. They were designed independently, for different purposes, and they identify actors differently as a result.

Some carry an address. Some carry a session. Some carry a request id that ties several rows into one interaction. Some carry a user and nothing else. On a real capture, four event types carry no CLIENT_IP column at all, and the query log is one of them.

That single fact reshapes an investigation. The address is the thing you are usually handed first, by a WAF alert, an abuse report, or a scanner triage. It is also the identifier with some of the worst coverage across the logs that matter most. You are given the key that opens the fewest doors.

The way through is not to pick a better filter. It is to stop filtering and start correlating: take the identifier you have, find the rows that carry it, read the other identifiers those rows expose, and follow those into the event types your original identifier could never reach. An address leads to a session, a session leads to request ids, a request id leads to the query log rows that never had an address to begin with.

That is mechanical work, which is the good news. It is also where cross-event correlation quietly goes wrong.

The two joins that have to be refused

Correlation is easy to get confidently wrong, and a wrong answer here is worse than no answer, because it reads as evidence. Two failures matter enough to be refused outright.

A blank field is never a join key

Suppose you are following REQUEST_ID between event types, and some rows have that field empty.

A naive join treats blank as a value. Every row with a blank REQUEST_ID now matches every other row with a blank REQUEST_ID, and your timeline quietly absorbs unrelated sessions from unrelated visitors. The output looks richer. It has just stopped being about your actor.

The failure mode is nasty because it makes the result more impressive, not less. A timeline that grows when you add a correlation feels like progress.

A shared identity is not expanded

The guest user problem again, in join form. If your actor's rows point at the guest user id, expanding through that id pulls in everyone else who used it.

The right behaviour is to refuse and show why:

Expansion refused: userId 005xx0000000000 is shared by 1371 distinct addresses
  (threshold 8). Override with --allow-shared-identity.

That refusal is more useful than any timeline it might have produced. It tells you the identity is shared, by how many, and that a deliberate override exists if you have a reason. A tool that expanded silently would have handed you 1,371 visitors' activity attributed to one person, with nothing on the page to suggest anything was wrong.

The threshold is tunable with --max-cardinality, defaulting to 8 distinct actors.

Why coverage belongs above the results

Here is the most dangerous output in incident response:

No rows matched.

It means one of two opposite things. Either the actor did nothing during that window, which is exculpatory, or nobody captured that window, which is not evidence of anything at all. Presented without context, an investigator reads the first meaning and moves on.

So the output leads with what was actually captured:

Window — coverage INCOMPLETE
  captured   AuraRequest, ListViewEvent
  MISSING    LightningInteraction (not-in-core-set)
  MISSING    GuestUserAnomalyEventStore (storage-disabled)

No activity in captured sources. Coverage incomplete — 2 sources missing.

Now the empty result is interpretable. Two sources are missing, and the reasons differ: one is not in the captured set, the other has storage disabled at the org level, which is a configuration finding in its own right and probably wants fixing before the next incident.

This is the difference between a tool that answers your question and one that tells you how much of your question it is in a position to answer.

Running it

The prerequisite is that you were already capturing. sf audit timeline reads captures written by sf audit events pull, which is the habit we argue for in Free event monitoring with EventLogFile. Free-tier EventLogFile retention is one day, so the capture has to be running before you need it. Nothing reconstructs a window nobody kept.

sf plugins install @cclabsnz/sf-audit

That warns the plugin is not digitally signed, which is expected: Salesforce only accepts signing keys served from its own domain, so no community plugin can satisfy it.

Then, with an address in hand:

sf audit timeline --window yesterday --seed ip:203.0.113.50

Seeds are typed and repeatable: ip:, user:, session:, request:, login:, transaction: and event:. Windows take whichever form is nearest to hand, because composing an ISO 8601 interval while an incident is running is not a good use of anyone's attention:

You type You get
yesterday the whole of yesterday, UTC
today midnight UTC until now
2h, 90m the last two hours, the last ninety minutes
2026-08-02 that whole day, the shape the free tier captures in
2026-08-02T04:00Z/PT1H an exact interval

Times are UTC throughout, which is worth saying out loud in a timeline that may end up in front of a regulator.

Two things you do not need to know in advance. Omit --seed and you get the whole window uncorrelated, which is where you go looking for something worth seeding on. And ask for a window nobody captured, and it tells you what was:

No captures for 2026-07-01 under ~/.sf/event-baseline/00Dxx0000000000EAA.

Captured days for this org:
  2026-08-01   11 event type(s), whole day
  2026-08-02   14 event type(s), whole day

Try:  --window 2026-08-02

Output is timeline.csv, timeline.json and summary.md, so the same run serves the spreadsheet, the pipeline and the written record.

The part that matters at 2am

It runs entirely offline. No org connection is opened.

That sounds like an implementation detail and is actually the most forensically significant property of the whole thing. In a real incident, access is often the first casualty. Credentials get rotated, the compromised integration user gets disabled, and sometimes the org is locked down by someone who is right to lock it down. Retention expires on its own schedule regardless.

A tool that queries the org during the investigation stops working at exactly the moment the investigation gets serious. One that reads captures already on disk keeps working weeks later, after the credentials it was captured with have been revoked. It also means you can hand the capture directory to someone else, an incident responder or an auditor, without handing over org access.

If you are triaging guest traffic and trying to work out whether you are looking at a scanner or a breach before you get this far, that decision has its own method, covered in Salesforce EventLogFile guest traffic triage.

What to write down

Whatever tool you use, the incident record needs a few things that investigators routinely omit and later wish they had:

Window examined: state it in UTC with the exact interval, not "Tuesday morning".

Coverage: which event types were captured for that window, and which were missing and why. An investigation that does not record its own blind spots cannot be reviewed later.

Seed and expansion path: what you started from, and which identifiers you followed to reach each subsequent source. Someone should be able to reproduce your timeline from the same capture.

Refusals: any correlation the tool declined, and whether you overrode it. If you expanded through a shared identity deliberately, that is a defensible choice you must be able to show you made knowingly.

What you did not find, distinguished from what was not captured. These get conflated under pressure, and the difference is the whole finding.

That last line is the one worth arguing about in a review. "We found no evidence of data access" and "we had no logs covering the hour in question" are different sentences with different consequences, and only one of them should ever appear in a breach notification.

Frequently Asked Questions

Q: Why does the query log not carry an IP address?

A: The event types were designed independently for different purposes, and identifying fields were not standardised across them. On a real capture, four event types carry no CLIENT_IP column at all, and the query log is among them. This is a property of the platform's logging, not a capture problem, so no amount of care during collection will produce an address column that was never emitted.

Q: We are on an Experience Cloud site. Can we ever attribute activity to one visitor?

A: Sometimes, but not through the user id, which is shared by every anonymous visitor. You correlate on identifiers that do distinguish sessions, such as session or request identifiers, and accept that attribution is a chain of inference rather than a lookup. The important part is that the chain is visible and each link is stated, so someone reviewing it can disagree with a specific step rather than the conclusion as a whole.

Q: What is the risk of just doing this correlation in a spreadsheet?

A: The two failures described above, and both are silent. A blank join key produces a bigger, richer-looking timeline that includes strangers, and expanding through a shared identity produces a confident attribution of a crowd's behaviour to one person. Neither shows up as an error. They show up as a persuasive answer that happens to be wrong, which is the worst outcome available.

Q: Do I need paid Event Monitoring?

A: No, and the free tier is the more common starting point. Every Enterprise, Unlimited and Performance org, plus Developer Edition, gets daily EventLogFile logs without the add-on. The constraint is one-day retention, which is precisely why capture has to be a standing habit rather than something you start after an alert.

Q: Can I use this after we have locked the org down?

A: Yes, and that is the point of it running offline. It reads captures from disk and opens no org connection, so it keeps working after credentials are rotated, the integration user is disabled, or the retention window has closed. You can also hand the capture directory to an external responder without granting them access to the org.

Key Takeaways

  • No single Salesforce event log answers "what did this actor do". Activity is split across event types carrying different identifying fields.
  • Filtering by address misses every SOQL execution, because the query log has no CLIENT_IP column, and it is not alone.
  • Filtering by user collapses on communities, where the guest user is one identity representing everyone.
  • Two joins must be refused: blank fields as join keys, and expansion through shared identities. Both fail silently and both make the output look better while making it wrong.
  • An empty result is ambiguous until coverage is stated. "Nothing happened" and "nothing was captured" are different findings.
  • Offline matters more than it sounds. Access is often the first thing you lose in an incident, and retention expires regardless.

What's Next?

Recommended Reading:

Action Items:

  1. Confirm you are capturing EventLogFile daily, before you need it. One-day retention means an uncaptured window is gone permanently.
  2. Check which event types your org actually emits and which have storage disabled, so you learn your blind spots outside an incident rather than during one.
  3. Run a timeline over a quiet window with no seed, to see the shape of your own coverage while nothing is at stake.

Resources & References

Responses

Checking your session.

Loading responses.