-
Notifications
You must be signed in to change notification settings - Fork 12
Monitoring System Performance
This page covers how to monitor the performance of the analytics engine itself: how quickly imports complete, how to compare today's run against historical baselines, and how to keep the SQL database from slowing down as it grows.
If you are looking for general health alerting (DTU%, CPU, "no recent data" rules, etc.), see the Monitoring page instead.
Each web-job runs on a continuous loop and re-imports data from the M365 audit/usage APIs into SQL. Two things tend to slow this loop down over time:
- Volume of incoming data - more users, more sites, more audit events per cycle.
- Size of the SQL database - merges, lookups and de-duplication queries get slower as tables grow.
If a single import cycle starts taking longer than 24 hours, the backlog from M365 will grow faster than the importer can drain it and data will start being missed. The goal of this page is to catch that trend early.
At the end of every Office 365 activity import cycle, ProgramTasks.DownloadActivityData writes a single Application Insights trace that includes the wall-clock duration:
Finished activity import. Time taken in = 12.43 minutes. Stats: ...
This is the easiest signal to track. Run this in your App Insights / Log Analytics workspace to see a time series of cycle durations:
traces
| where operation_Name == "Office365ActivityImporter"
| where message startswith "Finished activity import"
| extend MinutesTaken = toreal(extract(@"Time taken in = ([0-9\.]+) minutes", 1, message))
| project timestamp, MinutesTaken, message
| order by timestamp descRender it as a chart to spot the trend:
traces
| where operation_Name == "Office365ActivityImporter"
| where message startswith "Finished activity import"
| extend MinutesTaken = toreal(extract(@"Time taken in = ([0-9\.]+) minutes", 1, message))
| summarize avg(MinutesTaken), max(MinutesTaken) by bin(timestamp, 1d)
| render timechartIf avg(MinutesTaken) is creeping up week-on-week without a corresponding rise in tenant activity, that is the signal to investigate database performance (see section 4).
A healthy importer logs Waiting 2 mins... between cycles. If the gap between successive "Finished activity import" entries grows, the cycle itself is taking longer:
traces
| where operation_Name == "Office365ActivityImporter"
| where message startswith "Finished activity import"
| order by timestamp asc
| extend GapMinutes = datetime_diff('minute', timestamp, prev(timestamp))
| project timestamp, GapMinutesSeveral inner stages also log their own elapsed time. These are useful when a cycle is slow and you want to know which step is the bottleneck:
| Log message (contains) | What it measures |
|---|---|
Day {yyyy-MM-dd} completed in ...s |
A single day of App Insights data (page-views + events) |
API fetch completed in ...s |
Pulling raw data from the App Insights API |
Page-views SQL save completed in ...s |
Staging + merge of page-views (hits) |
Events SQL save completed in ...s |
Staging + merge of custom events |
Hits batch imported and merged in ...s |
Single hits batch end-to-end |
Search merge completed in ...s |
Searches merge step |
Startup: duplicate-hit cleanup completed in ...s |
Once-per-startup hit de-duplication |
Example query - average SQL save time per day:
traces
| where message startswith "Page-views SQL save completed"
| extend Seconds = toreal(extract(@"in ([0-9\.]+)s", 1, message))
| summarize avg(Seconds), max(Seconds) by bin(timestamp, 1d)
| render timechartIf Page-views SQL save or Hits batch imported and merged is the stage that is growing, the database is the bottleneck. If API fetch is the slow stage, the M365 / App Insights API is the bottleneck and the SQL cleanup below will not help.
Every 24 hours the UsageStatsManager snapshots the size of every table in the database and writes the JSON report to sys_telemetry_reports (the same payload that is optionally uploaded to Microsoft if telemetry is enabled, but it is always saved locally too).
The JSON includes a TableStats array - one row per user table - with TableName, Rows and TotalSpaceMB. This is the historical record you compare against.
Latest report:
SELECT TOP 1 submitted, report
FROM sys_telemetry_reports
ORDER BY submitted DESC;Diff the two most recent reports (row counts per table):
;WITH latest AS (
SELECT TOP 2 submitted, report
FROM sys_telemetry_reports
ORDER BY submitted DESC
),
parsed AS (
SELECT
submitted,
ROW_NUMBER() OVER (ORDER BY submitted DESC) AS rn,
j.TableName,
j.[Rows],
j.TotalSpaceMB
FROM latest
CROSS APPLY OPENJSON(report, '$.TableStats')
WITH (
TableName nvarchar(200) '$.TableName',
[Rows] bigint '$.Rows',
TotalSpaceMB decimal(18,2) '$.TotalSpaceMB'
) j
)
SELECT
n.TableName,
o.[Rows] AS PreviousRows,
n.[Rows] AS CurrentRows,
n.[Rows] - o.[Rows] AS RowDelta,
o.TotalSpaceMB AS PreviousMB,
n.TotalSpaceMB AS CurrentMB,
n.TotalSpaceMB - o.TotalSpaceMB AS MBDelta
FROM parsed n
LEFT JOIN parsed o ON o.TableName = n.TableName AND o.rn = 2
WHERE n.rn = 1
ORDER BY MBDelta DESC;Tables to watch closely - these are normally the largest and the first to cause slowness:
-
hits,searches,sessions- web-traffic -
audit_events,event_meta_sharepoint,event_meta_azure_ad,event_meta_exchange,event_meta_general -
audit_event_azure_ad_props,audit_event_exchange_props -
teams_channel_stats_log,teams_channel_stats_log_keywords,teams_channel_stats_log_langs,teams_channel_tabs_log,team_membership_log,teams_addons_log -
onedrive_usage_activity_log,onedrive_user_activity_log,outlook_user_activity_log,sharepoint_user_activity_log,teams_user_activity_log -
yammer_user_activity_log,yammer_group_activity_log,yammer_device_activity_log
The two queries above are complementary:
-
sys_telemetry_reportsdiff answers "is the database getting bigger?" -
App Insights
Finished activity importchart answers "is the importer getting slower?"
When both lines are climbing together, you are at the point where the cleanup script in section 4 should be run (or your SQL tier increased - see Monitoring section 2.4.4).
The repository ships with a maintenance script that archives data older than one month from the hits/audit/usage tables. As the dataset grows this is what you run to keep the importer fast.
Script: src/Clean Old Data Data.sql
It computes a single cut-off date - one month before "now" - and deletes everything older than that from:
| Area | Tables affected |
|---|---|
| Web tracking |
hits, then orphaned rows in searches and sessions
|
| Azure AD audit |
audit_event_azure_ad_props, event_meta_azure_ad
|
| Exchange audit |
audit_event_exchange_props, event_meta_exchange
|
| SharePoint audit | event_meta_sharepoint |
| General audit | event_meta_general |
| Teams |
teams_addons_log, teams_channel_stats_log, teams_channel_stats_log_keywords, teams_channel_stats_log_langs, teams_channel_tabs_log, team_membership_log
|
| Usage activity |
onedrive_usage_activity_log, onedrive_user_activity_log, outlook_user_activity_log, sharepoint_user_activity_log, teams_user_activity_log, yammer_device_activity_log, yammer_group_activity_log, yammer_user_activity_log
|
The cut-off is set at the top of the script:
declare @archiveDateMax datetime
set @archiveDateMax = dateadd(month, -1, GETDATE())Edit that line if you want to keep more (or less) history. For example, to keep 6 months use dateadd(month, -6, GETDATE()).
-
Stop the web-jobs first (or at least pause the importer) so that nothing is writing to the same rows while you delete.
-
Open the script in SSMS / Azure Data Studio against the analytics database.
-
Test inside a transaction first - the script ships with the transaction lines commented out for exactly this purpose:
begin transaction archive -- ... run the script body ... rollback transaction archive -- verify counts, then re-run with commit
-
Inspect the
session count before / after session clean:PRINToutput and run row counts against the affected tables. -
When happy, re-run with
commit transaction archive. -
Restart the web-jobs.
DELETE does not shrink data files and leaves index fragmentation behind. For the importer to actually go faster again, follow the delete with:
-- Rebuild indexes on the largest cleaned tables
ALTER INDEX ALL ON hits REBUILD;
ALTER INDEX ALL ON sessions REBUILD;
ALTER INDEX ALL ON searches REBUILD;
ALTER INDEX ALL ON audit_events REBUILD;
ALTER INDEX ALL ON event_meta_sharepoint REBUILD;
ALTER INDEX ALL ON event_meta_azure_ad REBUILD;
ALTER INDEX ALL ON audit_event_azure_ad_props REBUILD;
ALTER INDEX ALL ON event_meta_exchange REBUILD;
ALTER INDEX ALL ON audit_event_exchange_props REBUILD;
ALTER INDEX ALL ON event_meta_general REBUILD;
-- Refresh statistics so the query planner picks the right plans
EXEC sp_updatestats;On Azure SQL Database
DBCC SHRINKDATABASEis generally not recommended - it increases fragmentation. Trust the index rebuild + autogrowth headroom instead.
Once you have run the script manually a couple of times and are confident in the retention window you want, schedule it. Options:
- An Azure SQL Elastic Job or SQL Agent job (if your tier supports it).
- A small Azure Automation runbook invoking
Invoke-Sqlcmdagainst the database. - A scheduled Azure Function with a timer trigger.
Running it weekly is usually enough; running it more often than the importer cycle gains nothing.
For the rarer one-off cases, the repo also ships:
-
src/Clean Data By User StoredProc.sql- delete all activity associated with a specific user (e.g. for GDPR / right-to-be-forgotten requests). Not a performance tool. -
src/AnalyticsEngine/Common/Entities/Migrations/Clean Duplicate Urls.sqlandClean Duplicate Sessions.sql- one-off de-duplication scripts that were used when fixing past bugs. Only run these if you are following specific guidance for a known issue.
| Symptom | First check | Action |
|---|---|---|
| Import cycle taking longer over time | KQL Finished activity import trend (2.1) |
Compare sys_telemetry_reports (3.1); run Clean Old Data Data.sql (4) |
Page-views SQL save / Hits batch ... merged slow |
Per-stage timings (2.3) | DB-bound - cleanup + index rebuild (4.3) |
API fetch slow |
Per-stage timings (2.3) | Not a DB issue - check M365 / App Insights API throttling |
| DTU% pinned at 100% | Azure Portal SQL metrics | Upscale DB tier (see Monitoring 2.4.4) |
| Cycle interval > 24h | KQL gap query (2.2) | Cleanup + upscale - data loss risk |
- Home
- What data is collected
- The web portal
- Licence activity
- Copilot data & stats
- Architecture & costs
- App registrations setup
- Install with the installer
- Manual installation
- Private endpoints (optional)
- Certificate authentication (optional)
- Enable CSP for AITracker
- Verify the deployment
- Legacy SPO web setup