Community project. This is not a Microsoft product.

Copilot Interaction Logging

A build guide for collecting metadata about Microsoft 365 Copilot and Copilot Studio interactions from the Microsoft Purview unified audit log into Dataverse, using two Power Automate cloud flows and the Microsoft Graph audit log query API. From Dataverse you can report on usage with Power BI or any Dataverse client. The build collects no prompt or response text.

It is a personal project shared as-is under the MIT licence. Microsoft does not support it, and no SLA or warranty applies. Read the whole of this page, then obtain approval for the app registration and secret handling from your security and privacy owners, and build and test in a non-production environment, before you build it anywhere that holds real data.

Documentation only

No solution package, flow export or source code is published. This page and the action-by-action reference describe a tested reference build in enough detail for you to build your own. You own, secure and maintain whatever you build.

02 / 02 · CaptureAudit log → Graph → flows → Dataverse
Capture pipeline at a glance Copilot interaction records in the Microsoft Purview audit log are queried through the Microsoft Graph auditLogQuery API by two Power Automate flows: a scheduled daily flow that collects one UTC day with a three-day lag, and a manual back-fill flow that takes a start and end date. Both write to three Dataverse tables: interactions, flow runs and errors. Power BI or any Dataverse client reports from there. Only metadata is collected, never prompt or response text. SOURCE API 2 FLOWS 3 TABLES REPORT Purview audit logCopilot interactions Microsoft GraphauditLogQuery API Scheduled dailyone UTC day, 3-day lag Manual back-fillStartDate → EndDate DataverseInteractionsFlow runs · Errors ReportingPower BI or anyDataverse client METADATA ONLY · NO PROMPT OR RESPONSE TEXT FEEDS LANE C OF PART 01
  • Metadata onlyNo prompt or response text
  • 3 Dataverse tablesInteractions, runs and errors
  • Daily + back-fillScheduled and manual flows
  • Documentation onlyNo package: you build it
  • MIT licenceAs-is, no SLA

Overview

What you build

ComponentWhat it isDetails
App registrationA Microsoft Entra ID app with the Microsoft Graph application permission AuditLogsQuery.Read.AllGraph permissions
Client secret storeAzure Key Vault (recommended) or a plain-text environment variable (not recommended). You decide.Secret-handling decision
Dataverse tablesCopilot Interactions, Copilot Interaction Flow Runs and Copilot Interaction Flow Run ErrorsData model
Environment variablesSeven, plus three legacy ones to leave outEnvironment variables
Connection referenceOne, for Microsoft DataverseBuild from scratch
Scheduled flowRuns daily and collects one UTC day from four days ago. 85 actions in the reference build.Action reference
Manual flowBack-fills a date range you choose. 81 actions in the reference build.The manual flow
ReportYour own, for example Power BI with the Dataverse connectorReporting

No package is published, so you build every component yourself, inside one Dataverse solution in a development environment, then promote it as a managed solution (see Build from scratch).

Security

Read this first: high-privilege, tenant-wide access

Warning

The flows sign in as an Entra ID app registration that holds the Microsoft Graph application permission AuditLogsQuery.Read.All.

  • The permission covers the whole tenant. Application permissions can't be scoped to users, departments or sites, and this one reads the audit log for every workload and record type: Exchange, SharePoint, OneDrive, Entra ID, Teams and more, not only Copilot. The flows ask only for Copilot records, but the permission itself is not limited to them.
  • Whoever holds the credential holds that access. That includes anyone who can read the client secret or the Key Vault secret, and any automation that can. Treat the credential like a privileged admin credential.
  • The collected rows are personal data. Each row holds the user's UPN, client IP address and region, plus the names of the files, sites and other resources Copilot accessed. File and site names can reveal confidential work.
  • Flow run history shows raw audit records. In the reference build, the HTTP actions' outputs and the per-record actions aren't secured. Flow owners, co-owners and environment admins can therefore read the raw Graph responses and each record's values, including UPNs and IP addresses, for the run-history retention period (28 days by default). In your build, secure them as described in Securing run history; see also Run history exposure.

The app registration needs only AuditLogsQuery.Read.All. Microsoft Graph offers workload-scoped variants such as AuditLogsQuery-Exchange.Read.All and AuditLogsQuery-SharePoint.Read.All, but none is documented as covering Copilot interaction records. The reference build hasn't been tested with them. AuditLog.Read.All is not enough for the audit log query API. See the API permissions and the permissions reference.

Get approval before you build. Your security and privacy owners should approve the app registration, the admin consent for AuditLogsQuery.Read.All and the secret-handling option before anyone creates them. Also confirm that a built-in option, such as Microsoft Sentinel or Purview Audit search, doesn't already meet the need.

Before you start

Do you need this? Built-in options first

Microsoft already provides several supported ways to see Copilot activity. Check whether one of them meets the need before you build and maintain a custom collector.

NeedBuilt-in optionNotes
Adoption and usage numbersMicrosoft 365 Copilot usage report in the Microsoft 365 admin center; Copilot Dashboard (Viva Insights)Aggregated, supported, no custom code.
Search or investigate individual interactionsMicrosoft Purview Audit search (Copilot audit records)180 days' retention with Audit (Standard). Audit (Premium) keeps records for 1 year, or up to 10 years with an add-on (retention policies).
AI data-security posture, risky prompts, oversharingMicrosoft Purview DSPM for AICovers prompts and responses, which this build deliberately does not collect.
SIEM, long-term retention, correlation with security dataMicrosoft Sentinel CopilotActivity table (connector reference)Managed connector; retention is set in Log Analytics.
Hunting across Microsoft 365 activityDefender XDR advanced hunting CloudAppEvents (application "Microsoft Copilot for Microsoft 365"; release note)Requires Defender for Cloud Apps.
Bulk programmatic exportOffice 365 Management Activity API (Copilot schema)Subscription-based content feed.

This build fits a narrower need. It suits you when you want per-interaction rows in your own Dataverse environment, for example to join with your own agent inventory in Power BI or to keep summaries beyond your audit retention, and none of the options above is practical.

Prompts and responses aren't held in the audit log. They're stored in users' mailboxes and are subject to your retention and eDiscovery controls (Copilot audit logs).

Design

How it works

