2.3 Time Functions and Joins
Time Functions
Time is the most important dimension in security investigation. Every question starts with “when”: when did the sign-in happen, when was the file downloaded, when did the alert fire. KQL provides a rich set of functions for working with datetime values.
ago() — Relative time windows
The function you will use most. ago() calculates a datetime relative to the current time.
| |
Common intervals: ago(1h), ago(24h), ago(7d), ago(30d), ago(90d). You can also use minutes: ago(15m).
Initial triage: ago(1h) to ago(24h). Scope assessment: ago(7d). Historical pattern analysis: ago(30d) to ago(90d). Longer windows process more data and take longer to run. Start narrow and widen if needed.
between() — Fixed time ranges
When you need a precise window — not relative to “now” — use between:
| |
This returns all sign-in events between 14:00 and 16:00 UTC on March 15. Essential when investigating a specific incident with a known timeline.
datetime_diff() — Calculate time differences
Measures the gap between two timestamps:
| |
Units: 'second', 'minute', 'hour', 'day'. Use this to calculate dwell time (how long between initial compromise and detection) or response time (how long between alert and containment).
bin() — Time bucketing for trends
bin() rounds timestamps to a specified interval, creating discrete buckets for aggregation:
| |
This creates an hourly failure count — the raw data behind a “failed sign-ins over time” chart. Change 1h to 1d for daily counts, 15m for 15-minute resolution, or 5m for near-real-time analysis.
Figure 2.3: bin(TimeGenerated, 1h) groups events into hourly buckets, making spikes visible. The red bars indicate anomalous failure volume.
Other useful time functions
| Function | What it does | Investigation use |
|---|---|---|
hourofday(timestamp) | Hour (0-23) | Detect off-hours activity |
dayofweek(timestamp) | Day of week | Detect weekend activity |
startofday(timestamp) | Midnight of that day | Group by calendar day |
format_datetime(ts, 'yyyy-MM-dd HH:mm') | Format for readability | Clean report output |
now() | Current time | Calculate age of events |
Off-hours detection pattern:
| |
This surfaces successful sign-ins between 10pm and 6am — useful for detecting compromised accounts being used while the legitimate user is asleep.
Try it yourself
| |
The countif function counts only rows matching the condition, allowing you to calculate both metrics in a single summarize. Adding render timechart visualises this as a line chart directly in the query results — you will learn more about rendering in subsection 2.5.
Joining Tables
Joins are how you correlate data across different sources — the fundamental operation in cross-product investigation. “Show me everyone who received a phishing email AND subsequently signed in from a new IP” requires joining EmailEvents to SigninLogs.
The four join kinds you will use
An inner join between a table with 100 phishing emails and a table with 50 sign-ins per user can produce 5,000 result rows. innerunique caps this at 100 rows (one match per left row). Always start with innerunique unless you specifically need all combinations.
Worked example: correlating phishing emails with sign-ins
“Did any user who received a phishing email subsequently sign in from a new IP?”
| |
This uses a let statement to create a list of phishing recipients, then filters the sign-in logs to only those users. It is simpler than a full join and more efficient when you only need to check “is this user in the list?”
For a full join with data from both tables:
| |
This joins phishing emails with subsequent successful sign-ins by the same user, calculates how many minutes elapsed between the email and the sign-in, and sorts by the shortest gap. A user who signs in from a new IP 5 minutes after receiving a phishing email is a strong compromise indicator.
Check your understanding
1. You need to correlate a phishing email with a subsequent sign-in from the same user. Which join type is most appropriate?
2. What does bin(TimeGenerated, 1h) do in a summarize clause?
3. When would you use an anti join?