Every so often a request lands on the security team that isn't really a security request. It sounds like this: "Can you pull the computer activity for this person and tell us whether they were actually working on these dates?"
It usually comes with a good reason attached, and you usually can pull the data. Microsoft 365 gives you sign-in logs, Defender gives you endpoint process activity, and mailbox telemetry gives you sent mail. Three solid sources, all queryable from one place.
Here's the thing nobody asking the question wants to hear: none of those sources record whether a human was sitting at the keyboard. They record discrete events — an app launched, a mail sent, a token issued. Between those events is a void, and the entire skill of this kind of work is refusing to fill that void with a story.
This post is the mental model and the KQL I use, plus a walkthrough of how to read the output without lying to yourself. All names, domains, devices, dates, and mail subjects below are fictional ([email protected], device CONTOSO-WKS4471) — but the patterns are real, because the patterns are the lesson.
Mental model
The telemetry is a motion-sensor light in a hallway, not a camera. It fires when something crosses it — and stays dark while someone sits perfectly still in the room. A dark hallway is not an empty one.
01The mental model: three tiers of signal
Sort every source into a tier before you query. Getting this wrong is how activity reviews turn into fiction.
Build almost entirely from Tier 1, corroborate with Tier 2, and name Tier 3 explicitly so you can set it aside.
| Tier | What it is | Sources | How to treat it |
|---|---|---|---|
| 1 · Genuine human work | Events a person almost certainly caused deliberately | EmailEvents (sent mail); discrete Office app launches in DeviceProcessEvents | Your backbone — build the narrative from these |
| 2 · Auth moments | A human proved who they were at a point in time | SigninLogs (interactive sign-ins) | Corroboration — anchors a day, not a duration |
| 3 · Machine noise | Software-generated events that look like activity | AADNonInteractiveUserSignInLogs (token refreshes); browser helper processes | Name it, explain it, exclude it — never presence |
The mistake I see over and over: someone pulls a Tier 3 source, sees thousands of events around the clock, and writes "user was active 24/7." They weren't active. Their laptop was powered on. Those are not the same sentence.
The one-line model
Build from Tier 1, corroborate with Tier 2, name-and-exclude Tier 3. Everything that goes wrong in these reviews is a Tier 3 event promoted to presence.
02The KQL pack
Runs in Microsoft Sentinel / Log Analytics (Advanced Hunting works too, with the retention caveat at the end). Set the parameters once at the top of each query.
Don't fight AADSignInEventsBeta
If you reach for it in Advanced Hunting and get Failed to resolve table or column expression named 'AADSignInEventsBeta', that table isn't provisioned in your tenant — it depends on the Entra ↔ Defender identity integration being enabled. SigninLogs in Sentinel is the authoritative interactive sign-in source, and it's what the rest of this pack uses.
1 · Interactive sign-ins — the "was a human here" anchor
Anchor on the moments a real logon prompt was satisfied.
let subjectUpn = "[email protected]"; SigninLogs | where UserPrincipalName =~ subjectUpn | where ResultType == 0 // successful only | extend ETime = datetime_utc_to_local(TimeGenerated, "America/New_York") | where (ETime between (datetime(2025-03-02) .. datetime(2025-03-09))) or (ETime between (datetime(2025-03-23) .. datetime(2025-03-30))) | extend Device = tostring(DeviceDetail.displayName) | project ETime, AppDisplayName, Device, ClientAppUsed, IPAddress | sort by ETime asc
And the version that collapses to one row per day — this is what feeds a report:
let subjectUpn = "[email protected]"; SigninLogs | where UserPrincipalName =~ subjectUpn | where ResultType == 0 | extend ETime = datetime_utc_to_local(TimeGenerated, "America/New_York") | where (ETime between (datetime(2025-03-02) .. datetime(2025-03-09))) or (ETime between (datetime(2025-03-23) .. datetime(2025-03-30))) | summarize FirstSignIn = min(ETime), LastSignIn = max(ETime), SignIns = count() by Day = format_datetime(ETime, "yyyy-MM-dd") | sort by Day asc
2 · Non-interactive sign-ins — the noise you need to see to discount
Don't skip this one. You run it not to use it as activity, but to prove to yourself — and to whoever reads the report — why interactive sign-ins are sparse.
let subjectUpn = "[email protected]"; AADNonInteractiveUserSignInLogs | where UserPrincipalName =~ subjectUpn | where ResultType == 0 | extend ETime = datetime_utc_to_local(TimeGenerated, "America/New_York") | where (ETime between (datetime(2025-03-02) .. datetime(2025-03-09))) or (ETime between (datetime(2025-03-23) .. datetime(2025-03-30))) | summarize First = min(ETime), Last = max(ETime), Events = count() by Day = format_datetime(ETime, "yyyy-MM-dd") | sort by Day asc
3 · Endpoint logon — OS-level corroboration of Tier 2
let subjectDevice = "CONTOSO-WKS4471";
DeviceLogonEvents
| where DeviceName startswith subjectDevice
| extend ETime = datetime_utc_to_local(Timestamp, "America/New_York")
| where (ETime between (datetime(2025-03-02) .. datetime(2025-03-09)))
or (ETime between (datetime(2025-03-23) .. datetime(2025-03-30)))
| where ActionType in ("LogonSuccess","Logon","LogoffSuccess","Lock","Unlock")
| project ETime, ActionType, LogonType, AccountName, DeviceName
| sort by ETime asc
4 · Application activity — Tier 1, but only after you strip the noise
The naive version pulls every process. A modern browser spawns dozens of helper processes an hour, and if you count those as "activity" you'll report someone browsing at 3 a.m. when their machine was idle. The fix is one line: the human-launched browser has no --type= argument; the child/utility processes do.
let subjectDevice = "CONTOSO-WKS4471";
let realApps = dynamic(["OUTLOOK.EXE","ms-teams.exe","Teams.exe","EXCEL.EXE",
"WINWORD.EXE","POWERPNT.EXE","Acrobat.exe","AcroRd32.exe","msedge.exe","chrome.exe"]);
DeviceProcessEvents
| where DeviceName startswith subjectDevice
| extend ETime = datetime_utc_to_local(Timestamp, "America/New_York")
| where (ETime between (datetime(2025-03-02) .. datetime(2025-03-09)))
or (ETime between (datetime(2025-03-23) .. datetime(2025-03-30)))
| where FileName in~ (realApps)
// drop browser child/helper processes — keep only the user-launched instance
| where not((FileName in~ ("chrome.exe","msedge.exe")) and ProcessCommandLine has "--type=")
| project ETime, FileName, ProcessCommandLine, AccountName
| sort by ETime asc
5 · Sent email — your single most reliable human signal
let subjectUpn = "[email protected]"; EmailEvents | where SenderFromAddress =~ subjectUpn | where EmailDirection == "Outbound" | extend ETime = datetime_utc_to_local(Timestamp, "America/New_York") | where (ETime between (datetime(2025-03-02) .. datetime(2025-03-09))) or (ETime between (datetime(2025-03-23) .. datetime(2025-03-30))) | project ETime, Subject, NetworkMessageId, DeliveryAction | sort by ETime asc
EmailEvents gives you a recipient object ID, not an address. If you need the actual recipients, join to EmailRecipientInfo:
// ... continue from EmailEvents above ...
| join kind=leftouter (
EmailRecipientInfo
| project NetworkMessageId, RecipientEmailAddress
) on NetworkMessageId
| project ETime, Subject, RecipientEmailAddress, DeliveryAction
03Reading the results without over-reading them
The masked excerpts below are shaped exactly like real output — and each one has a trap in how you read it.
Interactive sign-ins: the "gap" that isn't a gap
The daily query comes back looking alarmingly empty — four rows for a fourteen-day window:
| Day | FirstSignIn | LastSignIn | SignIns |
|---|---|---|---|
| 2025-03-04 | 05:43 AM | 05:43 AM | 1 |
| 2025-03-23 | 06:31 AM | 06:31 AM | 1 |
| 2025-03-26 | 08:24 AM | 09:49 AM | 20 |
| 2025-03-29 | 04:57 PM | 05:42 PM | 2 |
First instinct: "They only signed in on four days — were they even working the rest of the time?" Wrong instinct. An interactive sign-in fires when someone satisfies a fresh logon prompt. If a user never fully signs out — they lock the screen and walk away, or just close the lid — the session persists for days and no new interactive sign-in is logged. Sparse interactive sign-ins don't mean absence; they mean a persistent session. To confirm that's what you're looking at, you run query #2.
Non-interactive sign-ins: the wall of noise
Query #2 comes back with a row for every day, including the weekend, every one of them enormous:
| Day | First | Last | Events |
|---|---|---|---|
| 2025-03-02 | (prev) 08:01 PM | 07:59 PM | 945 |
| 2025-03-04 | (prev) 08:00 PM | 07:59 PM | 1346 |
| 2025-03-05 | (prev) 08:00 PM | 07:59 PM | 1340 |
| 2025-03-08 (Sat) | (prev) 08:01 PM | 07:59 PM | 967 |
| 2025-03-09 (Sun) | (prev) 08:05 PM | 07:58 PM | 936 |
Two tells that this is machine noise, not a person. First, it never stops — every day spans roughly 8:00 PM the previous evening to 8:00 PM, a continuous 24-hour band. (That 8 PM boundary is just midnight UTC rendered in US Eastern; it's an artifact of the time-zone conversion, not a human clocking in at 8 PM.) Real humans don't generate steady auth traffic at 3 a.m. on a Sunday — background services do: Outlook sync, Teams presence, OneDrive, mobile apps all silently refresh tokens around the clock. Second, the volume — hundreds to over a thousand events a day. No one performs a thousand deliberate sign-ins.
This table is proof of a session, not a presence signal
It explains the sparse interactive sign-ins from #1 — a persistent authenticated session — and it is worthless as evidence anyone was at the machine. Put "signed in 24/7" in a report off the back of this and you've actively misled the reader. Name it, explain it, set it aside.
Process events: the browser's heartbeat vs. real work
Run the process query without the --type= filter and Chrome drowns everything — clusters at oddly regular times, every one carrying a --type= and landing at :50 past the hour:
| ETime | FileName | ProcessCommandLine (truncated) |
|---|---|---|
| 12:50 AM | chrome.exe | --type=utility --utility-sub-type=unzip.mojom.Unzipper … |
| 01:50 AM | chrome.exe | --type=utility --utility-sub-type=patch.mojom.FilePatcher … |
| 02:50 AM | chrome.exe | --type=renderer … |
These are Chrome's own background/updater helper processes — the browser maintaining itself, not a person browsing at 2 a.m. With the filter applied, the same day's real application activity is far quieter and far more honest:
| ETime | FileName |
|---|---|
| 06:01 AM | ms-teams.exe |
| 08:20 AM | EXCEL.EXE |
| 08:34 AM | EXCEL.EXE |
That's the signal. A Teams launch first thing, Excel worked mid-morning. Sparse, but real. One subtlety worth internalizing: you'll see zero OUTLOOK.EXE launches on days the person clearly sent email. That's not a contradiction — Outlook was already open from a previous day, so it never generated a new launch event. Which is exactly why sent mail, not app launches, is your anchor.
Sent email: the honest baseline
| ETime | Subject | DeliveryAction |
|---|---|---|
| 03-23 06:02 AM | RE: Q3 forecast delay | Delivered |
| 03-23 07:04 AM | Re: Today's sync | Delivered |
| 03-23 08:41 AM | RE: Q3 forecast delay | Delivered |
| 03-23 09:15 AM | Re: Q3 forecast delay | Delivered |
Substantive, timestamped, unambiguously human. A person wrote each of these and sent it. This is the closest thing you have to ground truth — and notice it lands on a day (03-23) where interactive sign-in showed only a single 6:31 AM event. Tier 1 fills in what Tier 2 leaves blank.
Assembling a defensible day
One defensible day · Monday 03-23
Interactive sign-in ~6:31 AM. Teams launched ~6:01 AM; four emails sent between 6:02 and 9:15 AM on the "Q3 forecast" thread; Excel worked 8:20–8:34 AM. Last captured activity: ~9:16 AM.
Every clause traces to a Tier 1 or Tier 2 event. Nothing is inferred from Tier 3. And note the careful phrase "last captured activity" — not "stopped working at 9:16." Which brings us to the part that matters most.
04The limitations that belong in every report
If you take one thing from this post, take this section. The limitations are the analysis.
Discrete events, not continuous input. The telemetry records launches, sign-ins, and sends. It does not record keyboard and mouse activity. Reading, phone calls, meetings, and working inside an already-open app generate no events — so a quiet stretch is not evidence someone stepped away.
Absence of an event is not evidence of absence. No sign-in on a given day doesn't mean no work (persistent session). No sent mail doesn't mean idle (maybe they had no reason to email). You can affirm what the events show; you cannot affirm what their silence means.
No reliable end-of-day marker. There's usually no dependable logoff or shutdown event. "Last captured activity" is a floor, not a stop time — the person may well have kept working in an open app afterward.
Machine noise is not presence. Non-interactive token refreshes and browser helper processes run whether or not a human is there. They belong in a "what we excluded and why" note, never in the activity narrative.
A report that states these plainly is not weaker for it — it's the only kind that survives scrutiny, and it protects the subject from being misjudged on background noise just as much as it keeps you honest.
05Takeaways
The discipline isn't KQL. It's writing down only what you can defend.
- Sort your sources into tiers before you query. Human work, auth moments, machine noise. Build from the first, corroborate with the second, name-and-exclude the third.
- The most impressive-looking table is usually the least meaningful. Thousands of round-the-clock events is a screensaver, not a workday.
- --type= is your friend for stripping browser chatter out of DeviceProcessEvents.
- Sent mail is your ground truth; interactive sign-ins corroborate; everything else needs a skeptic reading it.
- Write the limitations first, not last. They're not boilerplate. They're the reason anyone should trust the rest.
The one-line model
Look at a fourteen-day window with four sign-ins and a thousand nightly token refreshes, and write down only what you can actually defend. In a field that loves confident dashboards, the most senior move is to say "the data doesn't support that" — and mean it.
Comments
Questions or corrections welcome. Sign in with GitHub to join the thread.