Two flows share the same logic: a scheduled flow that collects one day at a time, and a manual flow for backfills and re-runs.

How a collection run works Start: the trigger sets the time window, the client secret is resolved from Key Vault or a plain-text variable, a Flow Runs row is written and a Microsoft Graph audit log query for CopilotInteraction records is created. Collect: the flow polls the query every 60 seconds until it succeeds, then a paging loop reads 500 records at a time, upserts each record into the Copilot Interactions table and updates the run counts, following the next-page link. Finish: if collection is complete the run succeeds; otherwise an error row is written and the run fails with CollectionIncomplete. All data is stored in three Dataverse tables. 1 · START Trigger Daily: UTC day 4 days ago Manual: StartDate → EndDate (exclusive) Set window Start and end times for the audit query Resolve the secret A: Key Vault reference B: poc_Audit_Secret Log run start Writes a Flow Runs row Create audit query Graph (beta) query for CopilotInteraction 5 attempts, 20 s apart 2 · COLLECT Wait for query Poll status every 60 s until succeeded Paging loop · scheduled: 100 pages or 10 h · manual: 1,000 pages or 10 h Read a page $top=500, follow nextLink 5 attempts, 30 s apart Upsert each record Key = audit record id Writes Copilot Interactions Failed write → error row Update run counts Writes Flow Runs: running counts, Finished next page 3 · FINISH last page or loop limit Collection complete? yes Run Succeeded no Write error row to Flow Run Errors Run Failed CollectionIncomplete Other failure codes: GetLogsFailed · KeyVaultSecretUnavailable (option A) DATAVERSE Copilot Interactions Copilot Interaction Flow Runs Copilot Interaction Flow Run Errors
  1. Set the window

    The scheduled flow runs once a day (time zone GMT Standard Time) and collects one UTC day, from startOfDay(addDays(utcNow(), -4)) to startOfDay(addDays(utcNow(), -3)), so a run on 10 March collects 6 March. The lag allows for audit records that arrive late. The manual flow collects from StartDate to EndDate, and EndDate is exclusive. See Phase A.

  2. Resolve the secret and open the run log

    If poc_Audit_UsingAKVtruefalse is true, the flow reads the client secret from Azure Key Vault with the Dataverse unbound action RetrieveEnvironmentVariableSecretValue. Otherwise it uses the plain-text variable poc_Audit_Secret. The Key Vault action has secure inputs and outputs, and the Compose actions that pass the secret on have secure inputs, so the secret is hidden in run history. The flow then adds a run log row with Flow State set to Running. See Phase B and Phase C.

  3. Create the query

    An HTTP action sends POST /beta/security/auditLog/queries with the window, and with recordTypeFilters and operationFilters both set to ["CopilotInteraction"] (create auditLogQuery, auditLogQuery). A retry loop makes up to 5 attempts within 5 minutes, 20 seconds apart. See D1.

  4. Wait for the query

    Purview runs the search in the background. The flow checks the query's status every 60 seconds until it's succeeded. See D2.

  5. Read the records

    The flow reads the results up to 500 at a time (list auditLogRecords) and follows @odata.nextLink to the next page. Each page read gets up to 5 attempts within 10 minutes, 30 seconds apart. All three HTTP actions have secure inputs, which hide the secret, but their outputs aren't secured in the reference build, so run history shows the records (Securing run history). See D3.

  6. Write each record

    For every audit log record, Get_a_row_by_ID checks whether it's already stored (only so that new and existing records can be counted), then Upsert_a_row_2 writes it with the row ID set to the audit record id, so re-running a window never creates duplicates. If an upsert fails, a run-after branch adds a "Failed - Upsert a row 2" error row and adds 1 to Records Error; that record isn't saved. The flow moves to the next page only after every record on the page succeeds, so a failed page is read again, and a failure that persists ends in CollectionIncomplete. See D4.

  7. Finish, or fail loudly

    If the query never started or pages were left unread, the flow writes an error row and ends the run as Failed with CollectionIncomplete. An unhandled failure inside Get_Logs ends the run as Failed with GetLogsFailed. Otherwise the run ends Succeeded. See Phases E and F.

Loop limits

LoopScheduled flowManual flow
Create the query (RetryLogic-StartAuditLogQuery)5 attempts or 5 minutesSame
Wait for the query (WaitUntilQueryFinished)480 checks or 8 hours300 checks or 3 hours
Read pages (ProcessAuditLogRecords)100 pages or 10 hours (about 50,000 records)1,000 pages or 10 hours (about 500,000 records)
Retry a page (RetryLogic-AuditLogRecords)5 attempts or 10 minutesSame
  • A loop that reaches its limit ends Succeeded, and the flow carries on (tested with a mock flow; see Until loop considerations). The completeness check catches a paging loop that stopped early, but not a wait loop that gave up before the query finished.
  • Time usually runs out before pages do. Records are written one at a time, at about 25 to 60 a minute, so the 10-hour limit is reached at about 15,000 to 36,000 records, well before 100 pages.
  • A run can be long. At the scheduled limits a run can take about 18 hours. A cloud flow run can last up to 30 days.
  • The endpoint is beta. Graph beta APIs can change without notice.

Details, and how to size the limits for your volumes: Loop limits and Planning for high volumes.

Beta endpoint

The Graph endpoint is beta. Microsoft doesn't support beta APIs for production use, and they can change without notice. The reference build uses beta because the v1.0 record-type list doesn't include CopilotInteraction, so a v1.0 build would have to filter by operation only, and needs validating (Switching to v1.0). Watch run history for unexpected failures.

Under the hood

The HTTP calls

Three HTTP actions do all the talking to Microsoft Graph. AuditLogQuery creates an audit log query, AuditLogQueryStatus waits for it to finish, and AuditLogRecords reads its records a page at a time. This section shows what each one sends and what the Parse JSON action after it expects back. The flows use no Graph connector: the HTTP actions sign in on their own, as in the CoE Starter Kit pattern. For your own build, consider a custom connector.

How each call signs in

All three actions use Active Directory OAuth to get an app-only token, so no user signs in and no connection is needed. The D1 screenshot shows these fields in the designer.

FieldValue
AuthorityAudit_Authority, default https://login.windows.net
TenantAudit_Tenant: your directory (tenant) ID
AudienceAudit_Audience, default https://graph.microsoft.com
Client IDAudit_AppRegID
Credential typeSecret, from the output of Resolve_Graph_Secret (option A or B), or Certificate (option C)

