Stop Drowning in Data: Why Telemetry Triangulation is the Key to Better M365 Insights
Stop Drowning in Data: Why Telemetry Triangulation is the Key to Better M365 Insights
Welcome back to the podcast! If you manage a Microsoft 365 tenant, you are likely all too familiar with the feeling of drowning in data. Every day, your environment generates millions of log lines, security alerts, sign-in records, and operational events. Yet, despite having more dashboards than ever before, true clarity often feels completely out of reach. Security teams chase endless false positives while IT and business leaders wonder why expensive licenses are going unused. The problem isn’t a lack of data—it’s a lack of connection. In this comprehensive guide, we are going to explore how telemetry triangulation can transform your approach to M365 management, drastically reducing false alerts and bringing crucial business context to your security operations.
To dive even deeper into this strategy and hear real-world examples of how to turn useless noise into pure gold, make sure to check out the corresponding episode, Correlate Microsoft 365 Telemetry for Better Insights. Now, let’s roll up our sleeves and break down how to stop collecting data in silos and start building a smarter, unified telemetry engine.
Core streams (collect at minimum)
Before you can build meaningful correlations, you need to ensure you are feeding your analysis engines with the right foundational ingredients. Trying to build insights from a single isolated data source is a recipe for blind spots and noisy alerts. At a minimum, your telemetry collection strategy must encompass the following core streams:
- Identity (Entra ID / Azure AD):
SigninLogsincluding risk state, location, device details, and application information. - Unified M365 audit:
OfficeActivityspanning SharePoint, OneDrive, Teams, Exchange, and Purview compliance events. - Usage & licensing: Graph reports detailing active users, feature usage, and assigned SKUs.
The overarching goal here is triangulation. Every security detection or business insight you build should pull from ≥2 streams. By requiring multiple data points to align before an alert triggers, you dramatically reduce false positives and inject vital business context into your environment.
KPIs that matter (and what to correlate)
Dashboards fail when they show metrics in a vacuum. A high number of sign-in failures might mean nothing on its own, but combined with rapid data exfiltration, it becomes an emergency. Here are the key performance indicators and correlations that genuinely matter for your organization:
-
Risky access + data movement
- Spike in failed or risky sign-ins AND large SharePoint/OneDrive downloads or external shares.
-
Adoption vs. login
- Users logging into Teams but never using meetings, apps, or channels → clear indication of a training need.
-
License spend vs. activity
- E5/E3 assigned AND zero meaningful activity over 30–60 days → time to reclaim or downgrade.
-
Email vs. Teams split-brain
- Big Exchange threads while Teams for that specific group is completely idle → collaboration friction.
-
External collaboration exposure
- New external Teams chats/meetings AND the same recipients appearing in SharePoint external shares.
Copy-paste KQL (Sentinel or M365 Defender Advanced Hunting)
To help you put telemetry triangulation into practice immediately, here are production-ready Kusto Query Language (KQL) scripts. You can drop these directly into Microsoft Sentinel (Log Analytics) or Microsoft 365 Defender Advanced Hunting. Table names may differ slightly depending on your platform, so note the comments included below.
1) High-risk sign-ins + download spike (past 24h)
// Sentinel: SigninLogs + OfficeActivity let risky_signins = SigninLogs | where TimeGenerated > ago(24h) | where RiskState in ("atRisk", "confirmedCompromised") or RiskLevelAggregated != "none" | summarize riskySignins=count(), firstSeen=min(TimeGenerated), lastSeen=max(TimeGenerated) by UserPrincipalName, IPAddress, AppDisplayName; let big_downloads = OfficeActivity | where TimeGenerated > ago(24h) | where Workload in ("SharePoint", "OneDrive") and Operation in ("FileDownloaded", "FileSyncDownloadedFull", "FilePreviewed") | summarize downloads=count(), uniqueSites=dcount(SiteUrl) by UserId, UserKey, UserType=tolower(UserType); risky_signins | join kind=innerunique ( big_downloads | project UserId, downloads, uniqueSites) on $left.UserPrincipalName == $right.UserId | where downloads >= 50 or uniqueSites >= 3 | project UserPrincipalName, IPAddress, AppDisplayName, riskySignins, downloads, uniqueSites, firstSeen, lastSeen | order by downloads descM365 Defender AH: use
IdentityLogonEvents(risk viaRiskStateif available) +OfficeActivity.
2) External sharing burst following failed sign-ins
let failed = SigninLogs | where TimeGenerated > ago(24h) | where ResultType !in ("0", "50125", "50140") // non-success (tune if needed) | summarize fails=count() by UserPrincipalName, bin(TimeGenerated, 30m); let shares = OfficeActivity | where TimeGenerated > ago(24h) | where Workload in ("SharePoint", "OneDrive") and Operation in ("SharingSet", "AnonymousLinkCreated", "SecureLinkCreated") | where isnotempty(SharingType) or isnotempty(ExternalUserName) | summarize externalShares=count(), lastShare=max(TimeGenerated) by UserId, bin(TimeGenerated, 30m); failed | join kind=inner (shares) on $left.UserPrincipalName == $right.UserId and $left.TimeGenerated == $right.TimeGenerated | where externalShares >= 5 | project UserPrincipalName, fails, externalShares, lastShare3) Teams activity gap (logins but no features)
// "Logged in" but no Meetings/Channel activity in 14d let logins = SigninLogs | where TimeGenerated > ago(14d) and AppDisplayName has "Teams" | summarize logins=count() by UserPrincipalName; let teams_use = OfficeActivity | where TimeGenerated > ago(14d) | where Workload == "MicrosoftTeams" | where Operation in ("MeetingStarted","CallStarted","ChannelMessageSent","ChatMessageSent","AppInstalled") | summarize actions=count() by UserId; logins | join kind=leftouter teams_use on $left.UserPrincipalName == $right.UserId | extend actions = coalesce(actions, 0) | where logins >= 3 and actions == 0 | project UserPrincipalName, logins, actions4) License assigned but no meaningful activity (60d)
Feed a reference of assigned SKUs into a Sentinel watchlist or Defender AH custom table.
let licensed = externaldata(UserPrincipalName:string, Sku:string)[@"https://<yourstorage>/licensed.csv"] with (format="csv", has_header_row=true); let activity = OfficeActivity | where TimeGenerated > ago(60d) | where Workload in ("SharePoint","OneDrive","MicrosoftTeams","Exchange") | summarize acts=count(), lastSeen=max(TimeGenerated) by UserId; licensed | join kind=leftouter (activity) on $left.UserPrincipalName == $right.UserId | extend acts = coalesce(acts, 0) | where acts == 0 | project UserPrincipalName, Sku, lastSeen5) Email vs Teams “split-brain” signal (stalled collaboration)
let email_threads = EmailEvents // Defender AH | where Timestamp > ago(7d) | summarize emailCount=count() by SenderFromAddress, bin(Timestamp, 1d); let teams_msgs = OfficeActivity | where TimeGenerated > ago(7d) and Workload == "MicrosoftTeams" and Operation in ("ChannelMessageSent","ChatMessageSent") | summarize teamsCount=count() by UserId, bin(TimeGenerated, 1d); email_threads | join kind=leftouter (teams_msgs) on $left.SenderFromAddress == $right.UserId and $left.Timestamp == $right.TimeGenerated | summarize totalEmail=sum(emailCount), totalTeams=sum(teamsCount) by SenderFromAddress | where totalEmail > 100 and totalTeams < 5PowerShell quick grabs (for data mart / Power BI)
Unified Audit Log (UAL) export (rolling)
# Exchange Online PowerShell Connect-ExchangeOnline $start = (Get-Date).AddDays(-7) $end = Get-Date Search-UnifiedAuditLog -StartDate $start -EndDate $end -ResultSize 5000 | Select-Object CreationDate,UserIds,Operations,AuditData | Export-Csv .\UAL_7d.csv -NoTypeInformationEntra sign-ins (Graph) for Power BI
# Requires MSGraph PowerShell & AuditLog.Read.All Connect-MgGraph -Scopes "AuditLog.Read.All","Directory.Read.All" Get-MgAuditLogSignIn -All -Filter "createdDateTime ge $(Get-Date).AddDays(-7).ToString('o')" | Select-Object CreatedDateTime,UserDisplayName,UserPrincipalName,AppDisplayName,IPAddress,Status | Export-Csv .\Signins_7d.csv -NoTypeInformationLicense assignment snapshot (Graph)
Connect-MgGraph -Scopes "User.Read.All" Get-MgUser -All -Property UserPrincipalName,AssignedLicenses | Select-Object UserPrincipalName, @{n='Skus';e={$_.AssignedLicenses.SkuId -join ';'}} | Export-Csv .\Licenses.csv -NoTypeInformationPower BI: Minimal data model (works)
Tables
Signins(UserPrincipalName, App, Risk, IP, Time)OfficeActivity(Time, Workload, Operation, UserId, SiteUrl, ItemType, Target)Licenses(UserPrincipalName, SKU, Dept/Manager if available)- (Optional)
Users(Dept, Country, Manager) for slicing
Relationships
Users[UPN]↔Signins[UserPrincipalName]Users[UPN]↔OfficeActivity[UserId]Users[UPN]↔Licenses[UserPrincipalName]
Measures
Risky Sign-ins = COUNTROWS(FILTER(Signins, Signins[Risk] <> "none"))SP/OD Downloads = COUNTROWS(FILTER(OfficeActivity, OfficeActivity[Workload] IN {"SharePoint","OneDrive"} && OfficeActivity[Operation] IN {"FileDownloaded","FileSyncDownloadedFull"}))Teams Feature Use = COUNTROWS(FILTER(OfficeActivity, OfficeActivity[Workload]="MicrosoftTeams" && OfficeActivity[Operation] IN {"MeetingStarted","ChannelMessageSent","AppInstalled"}))Inactive Licensed Users = DISTINCTCOUNTX(FILTER(Licenses, CALCULATE(COUNTROWS(OfficeActivity), ALLEXCEPT(OfficeActivity, OfficeActivity[UserId]))=0), Licenses[UserPrincipalName])
Visuals that matter
- Correlation strip: Risky Sign-ins vs Downloads (scatter, color by Dept)
- Adoption quadrant: Teams logins (x) vs Feature use (y)
- License waste: Bar of SKUs with “no activity 60d”
- External exposure: Card for external shares (last 24/72h) + drill by site/owner
Automations (Power Automate / Sentinel Analytics)
Power Automate (from Power BI alert or schedule)
- License reclaim: If
InactiveLicensedUsers> threshold or user has 60d inactivity → create ticket to owner/manager with roster → auto-remove after approval. - Targeted training: Users with
Teams logins ≥ 3andFeature use = 0→ send adaptive card with 15-min micro-training; re-check in 14d. - Security “double signal”: When Sentinel analytic fires for risky sign-ins and SharePoint external shares, open a high-severity incident + auto-scope DLP review for affected sites.
Sentinel analytic (use KQL #1 above)
- Schedule every 15 minutes; alert suppression 1h per user; severity High; entity mapping → Account, IP, URL/Site.
Guardrails & hygiene
- Retention: Ensure Audit is enabled and retained long enough (E5 can extend; standard is 90 days).
- Baselining: Use 30–90 day moving averages; alert on deviations, not raw thresholds.
- Privacy: Treat UPNs/IPs as sensitive; restrict workspace access; mask in broad exec views.
- Change mgmt: Re-validate joins after schema updates (AuditData payloads occasionally shift).
- Human in loop: Automations propose; owners approve for license clawback/security actions.
One-page Starter Checklist
- Turn on Unified Audit Log & verify events land in OfficeActivity.
- Stream SigninLogs and OfficeActivity to Sentinel or export to your BI lake.
- Import Licenses and (optionally) Users for slicing.
- Deploy KQL detections #1–#3 and the license inactivity view.
- Build the correlation visuals (Risk vs Downloads, Adoption quadrant).
- Wire Power Automate for license reclaim + training nudges.
- Review weekly: top incidents, savings, training conversion.
Closing thought
You don’t need “more dashboards.” You need relationships between identity, activity, and usage—plus small automations that force follow-through. Start with the three correlations above; you’ll uncover waste, risks, and adoption gaps most tenants never see.