Conversation
The Copilot importer already deserialised a set of CopilotInteractionAuditRecord fields and then threw them away - they were never written to SQL. Persist them. No new API, permission or import-cycle cost: the data is in payloads we already download. Now stored: * CopilotEventData.ThreadId, ClientRegion, CopilotLogVersion -> copilot_chats * ALL Contexts (Id/Type/ContainerId) -> new copilot_event_contexts + copilot_event_context_types. Only the first file/meeting context is resolved into copilot_event_files/_meetings; the rest of this unordered collection was lost. That resolution behaviour is unchanged. * AISystemPlugin (Id/Name/Version) -> new copilot_ai_system_plugins + junction * AccessedResources[].Action -> new copilot_event_accessed_resource_actions lookup + action_id; .listItemUniqueId -> list_item_unique_id_id, resolved against the EXISTING resource-id dimension (the payload repeats Id there) * Messages[].Size / .isPrompt -> copilot_event_messages. Prompts are no longer filtered out: Size only exists on the prompt row and is_prompt would be a constant otherwise. Roughly doubles that table. * ModelTransparencyDetails provider/version -> copilot_ai_models, whose key becomes the (name, provider, version) tuple No backfill is possible - Management Activity API content is retrievable for 7 days - so this applies to newly imported interactions only. The accessed-resource de-dup tuple is deliberately NOT widened: it is covered exactly by IX_copilot_event_accessed_resources_dedup, so the batch collapse moved into the resolve step (GROUP BY the original 5 columns + MIN on the new payload columns) and insert_junction no longer sorts at all. Also fixes copilot_event_messages having no de-duplication: an interaction that stages into two staging tables (Teams chat context + a following file context) inserted its messages twice. Measured at synthetic scale (1M chats / 3M junction rows, 1000-event batch, plan cache cleared, medians of 8 warm runs): logical reads 438,673 -> 564,582 (+29%), elapsed 1,780 -> 1,602 ms. insert_junction 328 -> 314 ms, link_ai_models 505 -> 125 ms. Junction index builds at 3M rows: 5.6 s / 81 MB for the pair. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Users with no Exchange Online mailbox - unlicensed, on-premises, inactive, or guest (#EXT#) accounts - return HTTP 404 from Graph on every sent-email call, permanently. Each such user was producing three App Insights exception records on every 10-minute import cycle, and was re-checked indefinitely. Three changes: 1. A 404 is now a typed, non-error outcome. ManualGraphCallClient throws GraphResourceNotFoundException (derived from HttpRequestException, so existing catch blocks are unaffected) and logs at Debug rather than Error, exposing Graph's error code (MailboxNotEnabledForRESTAPI, Request_ResourceNotFound) for diagnostics. 2. PageableGraphLoaderExtensions no longer double-logs. It was calling LogError twice for an exception ManualGraphCallClient had already logged, turning one failed call into three exception records. It now logs only the paging consequence, at Warning. Gains an opt-in throwOnNotFound flag (default false, so every existing caller keeps its partial-result behaviour) which the sent-email loader uses to tell "no mailbox" apart from "mailbox with no sent mail" - previously indistinguishable, as the 404 was swallowed and an empty list returned. 3. SentEmailImporter negatively caches mailbox-less users and skips them, re-sweeping the whole directory every SentEmailNoMailboxRetryHours (new AppSetting, default 24; 0 disables) so newly-licensed users are picked up. Backed by Redis when configured, otherwise in memory, and hoisted to process lifetime in Program.cs alongside the existing cadence stores. Deliberately a single key holding the whole set, not one key per user, so it stays O(1) round trips at the 200k-user target scale. No behaviour change for other Graph importers, no schema or migration change, and no CONFIG_VERSION bump (AppConfig is runtime app settings, not the installer's saved config schema). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
The telemetry dashboard failed with "Could not load dashboard data: stats:
HTTP 403" for users holding a completely valid token - correct audience,
correct issuer, and both the Telemetry.Dashboard.Read role and the
Telemetry.Read scope present.
The 403 came from App Service Authentication, not the application. The
authsettingsV2 validation block set allowedAudiences but no
defaultAuthorizationPolicy, so the platform stored
allowed_client_applications as an EMPTY array - which EasyAuth reads as
"permit nothing". It authenticated the caller and then rejected the request
before it ever reached Kestrel.
The symptom is badly misleading and worth recording:
- anonymous requests SUCCEED (unauthenticatedClientAction is AllowAnonymous),
so /health and /api/auth/config kept returning 200;
- a token with an INVALID signature also succeeds, because it fails
validation and is therefore treated as anonymous and passed through to the
app, which then answers with its own 401;
- a VALID token is the only thing that gets a 403.
So every conventional check - app role assignment, admin consent, audience,
issuer, tenant, token expiry, and the ASP.NET Core authorization attributes -
looks correct, because all of them are correct. An isolated reproduction of the
exact production auth pipeline (same .NET 10, same Microsoft.Identity.Web
4.14.2, same AzureAd settings, same [Authorize(Roles)] + [RequiredScope]
attributes) returns 200 for the very token that production rejects.
Fixed by naming the SPA client explicitly in allowedApplications. This is also
tighter than leaving the policy null, which would admit any client application
able to obtain a token for this audience.
EasyAuth remains enabled and non-enforcing, so the MISE key-discovery telemetry
required by SFI ID2.1.1 / ID2.1.2 continues to be emitted and the app
registration stays compliant.
azuredeploy.json is recompiled from the bicep. The local Bicep CLI was upgraded
to 0.46.1 first, because building with the older 0.36.1 silently downgraded the
nested deployment apiVersion from 2025-04-01 to 2022-09-01.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
The EasyAuth / MISE runtime caches the auth configuration, so a corrected allowedApplications value is not honoured until the site restarts. Until then the config APIs (az webapp auth show, the ARM authsettingsV2 GET) all report the new value while requests are still being rejected using the old one - so a correct fix looks like it did not work. Observed while remediating the empty allowed_client_applications allow-list: the setting read back correctly from both the v1 and v2 config views, yet the dashboard kept returning 403 until the App Service was restarted. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
The Application Insights resource for the telemetry service had received nothing since the service was first deployed - no requests, traces, dependencies or exceptions - even for requests that demonstrably reached the application and returned 200. The connection string was configured correctly and the ingestion endpoint was reachable from inside the container (a GET to /v2/track returns 405, i.e. the network path is fine). Nothing was reading either. Two gaps: 1. The application referenced no telemetry SDK at all - no Microsoft.ApplicationInsights.AspNetCore, no OpenTelemetry - and Program.cs never wired one up. 2. The infrastructure set ApplicationInsightsAgent_EXTENSION_VERSION=~3, which is the *Windows* App Service codeless-attach setting. This site runs on Linux (DOTNETCORE|10.0), where that setting is silently ineffective for .NET. It made the site look instrumented while guaranteeing silence. Fixed by adding Azure.Monitor.OpenTelemetry.AspNetCore and calling AddOpenTelemetry().UseAzureMonitor(), guarded on the connection string being present so local development stays quiet. The misleading agent setting is removed from the bicep, with a comment explaining why it must not come back. Note this needs an application redeploy to take effect - correcting the app settings alone will not start telemetry flowing. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
The dashboard rendered everything on one page: four headline cards, then the full aggregated table list, with the clients table stranded below it. On a real install base the table list is long enough that the clients table is effectively undiscoverable, and the styling did not match the in-product admin app. UI - Rebuilt on Fluent UI v9, matching src/AnalyticsEngine/Web/Scripts/admin-app: brand header, TabList navigation, same content width and tokens. The bespoke App.css is gone so the two apps cannot drift apart. - Content is split across Overview / Tables / Clients / Adoption, each lazy-loaded as its own chunk (main bundle 785 kB -> 534 kB). - Tables and Clients are filterable and sortable; Clients shows relative "last report" times, highlights stale installs and lists each client's enabled imports in a popover. - Added a Refresh button, proper loading and error states, and a distinct empty state for "no telemetry received yet". New statistics, all derived server-side from telemetry clients already send, so no client-side change is required to populate them: - Reporting freshness: clients bucketed by last check-in (24h / 7d / 30d / stale), which surfaces installs that have silently stopped reporting. - Deployment size distribution: median, average and largest client by both rows and size, plus average table count. Averages alone hide the shape of the install base. - Build adoption: clients per build version, with last-seen. - Import feature adoption: per-toggle enabled/disabled counts, parsed from each client's ConfiguredImportsEnabledDescription. - Storage by SQL schema, so application tables can be told apart from profiling ones. - Azure AI usage: total data points and how many clients report them. - Distinct table count, and average rows per client per table. Two deliberate choices in the aggregation: - Table totals are now keyed on schema + table rather than table name alone. The same table name can legitimately exist in two schemas, and merging them would silently overstate both. - Feature adoption percentages are of clients reporting that toggle, not of all clients. A newly added import is absent from older builds rather than reported as off, so the alternative would make new features look unpopular. Version labels and schema names are grouped under "(unknown)" when a client predates the field rather than being dropped, so counts still reconcile. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
The telemetry service had no tests and no pipeline: it was built and deployed by hand, and the dashboard aggregation (medians, settings parsing, freshness bucketing) was entirely unverified. Tests - New Tests.Unit (MSTest, net10.0), registered in TelemetryService.slnx: 58 tests covering dashboard aggregation, the settings-string parser, the upload endpoint's signature checks, save/merge behaviour and configuration binding. Aggregate now takes an injectable nowUtc so freshness bucketing is deterministic instead of depending on when the suite runs. - Vitest + Testing Library for the client: 22 tests covering the formatting helpers and the sortable table. Fluent UI has to be inlined in the Vitest config because @fluentui/react-icons ships extensionless ESM chunk imports that Vite's node resolver cannot follow. Two real defects were found by writing the tests: - Version adoption sorted "(unknown)" ahead of a real build on a tie, so a bucket that is not a version could present as the most common one. Unknown is now deliberately ordered last. - TelemetryController guards against an empty TelemetrySecret, but that value is a required config binding, so WebAppConfig already refuses to construct without it. The guard is unreachable through normal startup; the test now documents it as defence-in-depth rather than pretending it is a live path. Refactoring - Program.cs is now just an entry point. DI composition moved to TelemetryServiceCollectionExtensions so tests can build the real object graph without duplicating the wiring, and Program is made partial so integration tests can host it with WebApplicationFactory. - Cosmos container creation moved out of the startup path into a hosted service. It previously ran before the host was built, so an unreachable or misconfigured Cosmos account stopped the site starting at all - including the anonymous /health endpoint, which makes an outage look like a failed deployment. - vite.config.ts generated ASP.NET dev certificates at module scope, so every production build - including in CI, where there is no dev certificate and no need for one - shelled out to `dotnet dev-certs`. Certificate handling now only happens when actually serving. CI/CD - New telemetry-service workflow, kept separate from the existing ones because those build the .NET Framework solution on Windows and publish GitHub releases, whereas this is a .NET 10 Linux web app deployed to App Service. - Lints and tests both halves on every PR; deploys on pushes to main and dev. - dotnet publish also builds the Vite client into wwwroot, so one artifact is the whole site. - After deploying it polls /health, so a build that deploys but fails to start is reported as a failed deployment rather than a success. - Azure sign-in uses OIDC federated credentials, so no secret is stored. The app name, resource group, tenant and subscription all come from repository secrets/variables - no environment identifiers are committed to this public repository. - The deploy job cannot run from a pull request or from a fork. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
ci, pr and tests all build the .NET Framework AnalyticsEngine solution, but triggered on any change under src/**. Now that src/ also holds TelemetryService, a telemetry-only change kicked off a full Windows release build, an installer build and the AnalyticsEngine test suite for no reason. Narrowed all three to src/AnalyticsEngine/** and their own workflow file. reports/** stays, because the Power BI templates ship as part of that release. ci also triggered on .github/**, so any change to a workflow, an agent definition or the Copilot instructions started a full release build. That is now just .github/workflows/ci.yml, which is the file that actually changes what the release produces. tests.yml has three filters, not one: the push paths, the pull_request paths and the dorny/paths-filter "code" filter that gates every job. All three are updated, otherwise the workflow would still start and then evaluate the old filter. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
azuredeploy.json conflicted because #264 and this branch each recompiled it from their own bicep. Resolved by regenerating the template from the merged resources.bicep rather than hand-merging generated output, so it now carries both the EasyAuth allowedApplications fix and the removal of the Windows-only ApplicationInsightsAgent_EXTENSION_VERSION setting. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Program.cs conflicted because #265 added the in-process Application Insights wiring while this branch slimmed Program.cs down to an entry point. Both are wanted, so the resolution keeps the App Insights block inside the slim file. Azure.Identity is no longer imported here - the Cosmos credential moved to TelemetryServiceCollectionExtensions - so only the OpenTelemetry using remains. Web.Server.csproj auto-merged and retains both the Azure.Monitor.OpenTelemetry package reference and the InternalsVisibleTo needed by the test project. Verified after resolving: 58 server tests and 22 client tests pass. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Resolves the workflows README conflict: keeps the AnalyticsEngine scoping note from this branch and the telemetry-service section added by #267, and reworks the intro because there are now four workflows and telemetry-service is deliberately not AnalyticsEngine-scoped. Also bumps the actions in telemetry-service.yml. The first pipeline run warned that checkout, setup-dotnet, setup-node and upload-artifact all target Node 20 and were being forced onto Node 24. Moved to the current majors (checkout v7, setup-dotnet v6, setup-node v7, upload-artifact v7, download-artifact v8), which also brings the file in line with the versions the other workflows already use. azure/login and azure/webapps-deploy were not flagged and are left alone. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
The previous run's annotation flagged azure/login@v2 as targeting Node 20 and being force-run on Node 24. Azure publishes v3 specifically for Node 24 support, with the same inputs, so this is a straight major bump. azure/webapps-deploy@v3 was not flagged and is already current, so it is left alone. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Sam Betts <sambetts@users.noreply.github.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…282) Co-authored-by: Sam Betts <sambetts@users.noreply.github.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…260) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: Sam Betts <sambetts@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: Sam Betts <sambetts@users.noreply.github.com>
Multi-model review of this releaseThree models reviewed the full
I verified every finding against the code rather than relaying it. Two did not survive that check. The table below is my adjudication, not a merge of their opinions.
1. Graph failures are recorded as successful empty imports — openThe most valuable finding of the exercise, and no amount of unit testing would have surfaced it.
logger.LogWarning($"Unexpected HTTP error on page {pageCount}: {ex.Message}. " +
"Will not retry page & returning results upto current page.");
nextUrl = null;It returns the rows accumulated so far and does not throw. For the Copilot reports that means a 403 from a missing The existing shape-change guard does not catch it either, because it is conditioned on if (reports.Count > 0 && parsed.Count == 0) { throw ... }So a permissions misconfiguration, a 5xx mid-paging, or an exhausted 429 all present to an admin as "you have no Copilot licences". That is exactly the misleading-symptom class this project's release notes are supposed to call out. Suggested fix: a strict paging mode that rethrows HTTP failures, used by all three Copilot reports, with the deliberate 404/non-global-cloud case handled separately and recorded in 2. Accessed-resource actions are collapsed, and pairings can be fabricated — open
MIN(raction.id) AS action_id,
MIN(rlistitem.id) AS list_item_unique_id_idTwo consequences:
The in-code comment justifies this by keeping the dedup identity unchanged so Not a crash and not loss of pre-existing data, so defensible for a test release — but it should not reach stable without either widening the tuple or an explicit decision that the fidelity loss is acceptable. 3.
|
…eployment (#289) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
… only) (#291) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
… users (#292) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Generate O365 audit events plus SharePoint, OneDrive, Outlook, and Teams profiling sources, with a combined option that shares users and an exact date window with Copilot data. Align Copilot app hosts with profiling and include the full Sunday boundary in weekly aggregation. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 07839e7d-91ef-4eaf-8fcb-a6100194a4d4
…p, Graph paging order, and 7 more (#293) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…ing, not a precondition (#298) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
… a stronger upgrade rehearsal (#299) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…g breaking install verification (#310) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…chart description (#314) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 5fdf889f-b05e-4e31-b213-787104ddfde9
…rectness and scale fixes (#315) Follow-up work on the Copilot Adoption area (/copilot-adoption), on top of the original tool in #292. Explainability and capability - "i" info tips on every assertion: what it claims, how it is worked out, the formula, the source. - Capability parity with microsoft/AI-in-One-Dashboard: agents inventory with Keep/Review/Retire/New verdicts, unlicensed population, usage concentration, combined leaderboard, three-population trend. - Executive visuals and a 12-sheet Excel export with live charts, written as dependency-free OpenXML. - Overview restructured into named sections; enablement-plan rows drill through to the exact people. Correctness - NaN% methodology tab: CopilotAdoptionOptions was the only model without camelCase JsonProperty names. - "Invalid column name 'department'": dbo.users has department_id, not department (4 occurrences). - Rates divided capped counts by the uncapped licence count, so a 200k tenant could never report adoption above 25%. ScoredUsers is now the denominator. - Agent interactions-per-user divided a 120-day numerator by a 28-day denominator, inflating it ~4x. - "Last N days" started N days back, spanning N+1 dates, so the numerator's window was wider than the denominator's target. WindowStartUtc now sits beside TargetActiveDays so they cannot drift. - Agent user count could be inflated by unattributed (NULL user) audit events, flipping an agent's verdict from Review to Keep. Scale - the report was not slow at 200k users, it was broken Four queries asked for several COUNT(DISTINCT ...) in one GROUP BY. SQL Server streams a single distinct aggregate cheaply; two or more force a spool. All four exceeded the 90s command timeout, and the failure mode is a warning on the page rather than an error, so it was invisible. Measured on a synthetic 200,000-user / 12M-interaction tenant, medians of 3 runs discarding the cold run: LicensedUsersSql 28d 281s -> 73s 114.8M -> 772k logical reads LicensedUsersSql 365d 303s -> 135s 115.2M -> 819k AgentUsageSql 128s -> 23s 6.4M -> 2.7M UnlicensedUsageRows 131s -> 54s 8.4M -> 44.1M (reads regress, see PR) WeeklyAdoptionTrend 315s -> 38s 24.5M -> 58.1M (reads regress, see PR) No schema change, no migration, no new index. Every rewrite was gated by an old-vs-new row-for-row comparison before performance was considered: 33,999 / 200 / 33,999 / 27 rows, zero differing. Two of the rewrites trade more logical reads for much less elapsed time; that trade is documented in the PR rather than hidden, and points at the pre-aggregated rollups in the wiki backlog. Telemetry - One CopilotAdoptionAnalysis App Insights event per analysis (not per request), with per-step durations as measurements so they can be percentiled directly. Dimensions carry Outcome, TimedOut and SlowestStep so a degrading report is alertable. No tenant data. Terminology and docs - "Seat" replaced with "licence" in everything a user reads; identifiers, JSON names and SQL aliases deliberately unchanged so the wire contract holds. - Wiki: spec, user guide, measured-at-scale findings, telemetry/monitoring queries, and the FakeDataGen adoption-persona scenario. Test data - Tests.FakeDataGen can now shape a tenant into adoption personas covering every funnel stage and several distinct engagement shapes, verified against the real scoring code at run time. Copilot adoption suite 114/114. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 87347b97-407c-4dbe-bae5-e8880d0fc77d
Release candidate:
dev→main.Base
main@5ff6327→ headdev@a936caf(merge-base2ada302).main's extra commits are prior release merges and contribute no tree difference, so two-dot and three-dot diffs are identical.31 squash-merged PRs, 210 files, +37,032 / −3,531, 44 commits (the remaining commits are older-style branch merges and a few direct pushes). This body is kept current as PRs land; it describes the release as it now stands rather than as it was first opened. Last updated for #314.
Closes #270, closes #271, closes #272, closes #276, closes #278, closes #286, closes #290, closes #295, closes #297, closes #302, closes #285, closes #287, closes #294, closes #296, closes #312.
(The first ten were closed by hand on 2026-08-21 once their fixes reached
dev; the keywords are kept so the association is recorded on merge tomain.mainis the default branch, so these fire here — they would have been inert in thedev-targeted PRs.)1. What is in this release
UserGroupsFilterbecomes an optional narrowing rather than a precondition; unnarrowed user selection moved into SQLTests.FakeDataGen: generates Copilot prompt history and the new audit tables; stronger upgrade-from-stable rehearsal[Ignore]d repro test kept in place)copilot_chats→audit_events(#295)keyword_idindex for the orphan-keyword cleanup (#296)errorcolumn, and hardens the de-dup fail-open sentinel against aSqlDateTimeoverflowerrorcolumn; three documentation errorsTests.FakeDataGen), pushed directly todevVerified against the diff, not the PR text
CONFIG_VERSIONends at 2.1.0BaseSolutionInstallConfig.csondevreads"2.1.0".manual.sql.cs+ 7 matching.manual.sqlin themain...devdiff (1:1, verified by name)Create DB.sqlneeds no changecopilot_chatsfrom 2024. It is a legacy base schema; every Copilot table has always come from a migration. This is verified, not an omission.GraphCopilotUsageReports,CopilotInteractionHistory; removedGraphUserAppsThe break is the removal of
ImportTaskSettings.GraphUserApps. Older configs still load — the property is simply ignored.2. Migrations
Seven new migrations — four additive (bar the conditional drop in
DeprecateTeamsAddons), plus three index migrations from #307. Each has its own.manual.sql.Note the diff also shows an eighth migration file,
202607231700001_CoverCopilotAccessedResourceDedup.cs, as modified. That migration already shipped in the previous stable release; #309 changed only its XML doc comment (to record that #307 supersedes its de-dup tuple, and that upgrading from before it builds the index twice). ItsUp_Sql/Down_Sqlare byte-identical, so no customer database is affected and no new manual script is required. Verified: the diff for that file contains no non-comment lines.202608190622001_CopilotDroppedAuditFieldscopilot_event_accessed_resources202608190725064_AddCopilotUsageReportsdbo.usersfor the FK202608191533567_DeprecateTeamsAddonsEXISTStest, notCOUNT(*), so O(1)-ish even on a billion-row table202608200600001_AddCopilotInteractionHistory202608210700001_WidenCopilotAccessedResourceDedupIndexIX_copilot_event_accessed_resources_dedupwithaction_id+list_item_unique_id_idas key columns (#287)CopilotDroppedAuditFieldsindex pair202608210700002_IndexCopilotInteractionKeywordsByKeyword(keyword_id, interaction_id)on the Copilot keyword link table (#296)202608210700003_IndexCopilotInteractionsDedupWindow(session_id, created_utc) INCLUDE (graph_interaction_id)(#294)Gate classification (schema-performance rule)
Stating this explicitly, because an unclassified migration reads as an unmeasured one:
AddCopilotUsageReportsAddCopilotInteractionHistoryDeprecateTeamsAddonsCopilotDroppedAuditFieldsNULLon 100% of existing rows (no backfill), so there is no "before" query. Build time + storage supplied, plus a no-regression check (insert_junction328 → 314 ms). Classified not performance-motivated; needs conscious sign-offWidenCopilotAccessedResourceDedupIndexINCLUDEvariant documented (fewer reads, 5.5x slower)IndexCopilotInteractionKeywordsByKeywordIndexCopilotInteractionsDedupWindowNon-migration SQL in gate scope:
common_upsert_copilot_agents.sqlcarries a performance-motivated de-dup re-key (1.8x fewer reads / 5.7x faster, §8) and, from #307, the full-tuple de-dup correctness fix.Maintenance window — the only part that can cause an outage
CopilotDroppedAuditFieldsbuildsIX_..._action_idandIX_..._list_item_unique_id_idoncopilot_event_accessed_resources, one of the larger tables on a Copilot-heavy tenant. Measured at synthetic scale (offline build, buffer pool dropped, medians of 3) on a 3,000,000-row junction table:IX_..._action_idIX_..._list_item_unique_id_idThe build attempts
ONLINEon Enterprise (EngineEdition 3), Azure SQL DB (5) and Managed Instance (8), falling back to offline elsewhere. On Standard/Express/Web the build is offline and locks the table, so those tenants should upgrade in a maintenance window with the importer stopped.copilot_event_accessed_resourcesis now touched twice — the FK index pair above, and the dedup index rebuild in202608210700001_WidenCopilotAccessedResourceDedupIndex. The other five migrations create new/empty tables and need no window.copilot_event_accessed_resourcesThe "plan for" column is deliberately more pessimistic than the arithmetic. The per-million figures are linear extrapolations from a 2M-row measurement, and index builds are O(n log n) with a sort that will spill to tempdb well before 100M rows — so budget 2–3x the linear estimate and make sure tempdb has room.
Storage. The FK pair adds ~27 MB per million rows. The widened dedup index is 83 MB per 2M rows gross, but it replaces the existing 6-key index (~69 MB), so the net addition is only ~7 MB per million. At 100M rows: ~3.4 GB net. Two things that figure does not cover, and which an admin must have free anyway:
Apply the database migrations before or with the binaries — never run the new importer against a pre-migration schema. The merge SQL is an embedded resource in the importer and the bounded de-dup read is C#, but both of their supporting indexes arrive via migration. Because the manual-SQL path deliberately allows schema and binaries to be applied separately, "new binaries, old schema" is a reachable state — and by this release's own measurements it is slower than the version being upgraded from: the full-tuple merge against the un-widened index measured 530 ms vs 85 ms (5.6x slower, seek lost), and the bounded de-dup read without its index measured 337 ms vs 45 ms (7.5x slower, seek lost). Both recover completely once the migrations are applied.
2.1 Three snapshot defects found and fixed before merge
#260, #279 and #291 were each authored before the migrations that ended up preceding them, so their
.resxmodel snapshots predated the branch they merged into. That is issue #271 exactly, and it is why #282 exists.TargetbeforeAddCopilotUsageReportsDeprecateTeamsAddonsAddCopilotInteractionHistoryReproduced before fixing, on a scratch merge with the LocalDB test database dropped and the solution rebuilt:
devalonedev+ #260 as authoredThere is already an object named 'copilot_event_accessed_resource_actions' in the database.dev+ #260 repaired#291's stale snapshot failed differently and more loudly —
AutomaticMigrationDataLossExceptionat context construction. Its regenerated snapshot was verified by decompressing both and diffing entity sets: the delta is exactly the 10 interaction-history entities, with nothing removed (no Teams add-on entity resurrected, no usage-report entity dropped).#291's migration was also re-timestamped from
202608191347242to202608200600001, because the original sorted before the already-merged202608191533567_DeprecateTeamsAddons— i.e. it would have inserted itself into the middle of applied history.2.2 The manual scripts were stale too — reviewer hotspot
The least obvious part of the release.
.manual.sqlscripts stampdbo.__MigrationHistorywith an embedded copy of the model snapshot. Re-scaffolding the.resxleft those embedded blobs pointing at the old model, so a DBA upgrading by hand would have stamped an out-of-date model and EF would then have generated an automatic migration at runtime — reintroducing the same failure on hand-upgraded databases only. The installer path would never have shown it.CopilotDroppedAuditFieldsAddCopilotUsageReportsDeprecateTeamsAddonsAddCopilotInteractionHistoryThe regenerated blobs were executed against SQL Server and confirmed to assemble to the correct
DATALENGTH(41,823 and 40,748 bytes). Two prerequisites were also corrected:AddCopilotUsageReports' (#284) andAddCopilotInteractionHistory' (fromIndexReportDateQueriestoDeprecateTeamsAddons).3. Performance KPIs
Required by the repo's "prove every schema change improves performance before it is approved for stable" rule.
Benchmarked the hot path of the new import: the per-batch existence lookup in
CopilotUsageUserDetailLoader.SaveBatchAsync, which reads back already-stored rows so unchanged rows are not rewritten. It runs once per 1,000-user batch — 200 times per import at the 200,000-user baseline.Synthetic scale: 200,000 users × 15 daily report refreshes × 2 periods (D7 + D28) = 6,000,000 rows. Scattered user ids, since report order and
users.idorder are uncorrelated on a real tenant. Medians of 6 runs, first discarded,OPTION (RECOMPILE).SELECT *because the loader materialises full entities. All data synthetic — no customer database was involved.Read path —
IX_date_user_id_report_period_daysThe realistic row is the one that matters: one Graph report call covers a single period and returns a single
reportRefreshDate, soreportDatesandperiodseach hold exactly one value per batch.Projected per import at 200,000 users (200 batches): ~126 s → ~3 s, and ~24.1M → ~1.2M logical reads.
The wide-window regression is real and deliberately reported. At 30,000 matched rows the key lookups (the index does not cover the ~23 columns EF materialises) cost more than a scan. This loader never issues that shape, but anything that later widens the date range — a backfill importing many refresh dates at once — would hit it and would need a covering index. Recorded here so that is a conscious decision rather than a surprise.
Write path
The index costs ~0.8 s of write time per full import and saves ~123 s of read time. It is also the natural-key uniqueness constraint, so it has to exist regardless.
Index build: 6.3 s on 6M rows, 151.2 MB. The table starts empty on every existing customer, so the build at upgrade time is instant.
These are SQL-side figures; they exclude EF and network round-trip overhead. The delta attributable to the index is what is measured, and that is what the decision rests on.
4. Upgrade and combined verification
Tests.UnitTests/UpgradeFromStableTests.csrehearses the upgrade an existing customer actually performs, rather than starting from an empty database:main's newest migration (202608131055001_IndexReportDateQueries).Καλημέρα κόσμε) to catch Unicode truncation.dev.Both branches of
DeprecateTeamsAddons' conditional drop are covered, which an empty-database test can never reach:#291 and #292 had never been tested against each other, so the merged tree was verified directly at
e93630b: full solution builds (Debug + Release), and 94/94 pass across the migration-pipeline, migration-cleanup, interaction-history and adoption suites against a dropped-and-replayed LocalDB — so the whole chain reaches202608200600001with no snapshot mismatch. Their only file overlap wasEntities.csprojandTests.UnitTests.csproj; git merged both cleanly and the build confirms it. AlldevCI is green at that SHA.5. CI changes
#280 fixes two independent defects that meant required checks frequently never reported, leaving PRs at
BLOCKEDand mergeable only by admin bypass:pull_requestlistened only forready_for_review, which a PR opened non-draft never emits.paths:filter meant a PR touching onlysrc/TelemetryService/**never started the workflow at all — and a workflow that never starts reports nothing.The gate is applied per step, not as a job-level
if:, because GitHub evaluates a job-levelif:before expanding the matrix: a skippedtest_dotnetreports as plaintest_dotnetand the requiredtest_dotnet (Release)context never appears. Measured on #280 itself.test_aitrackerwas also gated onsrc/AnalyticsEngine/**rather than the tracker's ownsrc/SPO/AITracker/**, so its tests never ran when the tracker changed.#282 adds the migration snapshot guard that would have caught all three defects in §2.1 on the pull request. #289 adds a weekly read-only
az deployment group what-ifdrift check (maintainer-side only; deploys nothing).6. New user-visible and privacy surfaces
Two things in this release are not bug fixes and need their own treatment in the customer-facing release notes:
#291 — Copilot AI interaction history (opt-in). This is the first feature that sends customer Copilot prompt text out of the tenant, to Azure AI Language, and only when cognitive services are configured. No prompt or response text is persisted — bodies are reduced to counts and discarded, no column can hold one, response bodies are kept out of logs, and Copilot responses are never scored. But "nothing is stored" is not "nothing leaves the tenant", and the notes must say so plainly.
It is off by default and is capped per cycle — because the endpoint is one HTTP call per user with no tenant-wide or delta form, so an unscoped run at the 200k-user baseline would be 200k Graph calls per cycle. It also needs
AiEnterpriseInteraction.Read.All(application, no delegated form), which the installer does not grant: missing admin consent is the most likely reason for this import to silently do nothing.#292 — Copilot licence adoption tool. New admin API (
CopilotAdoptionAPIController) and admin-app pages for finding unused seats and unlicensed heavy users. Read-only and additive: no EF model change, no migration, no config-schema change; it queries existing tables via a context factory.#288 also fixed a live authorisation defect on the maintainer telemetry service: the
Telemetry.Readscope was never enforced, because[RequiredScope]is inert metadata whose handler was never registered, and[Authorize(Roles = …)]replaces the default policy rather than extending it. A token with the correct role but the wrong scope returned 200 before, 403 after. Exposure was limited — the app role is assignment-gated and EasyAuth validates audience andallowedApplications— so this was a missing defence-in-depth layer, not an open door. It affects the maintainer dashboard only, not customer deployments.7. Risks and reviewer hotspots
.resxTarget.copilot_event_accessed_resourcesis rebuilt twice (FK index pair, then the dedup index), and the other five migrations are instant — so it is easy to under-budget the window. See the sizing table and the storage/log notes in §2.NOT EXISTSwill back-fill rows that were previously collapsed away — socopilot_event_accessed_resourcesrow counts will step up after the upgrade. That is the fix working, not double-counting, but it should be said out loud in the admin notes. The growth is unquantified: the original justification for collapsing ("Action isReadfor every access, so nothing real is lost") was removed rather than tested, and the benchmark holds the resolved-row count constant, so it measures cost per row and not the change in row count.CONFIG_VERSION2.1.0, with a breaking step at 2.0.0. Confirm the removal ofGraphUserAppsdegrades gracefully for existing saved configs.DeprecateTeamsAddonsis conditional. A tenant with add-on data keeps the tables and the reporting views; a fresh install loses them. Two shapes in the field from one migration.test_dotnet (Release)now runs on every PR and does nothing (~10 s) whensrc/AnalyticsEngine/**is untouched. Deliberate: the alternative is unenforceable required checks.8. Multi-model review outcome
This release has been through two independent multi-model reviews.
Round 1 — the diff as originally opened
Three models (Claude Opus 4.8, GPT-5.6 Sol, Gemini 3.1 Pro) reviewed it independently. Every finding was verified against the code; one did not survive that check.
Fixed and included here (#284):
AddCopilotUsageReports.manual.sqlnamed the wrong prerequisite, letting a hand-upgrading DBA skip a migration and still pass the gate.CopilotUsageUserDetailLoaderdropped unkeyable rows with an O(N²)RemoveAtloop.Rejected: a reported
.ToLowerInvariant()rule violation inSentEmailImporter— those values are persisted, not just used as set keys, so removing them would change stored data.Round 2 — #288, #289, #291 and #292
Those four were added after round 1, so they were reviewed separately by four models (Claude Opus 4.8, GPT-5.6 Sol, Gemini 3.1 Pro, Grok 4.6), alongside a synthetic-scale benchmark of the interaction-history dedup path. Two findings did not survive verification and were dropped.
It found four release blockers, all fixed in #293, now merged into
dev:copilot_usage_user_activity_logis keyed(date, user_id, report_period_days), so every licensed user was duplicated per stored period — adoption figures inflated up to 4x, able to exceed the licensed population--result-format FullResourcePayloadsinto a world-readable job summary on a public repo — would have printed the Application Insights connection stringAiEnterpriseInteraction.Read.Allconsent silently did nothing for 24hPlus seven correctness/scale fixes, including a dedup re-key that fixes a cross-user data drop and measures 1.8x fewer logical reads / 5.7x faster, and
$orderby=createdDateTime asc(flagged independently by three of the four models) without which a truncated first-run backfill can permanently lose history.The benchmark also supplied the measurement #291 shipped without: its
(session_id, graph_interaction_id)index takes the dedup from 48,328 logical reads to 1,323 (seek, not scan), so the shipped index shape is proven correct and needs no change.Four further findings were filed with their measurements rather than fixed in #293. Two have since been fixed and are in this release — #295 (adoption trend double scan, fixed in #303) and #297 (pilot-scope call brake, fixed in #306).
Two remain deferred: #294 and #296— both are now fixed and in this release via #307, each carrying the measured before/after the gate requires (see the gate classification table in §2).Round 3 — the easy-issue sweep
After round 2, the remaining open issues were triaged for what could be safely completed for this release. Four were genuinely low-risk and are now merged: #286 (#304), #295 (#303), #297 (#306) and #302 (#305).
Deliberately not taken, despite looking easy:
[Ignore]d test proving the bug is real is kept as the repro.Copilot interaction history: dedup re-reads whole session history every cycle (needs a bounded window + index) #294, copilot_interaction_keywords has no index leading on keyword_id; orphan cleanup scans the link table #296— now fixed and in this release via Copilot RC fixes: Graph paging failures, accessed-resource dedup correctness, and two bounded reads (#285, #287, #294, #296) #307. The gate did its job rather than blocking indefinitely: the naive fix for Copilot interaction history: dedup re-reads whole session history every cycle (needs a bounded window + index) #294 (bound the read, add no index) was measured as clearly worse, becausecreated_utcis not in the existing unique index; the shipped fix bounds the read and adds the supporting index, which is what turns it into a 7.5x improvement. Two separate measurements of that naive fix exist on different synthetic datasets — "4,713 → 16,687 reads (3.5x)" here, and "5,484 → 15,555 (2.8x)" in the migration doc. Different data, same conclusion; neither is wrong.9. Known issues shipping with this release
Tracked, not blocking, but they should appear in the customer-facing notes:
Graph paging failures are recorded as successful empty imports #285 — Graph paging failures recorded as successful empty imports.Fixed in Copilot RC fixes: Graph paging failures, accessed-resource dedup correctness, and two bounded reads (#285, #287, #294, #296) #307 for the Copilot reports, and in Release critique follow-ups: the same #285 hole in the legacy daily reports, a fail-closed dedup guard, and three doc errors #309 for the legacy daily usage reports (SharePoint/Teams/OneDrive/Yammer/Exchange), which turned out to have the identical hole. A 403 now fails the report import with the HTTP status on the Health page, instead of reading as "no Copilot licences" and suppressing retry for 24h. A genuinely empty report is still a clean success, and a 404 (report absent outside the global cloud) is tolerated with the reason recorded.Copilot accessed-resource actions are collapsed by independent MIN(), and pairings can be fabricated #287 — accessed-resource actions collapsed by independentFixed in Copilot RC fixes: Graph paging failures, accessed-resource dedup correctness, and two bounded reads (#285, #287, #294, #296) #307. De-duplication now keys on the full seven-column tuple, so distinct actions survive and action/list-item pairings can no longer be fabricated.MIN().[Ignore]d test in the repo reproduces it. Investigated since: the half-open window is correct and the original predicate does lose Sunday after midnight, so this is a genuine bug awaiting its own PR.10. Deferred
Copilot interaction history: dedup re-reads whole session history every cycle (needs a bounded window + index) #294 and copilot_interaction_keywords has no index leading on keyword_id; orphan cleanup scans the link table #296— now included via Copilot RC fixes: Graph paging failures, accessed-resource dedup correctness, and two bounded reads (#285, #287, #294, #296) #307, each with the measured before/after the gate requires (see the classification table in §2). Copilot interaction history: dedup re-reads whole session history every cycle (needs a bounded window + index) #294's earlier "obvious fix" measured 3.5x worse; the shipped version bounds the read and adds the supporting index, which is what makes it 7.5x faster instead. The gate did its job.ci.ymlstill omitssrc/SPO/AITracker/**from itspaths:, so a tracker-only change tomainproduces no release build. Worth a follow-up issue.11. On merge
dev) and Graph paging failures are recorded as successful empty imports #285, Copilot accessed-resource actions are collapsed by independent MIN(), and pairings can be fabricated #287, Copilot interaction history: dedup re-reads whole session history every cycle (needs a bounded window + index) #294, copilot_interaction_keywords has no index leading on keyword_id; orphan cleanup scans the link table #296 from Copilot RC fixes: Graph paging failures, accessed-resource dedup correctness, and two bounded reads (#285, #287, #294, #296) #307.mainis the default branch, so the keywords fire on this merge..manual.sqlscripts to the generated stable release —ci.ymluploads**/*.ziponly, so they are never attached automatically and their absence is invisible until a DBA needs one. They form a strict prerequisite chain and must be run in migration-id order; the predecessor of the first is202608131055001_IndexReportDateQueries. Each hard-fails withRAISERRORseverity 16 if its predecessor is not stamped in__MigrationHistory.202608190622001_CopilotDroppedAuditFields.manual.sql202608190725064_AddCopilotUsageReports.manual.sql202608191533567_DeprecateTeamsAddons.manual.sql202608200600001_AddCopilotInteractionHistory.manual.sql202608210700001_WidenCopilotAccessedResourceDedupIndex.manual.sql202608210700002_IndexCopilotInteractionKeywordsByKeyword.manual.sql202608210700003_IndexCopilotInteractionsDedupWindow.manual.sqlAdded since this PR was last updated
Two further changes merged into
dev.Copilot Adoption: explainability, parity, correctness and scale (#315)
Follow-up to the original tool (#292). The user-visible additions are info tips on every assertion, an agents inventory with Keep/Review/Retire/New verdicts, an unlicensed-usage view, executive visuals and a 12-sheet Excel export with live charts.
The part that matters for a release decision is the scale work. Four hand-written queries asked for several
COUNT(DISTINCT …)in oneGROUP BY. SQL Server streams a single distinct aggregate cheaply; two or more force a spool. Measured on a synthetic 200,000-user / 12M-interaction tenant, all four exceeded the 90-second command timeout — and the failure mode is a warning on the page rather than an error, so on a large tenant the report was not slow, it was quietly broken, and nothing in production reported it.LicensedUsersSql28dLicensedUsersSql365dAgentUsageSqlUnlicensedUsageRowsSqlWeeklyAdoptionTrendSqlNo schema change, no migration, no new index — every fix is a change to how the query was written. Each rewrite was gated by an old-vs-new row-for-row comparison before performance was considered (33,999 / 200 / 33,999 / 27 rows, zero differing).
Two honest caveats, both carried over from #315 rather than hidden:
LicensedUsersSqlat a 365-day window is still 135 s at that scale. No longer failing, but not comfortable.Also in #315: several correctness fixes (capped-denominator rates that capped a 200k tenant at 25% adoption; an agent KPI inflated ~4×; a "last N days" window that spanned N+1 days; an agent user count inflated by unattributed audit events), a new
CopilotAdoptionAnalysisApp Insights event with per-step durations as measurements, and a terminology pass from "seat" to "licence" in everything a user reads.Installer: handle new-tenant Graph Reports readiness (#317)
A newly created tenant's Microsoft 365 reporting backend may not have onboarded yet. In that state every
/reports/get*endpoint returns HTTP 404 carrying the nested Graph codeUnknownTenantId, while all other Graph endpoints are healthy. The installer now detects that condition and reports it, instead of the admin seeing a successful install whose usage reports silently return nothing.Migrations in this release
Seven migrations. All are additive or index-only — new tables and nullable columns for the Copilot usage reports, interaction history and dropped audit fields, plus three index changes:
CoverCopilotAccessedResourceDedup,WidenCopilotAccessedResourceDedupIndex— measured, documented in their doc commentsIndexCopilotInteractionKeywordsByKeyword,IndexCopilotInteractionsDedupWindow— on tables new in this release, so instantPer the release policy each needs its
<migrationid>.manual.sqlattached to the stable GitHub release — CI does not upload these, so they must be added by hand, in migration-id order.