Turn on secure inputs for all three actions, so the secret and the token request stay out of run history.

What the app registration needs

The Microsoft Graph application permission AuditLogsQuery.Read.All, with admin consent. Tested on the beta endpoint, it covers all three calls. AuditLog.Read.All isn't enough, and there's no Copilot-specific permission.

The permission isn't limited to Copilot: whoever holds the credential can query audit records from every audited service in the tenant. Get it approved before you build (see Read this first).

The reference build calls the beta endpoint, where all three calls were tested. Moving to v1.0 is untested: it means dropping the record-type filter, regenerating the Parse JSON schemas and checking the permissions again, so try it in a sandbox first (Switching to v1.0).

The three calls

StepRequestSuccess and retriesWhat happens with the response
D1
AuditLogQuery
POST …/security/auditLog/queries with the JSON body below: the time window, plus the CopilotInteraction record type and operation. 201 Created. Wrapped in RetryLogic-StartAuditLogQuery: up to 5 attempts or 5 minutes, waiting 20 seconds after a failure. ParseBody_2 reads the new query's id. Set-AuditLogQueryID keeps it, and Set-InitialAuditLogQueryRecordsURL builds the first page's URL: …/queries/{query-id}/records?$top=500.
D2
AuditLogQueryStatus
GET …/security/auditLog/queries/{query-id}, no body. WaitUntilQueryFinished asks every 60 seconds until the status is succeeded: up to 480 checks or 8 hours (manual flow: 300 checks or 3 hours). ParseBody-QueryStatus reads status: notStarted, running, succeeded, failed or cancelled.
D3
AuditLogRecords
GET {records URL}, no body and no extra headers. The first URL comes from D1; each later one is the previous page's @odata.nextLink. 200 OK. Wrapped in RetryLogic-AuditLogRecords: up to 5 attempts or 10 minutes, waiting 30 seconds after a failure. The outer loop, ProcessAuditLogRecords, makes one pass per page: up to 100 pages (manual flow: 1,000) or 10 hours. ParseBody-AuditLogRecords reads value, an array of up to 500 records, and @odata.nextLink. D4 upserts each record, then copies the link into the records URL. The last page has no link, so the loop ends (tested with 550 records over two pages).

Shown with … for https://graph.microsoft.com/beta. Placeholders in braces are filled in by the flow at run time.

The D1 request body

The two variables hold the one-day window that phase A works out; the end time is exclusive. Filtering on the record type and the operation keeps the query to Copilot interactions.

{
  "filterStartDateTime": "@{variables('startTime')}",
  "filterEndDateTime": "@{variables('endTime')}",
  "recordTypeFilters": ["CopilotInteraction"],
  "operationFilters": ["CopilotInteraction"]
}

This recordTypeFilters value works on beta only; v1.0 has no such member.

A records schema that won't stall paging

The reference schema marks 13 properties of each record as required. One record that lacks any of them, or has null in a typed one, fails Parse JSON: the page isn't written, the URL doesn't move on, and the same page is read again until the loop limits. Requiring only id avoids that. Validate it against a page from your own tenant:

{
  "type": "object",
  "properties": {
    "@odata.nextLink": { "type": "string" },
    "value": {
      "type": "array",
      "items": {
        "type": "object",
        "properties": {
          "id": { "type": "string" },
          "userPrincipalName": {},
          "auditData": {}
        },
        "required": ["id"]
      }
    }
  }
}

How the retries work

D1 and D3 each sit inside a small Do until loop, a pattern inherited from the CoE Starter Kit. If the call fails, a failure scope waits and sets httpCallFailed to true, and the loop tries again. If the last attempt still failed, a condition adds 1 to httpCallFailureCount, which the completeness check in phase F reports.

The HTTP actions' own retry policy already handles timeouts (408), throttling (429) and server errors (5xx). It doesn't retry authentication errors such as 401, but the loop still makes its five attempts. Tested with an invalid client secret: D1 used all five, D3 then failed on an empty URL, and the run ended as Failed after about 4.5 minutes, with "query started: no" in the completeness check.

Three gaps to close in your build

  • Only succeeded ends the D2 wait. Also stop on failed or cancelled, then fail the run unless the status is succeeded. The reference build's QueryWaitTimeExceeded action never runs, so don't rely on it.
  • Skip D3 when D1 didn't return a query ID, instead of reading an empty URL.
  • The D3 response holds user names, IP addresses and file names. Turn on secure outputs for AuditLogRecords as well as secure inputs, so they stay out of run history.

Recommended: review the HTTP actions and consider a custom connector

The flows call Graph with the built-in HTTP action because they're adapted from the CoE Starter Kit's audit log flows, which work the same way. Review that choice before you go to production, and consider wrapping the three Graph calls in a custom connector instead.

Why it helps

  • Governance. Data policies can classify a custom connector on its own, by name in an environment or by host URL across the tenant (data policies for custom connectors). The environment then doesn't have to allow the general-purpose HTTP connector.
  • No secret in the flow. The connector and its connection hold the sign-in, so no secret passes through the flow's actions or run history, and the Phase B secret lookup is no longer needed.
  • Reuse. The three calls become named operations with defined request and response schemas, shared by every flow that uses the connector.

Check before you switch

  • The sign-in changes. Microsoft notes: "Currently, client credentials grant type is not supported by custom connectors" (authentication). That's the app-only sign-in the HTTP actions use. The connection would sign in as a user account with delegated Graph permissions instead, or the connector would call an endpoint you host, for example in Azure API Management, that signs in to Graph with a managed identity. Either way, who holds the access changes, so put it through the same security review as the app registration.
  • Secrets. If the connector needs a client secret, keep it in a Secret-type environment variable backed by Azure Key Vault. A Text-type one "isn't secure. These values aren't encrypted" (environment variables in custom connectors).
  • Paging. Graph says to use the entire @odata.nextLink URL: "Don't try to extract the $skiptoken or $skip value and use it in a different request" (paging). Make sure the connector can follow that URL.
  • Re-test. Rebuild D1 to D3 in a sandbox, and compare the query wait, paging, retries and row counts with the HTTP build before you remove the HTTP actions. Both are premium, so licensing doesn't change.

Every action, with its settings, purpose and the change a clean build should make: HTTP authentication · D1 · D2 · D3 · D4 · Securing run history · Limits and throttling.

Before you build

Prerequisites and permissions

AreaRequirementWhy
Microsoft 365Unified audit logging turned on (turn auditing on or off, set up Audit (Standard))Copilot interactions are logged as part of Audit (Standard).
Microsoft 365Global (commercial) cloudThe audit log query API isn't available in the US Government L4/L5 (DoD) or 21Vianet clouds.
Entra IDAn app registration in your tenant (register an app)The identity the flows sign in as.
Entra IDMicrosoft Graph application permission AuditLogsQuery.Read.All, with admin consentRequired by the audit log query API.
Entra IDPrivileged Role Administrator or Global Administrator, to grant consentConsent to Microsoft Graph application permissions needs one of these roles.
Entra IDA client secret on the app registrationUsed by the HTTP actions. For certificates, see option C.
Power PlatformAn environment with DataverseHolds the solution, tables and flows.
Power PlatformThe builder is a System Administrator or System Customizer in that environmentNeeded to create the tables, environment variables, connection reference and flows.
Power PlatformA Microsoft Dataverse connection, owned by the flow ownerUsed by the build's Dataverse connection reference (connection references).
Power PlatformA premium Power Automate licence for the flow owner; for a busy tenant, consider a Process licence for the scheduled flowThe HTTP and Dataverse connectors are premium. Each record costs several Power Platform requests, so a busy tenant can exceed a user licence's daily request limit; see Limitations.
Power PlatformA data policy that allows HTTP and Dataverse together (or your custom connector and Dataverse, if you use one)Otherwise the flows are suspended.
Azure (option A only)A Key Vault, the Microsoft.PowerPlatform resource provider, and RBAC role assignmentsSee option A.

Decide first

Secret-handling decision: how the client secret is stored

Choose before you build

The flows authenticate to Microsoft Graph with the app registration's credential, and where that credential lives is your decision, agreed with your security team. Nothing in this guide is pre-filled: you create every environment variable and enter every value yourself. The flows choose where to read the secret from using poc_Audit_UsingAKVtruefalse.

Scripts are off: all three options are shown below.

Option A — Azure Key Vault (recommended)

Where the secret lives
A Key Vault secret. Dataverse holds only a reference to it.
Who can read it
Identities with Key Vault Secrets User on the vault, and Dataverse when a flow asks for it.
Exported with the solution?
Only the reference, never the secret.
Rotation
Update the secret in Key Vault, then save the flows or turn them off and on.
Effort
Medium: Azure subscription, vault, RBAC and networking.
Settings
poc_Audit_UsingAKVtruefalse = true; poc_KeyVaultSecret set; poc_Audit_Secret empty.
Suits
Production.

Follow Microsoft's Use Azure Key Vault secrets guidance. In summary:

  1. Register the Microsoft.PowerPlatform resource provider in the Azure subscription that holds the vault.
  2. Use a vault in the same tenant as Power Platform, with the Azure RBAC permission model. Microsoft suggests a separate vault for each environment.
  3. Add a secret that holds the client secret's Value (not its Secret ID).
  4. Assign the Key Vault Secrets User role to:
    • the user who will create the environment variable;
    • the Dataverse service principal, application ID 00000007-0000-0000-c000-000000000000. Remove any Dataverse role assignment with a different application ID.
  5. If the vault's firewall is on, remember that Power Platform isn't covered by the "Trusted Services Only" option. Allow the Power Platform IP addresses, or use virtual network support.
  6. Create poc_KeyVaultSecret as a Secret environment variable. Power Apps asks for the subscription ID, resource group name, vault name and secret name, and builds the reference:
    /subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/Microsoft.KeyVault/vaults/{keyVaultName}/secrets/{secretName}
  7. Set poc_Audit_UsingAKVtruefalse to true, and leave poc_Audit_Secret empty.

How the flow reads it. Secret environment variables aren't available in the dynamic content selector, so the flow calls the Dataverse unbound action RetrieveEnvironmentVariableSecretValue with the variable's schema name and reads outputs('Perform_an_unbound_action')?['body/EnvironmentVariableSecretValue']. The action has secure inputs and outputs. If it fails, the flow writes an error row (no run log row exists yet) and ends the run as Failed with KeyVaultSecretUnavailable, before any Graph call is made. Secret environment variables work only with Power Automate flows, Copilot Studio agents and custom connectors. See Phase B.

Option B — Plain-text environment variable (not recommended)

Not recommended

This option stores a credential for a tenant-wide, high-privilege permission in clear text. Use Key Vault or a certificate instead. It's documented only because the reference build was live-tested this way; if you use it at all, keep it to a short-lived secret in an isolated test environment.

Where the secret lives
A Text environment variable value in Dataverse.
Who can read it
Anyone who can read environment variable values in the environment.
Exported with the solution?
Yes, in clear text, if a current value is in the solution. Remove it before export.
Rotation
Update the value, then save the flows or turn them off and on.
Effort
Low.
Settings
poc_Audit_UsingAKVtruefalse = false; poc_Audit_Secret set.
Suits
Not recommended. At most, an isolated test environment with a short-lived secret.
  1. Set poc_Audit_UsingAKVtruefalse to false.
  2. Enter the client secret's Value, not its Secret ID, as the Current value of poc_Audit_Secret. Leave its Default value empty, because the default is part of the variable's definition and is always exported.
  3. Use a short-lived secret, and never use this option in production.
  4. Before you export the solution, open the variable and, under Current Value, select ... > Remove from this solution.

See Environment variables overview and Storing the client secret.

Option C — Certificate (advanced)

Where the secret lives
A certificate (PFX) and its password, ideally in Key Vault.
Who can read it
As option A or B, depending on where you store the PFX and password.
Exported with the solution?
As option A or B.
Rotation
Upload a new certificate, then update the stored PFX and password.
Effort
High: in both flows, set Credential Type to Certificate on AuditLogQuery, AuditLogQueryStatus and AuditLogRecords. The reference build was tested with a client secret only.
Settings
Your own variables for the PFX and password.
Suits
Production, where policy requires certificates.

Microsoft recommends a certificate rather than a client secret in production. Upload the certificate's public key to the app registration (certificate credentials). Then, in both flows, set Credential Type to Certificate on the three HTTP actions, and supply Pfx (the base64 content of the PFX file) and Password. Store both as you would a client secret, ideally in Key Vault, and pass them through a Compose action with secure inputs, as Resolve_Graph_Secret does. The reference build hasn't been tested with a certificate, so validate the change with the manual flow before you rely on it. See HTTP authentication.

Compare all three options side by side
AspectA: Azure Key Vault (recommended)B: Plain-text environment variable (not recommended)C: Certificate (advanced)
Where the secret livesA Key Vault secret. Dataverse holds only a reference to it.A Text environment variable value in DataverseA certificate (PFX) and its password, ideally in Key Vault
Who can read itIdentities with Key Vault Secrets User on the vault, and Dataverse when a flow asks for itAnyone who can read environment variable values in the environmentAs option A or B, depending on where you store the PFX and password
Exported with the solution?Only the reference, never the secretYes, in clear text, if a current value is in the solution. Remove it before export.As option A or B
RotationUpdate the secret in Key Vault, then save the flows or turn them off and onUpdate the value, then save the flows or turn them off and onUpload a new certificate, then update the stored PFX and password
EffortMedium: Azure subscription, vault, RBAC and networkingLowHigh: in both flows, set Credential Type to Certificate on the three HTTP actions. Tested with a client secret only.
Settingspoc_Audit_UsingAKVtruefalse = true; poc_KeyVaultSecret set; poc_Audit_Secret emptypoc_Audit_UsingAKVtruefalse = false; poc_Audit_Secret setYour own variables for the PFX and password
SuitsProductionNot recommended; at most an isolated test environment with a short-lived secretProduction, where policy requires certificates

Caution: the switch is an exact text match

In the reference build, only true (lower case, no spaces) is guaranteed to select Key Vault. A value such as yes, or true with a trailing space, silently selects the plain-text secret instead. If that's empty, sign-in fails with AADSTS7000215 and nothing points to the switch. In your build, normalise the value with toLower(trim(...)) or use a Two options variable.

Client secrets last 24 months at most, and Microsoft recommends less than 12. The value is shown only once, when you create it. When a secret expires, the Graph calls fail and the run log shows the query never started; see Troubleshooting and Rotating the secret.

Configuration

Environment variables

The flows read their settings from Dataverse environment variables, so the same flows work in every environment. poc is the reference build's publisher prefix; yours will be your own publisher's prefix. In code view, a flow refers to a variable by display name and schema name, for example parameters('Audit_Tenant (poc_Audit_Tenant)'). Create these:

Schema name (display name)TypeDefault valuePurposeClean buildWith option A
poc_Audit_Tenant
Audit_Tenant
Text(none)Your tenant ID, for the HTTP actions' TenantKeepSet
poc_Audit_AppRegID
Audit_AppRegID
Text(none)The app registration's client IDKeepSet
poc_Audit_UsingAKVtruefalse
Audit_UsingAKVtruefalse
TextfalseThe secret-storage switch. Only true selects Key Vault.Change. Normalise the value, or use Two options.Set to true
poc_KeyVaultSecret
KeyVaultSecret
Secret(none)Option A: the Key Vault reference (see option A)Keep for option ASet: the Key Vault reference
poc_Audit_Secret
Audit_Secret
Text(none)Option B (not recommended): the client secret value. Stored in clear text.Keep for option B onlyLeave empty (or omit in a clean build)
poc_Audit_Authority
Audit_Authority
Texthttps://login.windows.netThe sign-in authorityKeepKeep the default
poc_Audit_Audience
Audit_Audience
Texthttps://graph.microsoft.comThe token audienceKeepKeep the default

Don't create the three legacy variables in the reference build: poc_Audit_ReviewerEmail (unused), poc_AuditMinutestoLookBack (Decimal number, 65) and poc_AuditEndTimeMinutesAgo (Decimal number, 2820). Only legacy Compose actions read the last two.

Changing a value

Flows keep using the previous value until they're saved, or turned off and on again. After any change, save both flows and run the manual flow over a short window. See Changing a value and Environment variables in the action reference.

From an empty environment

Build from scratch: step by step

No solution package is published, so you create every component yourself, in this order, in a development environment. Building it yourself means you choose the secret option, publisher prefix, retention and security that fit your organisation, rather than inheriting someone else's. Get the approvals described in Read this first before step 1. Each step links the detail you need.

  1. Register the app

    1. In the Entra admin center, go to App registrations > New registration. Choose a single-tenant registration.
    2. Go to API permissions > Add a permission > Microsoft Graph > Application permissions, and add AuditLogsQuery.Read.All. AuditLog.Read.All isn't enough.
    3. Select Grant admin consent. This needs a Privileged Role Administrator or Global Administrator.
    4. Go to Certificates & secrets > New client secret. Choose an expiry of less than 12 months (24 months at most), and copy the Value straight away: it's shown only once. Record the expiry date and an owner for rotation.

    A certificate is preferred in production. See Graph permissions.

  2. Store the secret

    Store it using the option you chose in the secret-handling decision.

    Option A: add the secret value to Key Vault, grant Key Vault Secrets User to yourself and the Dataverse service principal, and keep the vault details ready for when you create poc_KeyVaultSecret in step 5 (see option A).

    Option B: keep the secret value ready to enter as the current value of poc_Audit_Secret in step 5. Not recommended (see option B).

    Option C: upload the certificate's public key to the app registration, and store the PFX and its password, ideally in Key Vault. You set the HTTP actions' credential type to Certificate in step 7 (see option C).

  3. Create a publisher and a solution

    1. In the development environment, go to Solutions > New solution and create a new publisher with your own prefix. This guide uses poc, the reference build's prefix; read it as yours throughout.
    2. Create the solution with that publisher. Create everything in steps 4 to 7 inside the solution, so the tables, variables, connection reference and flows move together when you promote it.

    See solution concepts and create a solution.

  4. Create the three tables

    1. Copilot Interactions: one row per audit record. Rename the primary name column to Audit Log ID, then add the 16 columns in the data model.
    2. Copilot Interaction Flow Runs: the run log, with the two choice columns Flow State (Running, Finished) and Run Type (Scheduled, Manual) and the four whole-number count columns.
    3. Copilot Interaction Flow Run Errors: one row per failure. In a clean build, add a lookup from each error row to its run.

    Use the exact column types and lengths in Dataverse tables (create tables). Choice values come from your publisher, so when a flow sets a choice column, pick the option by its label.

  5. Create the environment variables

    Create only the variables your secret option needs, from the seven in Environment variables:

    • Always: Audit_Tenant, Audit_AppRegID, Audit_Authority and Audit_Audience.
    • Option A or B: the switch Audit_UsingAKVtruefalse.
    • Option A: the Secret-type variable KeyVaultSecret, pointing at your vault.
    • Option B (not recommended): the Text variable Audit_Secret.
    • Option C: your own variables for the certificate's PFX and password, ideally Key Vault-backed.

    Leave every default value empty or non-sensitive, because defaults are always exported with the solution (environment variables in flows).

  6. Create a connection reference

    In the solution, select New > More > Connection reference, choose Microsoft Dataverse, and map it to a connection owned by the account that will own the flows. Both flows use this one reference for every Dataverse action. The HTTP actions need no connection: they sign in with the app registration (connection references). If you use a custom connector for the Graph calls, create it in the solution first, and add a connection reference for it too.

  7. Build the flows

    Create a new scheduled cloud flow inside the solution (daily recurrence), then add the actions in this order. The screenshots show each part.

    1. Phase A: initialise the variables and the collection window (one UTC day, from midnight four days ago to midnight three days ago).
    2. Phase B: resolve the secret for your option, with secure inputs and outputs.
    3. Phase C: add the run log row with Flow State set to Running.
    4. D1 to D3: create the audit log query, wait for it to finish, then read the records page by page.
    5. D4: upsert each record into Copilot Interactions and update the run counts.
    6. Phases E and F: write error rows and fail the run if collection is incomplete.

    Then build the manual flow the same way, with a manual trigger that takes two dates and the few differences listed there. Turn on the recommended secure inputs and outputs, size the loop limits for your volume, and add a note to each action saying why it exists (solution-aware flows).

  8. Validate

    Run the manual flow over one day that's at least four days old. For example, if today is the 10th, use StartDate …-06 and EndDate …-07. Then confirm all of these:

    • the run Succeeded in run history;
    • the new Copilot Interaction Flow Runs row shows Records Retrieved above 0, if anyone used Copilot that day;
    • the Copilot Interactions row count for that day is close to a Purview Audit search over the same day;
    • Copilot Interaction Flow Run Errors has no new rows;
    • run history doesn't show the secret.

    Then work through the clean-build checklist.

  9. Lock it down

    Give report readers a read-only security role, schedule a bulk-delete job for your retention period, confirm the data policy and set up monitoring (see Failure states and monitoring). Secure run history as described in Securing run history.

  10. Promote it

    Before you export, remove the current value of every environment variable from the solution, so no secret, tenant ID or client ID leaves the environment. Then export the solution as managed and import it into test, then production (export solutions), and set the environment variable values in each target environment.

Dataverse

Data model

The flows write to three custom tables. All three are user or team owned, and none uses auditing, change tracking or alternate keys. This guide includes no security role, so create your own. See Dataverse tables written by the flows and Upsert mapping.

The tables aren't related in the reference build. A clean build adds a lookup from each error row to its run.

CreationTime is an hour out during British Summer Time

The reference build converts the audit time from UTC to UK time and sends it with no offset, and Dataverse stores a value without an offset as UTC. In your build, send UTC: formatDateTime(items('For_each_1')?['auditData']?['CreationTime'], 'yyyy-MM-ddTHH:mm:ssZ') for CreationTime, and the same expression with 'yyyy-MM-dd' for CreationDate. See Upsert mapping.

Copilot Interactions (poc_CopilotInteraction): one row per audit record
ColumnSource (auditData unless noted)Notes
poc_AuditLogIDrecord idPrimary name column. The upsert also sets the row's primary key (poc_CopilotInteractionId) to the same audit record id, which the lookup and upsert use. There is no alternate key.
poc_UserIDrecord userPrincipalNamePersonal data.
poc_ClientIPClientIPPersonal data. About 58% populated in the reference data.
poc_ClientRegionClientRegion
poc_CreationDateCreationTime. The reference build converts it to UK time; send the UTC date.Date only, Time zone independent.
poc_CreationTimeCreationTime. The reference build converts it to UK time; send UTC.Date and time, User local. An hour out during British Summer Time in the reference build.
poc_OperationOperationCopilotInteraction.
poc_WorkloadWorkloadCopilot.
poc_AppIdentityAppIdentityFor example Copilot.M365Copilot.Apps, or Copilot.Studio.<id>-<agent schema name> for Copilot Studio agents. Can be empty.
poc_AppHostCopilotEventData.AppHostThe app where the interaction happened, for example Microsoft Teams, Word, Outlook, Office or Copilot Studio.
poc_AgentIdAgentIdPresent when an agent was involved.
poc_AgentPlatformAgentPlatformFor example CopilotStudio. Often empty.
poc_PlatformAgentIdPlatformAgentId
poc_ConversationIdCopilotEventData.ConversationId
poc_ThreadIdCopilotEventData.ThreadIdPopulated on every row in the reference data, so it's the better session key.
poc_ResourceCountlength of CopilotEventData.AccessedResourcesCounted before duplicates are removed.
poc_Resourcesdistinct resource Name (or Type), separated by ; Truncated to 4,000 characters. May reveal confidential file or site names.

Five of these fields (Agent Id, AppIdentity, Conversation Id, Agent Platform and Platform Agent Id) aren't in Microsoft's published Copilot schema, or are only partly documented. Check them against your own data before you rely on them.

Copilot Interaction Flow Runs (poc_CopilotInteractionFlowRuns): the run log, one row per run
ColumnNotes
NameYour own label plus the run start time (UTC). The reference build uses the flow's display name.
AuditQueryStartDate, AuditQueryEndDateThe collection window.
FlowStartDateandTimeWhen the run started (UTC).
RunTypeScheduled or Manual.
FlowStateRunning while a page is processed, then Finished after each page. It doesn't show whether the window was collected completely; check the run status.
RecordsRetrievedRecords successfully upserted. Despite the name, it isn't the raw count returned by Graph.
RecordsCreatedRecords the lookup didn't find (new rows), or where the lookup failed.
RecordsModifiedRecords that already existed.
RecordsErrorFailed upserts. Those records weren't saved.

The counts are running totals, updated after each page, and include pages that were read more than once. Treat them as indicative; run history is authoritative.

Copilot Interaction Flow Run Errors (poc_CopilotInteractionFlowRunErrors): one row per failure

Columns: PrimaryDetail, ErrorReason, ErrorDate, ErrorDateandTime. The flows write three kinds of row:

PrimaryDetail starts withWrittenDetails
Failed - Upsert a row 2Once per record whose write failedThe row doesn't include the audit record id.
Failed - Collection incomplete - window … - run …Once per incomplete runErrorReason gives Query started, Pages left unread and Failed Graph calls. The run then fails with CollectionIncomplete.
Failed - Unbound Action call to AKVOption A only, when the Key Vault secret can't be readThe run then fails with KeyVaultSecretUnavailable.

For reporting ideas, see Reporting.

Personal data

Copilot Interactions holds personal data. Restrict all three tables with security roles, agree a retention period and delete older rows on a schedule with a bulk deletion job.

Build your own view

Reporting

The build is a collector only: you build the report. Connect Power BI to the three tables with the Dataverse connector, or use any Dataverse client.

An example Power BI report page with cards for interactions, sessions and agents with usage, a daily interaction trend, traffic by surface type, sessions and turns by month, and client surfaces
Illustrative — demo environment; names fictitious; figures indicative only.

Ideas for a first report

  • Interactions over time from CreationDate, once you store UTC (see Data model).
  • Sessions as distinct Thread Id values. Thread Id is populated on every row in the reference data, so it's a better session key than Conversation Id.
  • Where people use Copilot, by App Host and AppIdentity.
  • Agents, by Agent Id and Agent Platform, joined to your own agent inventory.
  • Collection health from the run log and error rows, including days with no rows (see Failure states).

For a fuller example that combines this data with agent inventory from several platforms, see Custom Agent Reporting – Architecture. There, these tables sit in one reporting environment alongside the other collectors' tables, so its Power BI model reads them all through a single Dataverse connection. If you build both, put them in the same environment.

Run

Failure states and monitoring

Important

Run history is the source of truth. The FlowState column only ever shows Running or Finished, never Failed, and Finished is set after every page, not only at the end. A failed run can leave its row on Running, or show Finished with partial counts.

Run resultCode / actionMeaningWhat to do
SucceededEvery page was read and every record was upserted, or the failures were logged as error rows.Check RecordsError and the error rows.
FailedCollectionIncompleteThe query never started, or pages were left unread. An error row records Query started, Pages left unread and Failed Graph calls.Fix the cause (see Troubleshooting), then re-run the window with the manual flow.
FailedGetLogsFailedAn unhandled failure inside the collection scope (Get_Logs). The reference build writes no error row for it.Open the run, find the failed action, fix it and re-run the window.
FailedKeyVaultSecretUnavailableOption A only: the Key Vault secret couldn't be read. An error row is written before the run log exists.Check RBAC, the resource provider, networking and the reference path.
FailedQueryWaitTimeExceeded actionMeant to stop a run whose query didn't finish in time. It never ran in testing, because a Do until loop that reaches its limit ends Succeeded (Until-loop considerations).In your build, check the query's status after the wait loop and fail the run if it isn't succeeded.
AnyRecordsError above 0, or Failed - Upsert a row 2 rowsAt least one record write failed. If the run failed, records are missing. If it succeeded, the page was read again and the write succeeded on a later pass.Fix the cause (for example capacity, permissions or throttling), then re-run the window. Re-running is safe.
Failed, while FlowState shows FinishedCollectionIncompleteThe run log was marked Finished after the last page processed, then the completeness check failed the run.Trust the run status, and re-run the window.

Monitoring options

The reference build sends no alerts. Choose at least one:

  • Power Automate's built-in failure notification emails to the flow owner.
  • An alert flow on the Dataverse cloud flow run metadata.
  • The CoE Starter Kit.
  • A Power BI tile showing days with no rows.

Run history is kept for 28 days (limits, missing run history), so record failures somewhere that lasts longer. See also the error-handling guidance, and Failure paths and Known issues in the action reference.

Day two

Operations

Missed days are not self-healing

The scheduled flow always collects the UTC day from four days ago. If a run fails, or the flow is off, that day is never collected again unless you back-fill it with the manual flow.

Back-fill with the manual flow

Run the manual flow with StartDate and EndDate in YYYY-MM-DD format.

  • EndDate is exclusive. To collect 1 to 7 September, enter 1 September and 8 September.
  • Keep EndDate at least 48 hours in the past, and StartDate within the last 180 days (the Audit (Standard) retention).
  • Overlapping windows are safe: the upsert updates existing rows rather than duplicating them.
  • Every run has the same 10-hour paging limit, so back-fill a few days at a time rather than weeks in one run (see Planning for high volumes).
  • British Summer Time. During BST, the reference build's manual window starts and ends at 01:00 UTC, so it misses the first hour of StartDate. Start one day earlier, or apply the fix in The manual flow: @{triggerBody()?['date']}T00:00:00Z.

Volume

The flow writes about 25 to 60 records a minute, so the 10-hour paging limit runs out at roughly 15,000 to 36,000 records a day. On a busier day, split the day into smaller windows. Don't turn on concurrency for the per-record loop: its counters are flow variables, and they become unreliable when iterations run in parallel. See Planning for high volumes.

Time zone

Store UTC (see Data model) and convert to local time in the report.

Schedule

The reference build's Recurrence trigger sets no start time or hour, so it runs at about the time the flow was turned on. In your build, set an hour, for example 02:00.

Retention

Create a recurring bulk deletion job that removes rows older than the agreed period from all three tables.

Rotating the secret

  1. Add a new client secret to the app registration.
  2. Update the Key Vault secret (option A) or poc_Audit_Secret (option B) with the new secret's Value.
  3. Save both flows, or turn them off and on again.
  4. Run the manual flow over a short window.
  5. Delete the old secret.

Auditing changes

Turn on Dataverse auditing for the environment variable tables, to record who changed credentials or settings.

Fix

Troubleshooting

SymptomLikely causeFix
AADSTS7000215 (invalid client secret)The secret ID was used instead of its Value; the secret has expired; or the switch isn't exactly true and poc_Audit_Secret is empty.Check the value, the expiry and the switch (see Secret handling).
AADSTS700016 (application not found)The wrong client ID in poc_Audit_AppRegID, or the wrong tenant in poc_Audit_Tenant.Copy both from the app registration's Overview page.
HTTP 401 or 403 from GraphAuditLogsQuery.Read.All is missing, or admin consent wasn't granted. AuditLog.Read.All isn't enough.Add the permission and grant consent (see Graph permissions).
Failed with KeyVaultSecretUnavailableThe Key Vault secret couldn't be read.Check the reference, the role assignments, the network rules and the secret's expiry (see option A).
CollectionIncomplete with "Query started: no"The query was never created, for example because of an expired secret, missing consent or Conditional Access. The run takes a few minutes to fail, because the records loop still makes its 5 attempts.Fix the cause, then re-run the window.
CollectionIncomplete with "Pages left unread: yes"The page cap or the 10-hour limit was reached, or a page couldn't be read.Re-run the window in smaller parts (see Failure paths).
Run succeeds but writes no rowsNo Copilot activity in the window, auditing is off, or the records hadn't arrived yet.Compare with a Purview Audit search for the same window, and keep windows at least 48 hours old.
Flow is suspended or turned offThe data policy, the connection reference or the owner's licence.Check the data policy, the connection and the licence.
Times look an hour outThe British Summer Time defect in the reference build.Store UTC (see Upsert mapping).
HTTP 429 from GraphThrottling. The retry loops wait a fixed time and don't read Retry-After.Use smaller windows, or run less often (see Limits and throttling).

Know before you rely on it

Limitations

Scope

Only audit records where both RecordType and Operation are CopilotInteraction. That covers Microsoft 365 Copilot, Copilot Chat and Copilot Studio agents. These aren't collected:

  • ConnectedAIAppInteraction;
  • AIAppInteraction, which for non-Microsoft AI apps uses Purview pay-as-you-go billing;
  • TeamCopilotInteraction (Facilitator);
  • Copilot admin activity.

See Copilot audit logs and What's collected and what isn't.

Query wait

If Graph takes longer than the wait limit (8 hours scheduled, 3 hours manual) to finish the query, the wait loop still ends Succeeded, and the flow reads whatever the query returns. The run can therefore record fewer rows than exist. In your build, check the query's status after the wait (see Loop limits).

Record-count limit

A query returns at most 1,000,000 records, and it can show succeeded even when the service stopped early. The reference build doesn't check isRecordCountLimitExceeded; your build should, and should fail the run when it's true (see Limits and throttling).

Run history exposure

In the reference build, the HTTP actions' outputs and the per-record actions aren't secured. Anyone who can see run history (flow owners, co-owners and environment admins) can therefore read raw records, including UPNs, IP addresses and resource names, for 28 days. To hide them in your build, change both flows:

  • turn on Secure outputs on the three HTTP actions (AuditLogQuery, AuditLogQueryStatus, AuditLogRecords);
  • turn on Secure inputs and Secure outputs, where offered, on ParseBody-AuditLogRecords and on the per-record actions Get_a_row_by_ID, Select_Resource_Names, Compose_Resources and Upsert_a_row_2. Compose and Parse JSON offer only Secure inputs, which also hides their outputs.

Securing an action's outputs doesn't hide everything downstream, so run a test afterwards and check the run history. Secured values no longer show in run history, which makes failures harder to debug. See Securing run history, secure inputs and outputs and the coding guidelines.

Throughput

The per-record loop runs one record at a time (the Apply to each default; see concurrency control), at roughly 25–60 records a minute in testing. Concurrency would speed it up, but the run counters are flow variables, which can give unpredictable results in a parallel loop.

Power Platform request limits

On a busy tenant these can matter more than the loop limits (request limits, when a flow is throttled, Request limits).

  • Each record costs about 6 requests, and up to 9.
  • In the current transition period, a cloud flow gets 200,000 requests per 24 hours: very roughly 20,000–30,000 records a day.
  • The official limit is 40,000 requests per user, shared with the owner's other flows: about 4,000–6,000 records a day. At the time of writing, Microsoft says it won't enforce this until at least six months after Power Automate usage reporting becomes generally available.
  • For high volumes, assign a Process licence to the scheduled flow: 250,000 requests per 24 hours per licence, and licences can be stacked.
  • Check consumption in the Power Platform admin center under Licensing > Power Automate > Usage. Dataverse service protection limits apply separately.

Beta endpoint

The flows use the Microsoft Graph beta endpoint, which isn't supported in production. The v1.0 record-type list has no CopilotInteraction, so a v1.0 build must filter by operation only, and needs validating (see Switching to v1.0).

One tenant, global cloud

One build collects from the tenant of its app registration, in the global (commercial) cloud only.

Collector only

There's no report, alerting or retention job. Build them yourself (see Reporting).

Where the pattern comes from

Origins and credit

This build is adapted from the audit log collection pattern in the Microsoft Power Platform CoE Starter Kit, described in Collect audit logs using an HTTP action with Microsoft Graph. The kit is open source under the MIT licence at github.com/microsoft/coe-starter-kit. Microsoft describes it as no longer actively maintained, with its core capabilities now in the Power Platform admin center. Credit for the original pattern belongs to the CoE Starter Kit team.

This build changes the pattern: it collects only CopilotInteraction records into their own table, logs every run and every failure, lets you choose Key Vault or a plain-text variable (not recommended) for the secret, fails the run loudly when collection is incomplete, and adds a manual back-fill flow. Some legacy actions and variables from earlier versions remain in the reference build and are labelled Legacy in the reference. It keeps the kit's HTTP actions for the Graph calls; for your own build, consider a custom connector instead. See Origins and credit in the action reference.

A vibe-coded demonstration

The very first build was created manually. Further versions and changes have since been made with GitHub Copilot, using the skills in the Power Automate plugin from microsoft/power-platform-skills. Treat this solution as a vibe-coded demonstration of how Power Automate can pull data from the audit log, and review every action before you use it in your own build.