Microsoft Copilot Activity Logging
Overview
The scheduled Python job signs in as an Entra application, uses its temporary token to list directory users, and then uses each selected user’s object ID to retrieve that user’s Copilot prompts and responses through Microsoft Graph.
Pre-Step App Permissions
In the app registration, grant admin consent for these two Microsoft Graph Application permissions. Each covers a different step.
Permission | What it lets the app read |
User.Read.All | The tenant’s user directory, including the object IDs you need |
Microsoft 365 Copilot interaction history, including prompts and responses returned by the export API |
Value | What it tells Microsoft Entra ID |
Tenant ID | Which organization’s directory to use |
Application (client) ID | Which registered app is requesting access |
Client secret value | Proof that the script can authenticate as that app. Use the secret Value from the app registration. |
import os
import requests
tenant_id = os.environ["ENTRA_TENANT_ID"]
response = requests.post(
f"https://login.microsoftonline.com/{tenant_id}/oauth2/v2.0/token",
data={
"client_id": os.environ["ENTRA_CLIENT_ID"],
"client_secret": os.environ["ENTRA_CLIENT_SECRET"],
"grant_type": "client_credentials",
"scope": "https://graph.microsoft.com/.default",
},
timeout=30,
)
response.raise_for_status()
token = response.json()
headers = {"Authorization": f"Bearer {token['access_token']}"}
2a. Use the token to build the list of users to query
Send this request with the headers from Step 1a. The response contains users in value. If Graph includes @odata.nextLink, follow that full URL with the same authorization header until you reach the last page. Then apply your organization’s account-selection rules. For example, accountEnabled can help identify disabled accounts, but identifying service accounts depends on how your organization tracks them. If the export covers past activity, decide whether accounts disabled since that period should still be included.
2b. List Entra users and keep likely human accounts
$select asks Graph for the exact fields the script needs. Graph returns a default set of fields, so explicitly requesting them makes the script’s inputs clear. A requested field can still be null if the directory has no value for it.
Graph field | How the developer used it |
userType | Keep Member accounts. memberOf is a separate relationship for directory memberships. Note: A service account can also be a Member. |
surname | Keep accounts with a populated last name. This works as a tenant-specific clue if your provisioning process reliably fills in surnames for people. |
accountEnabled | Keep accounts where the value is true. This removes disabled accounts. Enabled accounts can still be dormant or belong to a service. |
id | Save this Entra user object ID for Step 3. |
userPrincipalName | Keep it if you want a readable identifier for reviewing the results. The per-user request below uses id. |
If your tenant has an authoritative employee roster or a maintained service-account list, use it to validate the filter.
Read every page before treating the list as complete. /users defaults to 100 users per page and supports up to 999 per page for this request. Each response has a value array. When it also has @odata.nextLink, send a GET to that full URL with the bearer token. Repeat until there’s no next link. Graph already includes the paging information in that URL, so reuse it as returned.
import requests
# headers comes from Step 1 and contains Authorization: Bearer <access_token>
url = "https://graph.microsoft.com/v1.0/users"
params = {
"$select": "id,userPrincipalName,userType,surname,accountEnabled",
"$top": 999,
}
candidate_user_ids = []
while url:
response = requests.get(url, headers=headers, params=params, timeout=30)
response.raise_for_status()
page = response.json()
for user in page["value"]:
if (
user.get("userType") == "Member"
and (user.get("surname") or "").strip()
and user.get("accountEnabled") is True
):
candidate_user_ids.append(user["id"])
url = page.get("@odata.nextLink")
params = None # The nextLink already contains the query parameters.
3a.Use each selected user’s id to retrieve Copilot activity
For every ID in candidate_user_ids, make a direct HTTP GET with the same app-only bearer token while it’s valid. The app is the caller. The ID in the URL tells Graph whose interaction history to retrieve. This request requires the AiEnterpriseInteraction.Read.All application permission. Microsoft also documents a Microsoft 365 Copilot license requirement with the Graph-grounded chat service plan.
GET https://graph.microsoft.com/v1.0/copilot/users/{id}/interactionHistory/getAllEnterpriseInteractions
For a scheduled run, pass a start and end time to limit the results. Graph returns records in value. Fields such as interactionType, createdDateTime, and body.content tell you what was recorded.
# Example for one candidate. The scheduled job repeats this for each ID.
user_id = candidate_user_ids[0]
url = (
f"https://graph.microsoft.com/v1.0/copilot/users/{user_id}"
"/interactionHistory/getAllEnterpriseInteractions"
)
params = {
"$filter": (
"createdDateTime gt 2026-11-08T00:00:00Z "
"and createdDateTime lt 2026-11-09T00:00:00Z"
)
}
response = requests.get(url, headers=headers, params=params, timeout=30)
response.raise_for_status()
page = response.json()
Result | What to record |
Graph returns a userPrompt record | The user has recorded prompt activity in the queried window. Forward the records to Splunk or your ingestion pipeline if you’re collecting content. |
The request succeeds and the full result contains no records | Graph returned no activity for that user in the queried window. Coverage depends on licensing and which Copilot experiences write to this history. |
The request fails | Record the lookup as failed so the user stays distinguishable from someone with an empty result. |
The snippets show the directory loop and a single Copilot request. In the scheduled collector, follow any @odata.nextLink returned for interactions, renew the token when needed, and honor Retry-After if Graph returns 429. Graph throttling varies by workload, so treat the developer’s 10,000 requests per minute as an estimate, rather than a guaranteed allowance for this endpoint.
Note: Environment of 4,000 users takes about 30 minutes to pull all data. Script runs every hour.
3b. How the scheduled job remembers where it left off
A checkpoint is the end time of the last collection window the script successfully completed for a user. Keep it in durable storage keyed by tenant and user ID so it survives restarts. Microsoft documents a createdDateTime filter with both a start and an end time for this endpoint. The collector manages the checkpoint because the endpoint does not support a delta query.
Read the user’s saved checkpoint. If the last completed window ended at 14:00 UTC, use that as the starting point for the next run.
Choose a fixed end time for this run. For example, query through 15:00 UTC. Start slightly before 14:00 UTC if you want overlapping windows to pick up records that became visible late or landed at the previous boundary.
Read every result page and persist the events. If you overlap windows or retry a run, use a deduplication key such as tenant_id + queried_user_id + message_id so re-reading a message doesn’t count it twice.
Advance the checkpoint only after the window succeeds. Save 15:00 UTC after all pages have been processed and the results have been durably written or queued for delivery. If the user’s request or delivery fails, keep the prior checkpoint and retry that window. A successful empty window can still advance the checkpoint.
@odata.nextLink and the checkpoint serve different purposes. @odata.nextLink tells the script where to get the next page of the current query. The checkpoint tells the next scheduled run which time window to query. Use the completed window’s end time for that checkpoint so users with no activity still move forward.
4a.Send Copilot interaction records to SIEM and interpret
The getAllEnterpriseInteractions endpoint returns individual messages from supported Microsoft 365 Copilot experiences. A user’s prompt and Copilot’s reply can arrive as separate JSON records.
What the JSON can capture
Field | What it tells you |
interactionType | userPrompt is a message sent by the user. aiResponse is a reply generated by Copilot. |
body.content | The prompt or response content. Use body.contentType to interpret its format. |
createdDateTime | When Graph says the message was created. Use this to order the conversation, especially if the events reach the SIEM at different times. |
contexts, attachments, links, mentions | Additional context when present, such as a Teams meeting reference or an attached resource. |
appClass | The Copilot experience associated with the message. For example, IPM.SkypeTeams.Message.Copilot.Teams identifies Teams Copilot, while IPM.SkypeTeams.Message.Copilot.BizChat identifies the BizChat experience. Keep the raw value even if you also map it to a shorter label. |
appClass is the field to use for this Graph export. If you also ingest Microsoft 365 audit records, you may see AppHost in the separate CopilotInteraction audit schema. Both provide app context. If you normalize them into one SIEM field, retain the original value and source so an analyst can tell which schema supplied it.
4b. Which ID connects the record to a user?
Connect User to ID on query
In this scenerio a seperate file of user name to user ID would be stored. Two common scenerios:
If someone wanted to query all the copilot interactions for a user the user would be input itno the query and a lookup function would pull the users AI interactiuon ID to then search through the logs to pull all their AI interactions.
An AI interaction flags as suspcious, so the interaction ID is looked up to assocate that interaction with a user.
The 1st scenreio would be more common for this type of data because alert tools like DSPM for AI already flag on suspcious AI usage and coorelate the user to that session. Example, #1 is a use case where there is some oher reason to check into the users copilot interactions (HR, Insider Threat, etc)
Wite the user to the log
This is more complex because it isn't just pullint the AI interaction logs and wriing them to storage for late search. It involves adding an addiitonal meta dat field to the log that is the user to AI ID. Caustion needs to be taken so the incorrect user is not assoicated with AI IDs that are not them; thereby not correctly attributing Copilot AI sessions to the correct user.
The script already knows whose history it requested because it inserted that user’s ID into /copilot/users/{id}/interactionHistory/getAllEnterpriseInteractions. Store it in a collector-added field such as queried_user_id, along with the tenant ID.
ID | Use it to answer |
Entra user object ID from the request URL | Whose history did we query? This is the key for the directory lookup. |
from.user.id, when populated | Who authored this particular message? On a Copilot reply, from.user can be null and from.application can identify the Copilot bot. |
JSON id | Which individual message is this? Preserve it for event identity and deduplication. |
requestId | Which prompt and response belong to the same exchange? Microsoft’s Teams example shows two distinct message IDs sharing one requestId. |
sessionId | Which messages belong to the same conversation or thread? Group by tenant, queried user, and sessionId, then sort by createdDateTime to reconstruct the conversation. A session can contain multiple prompt-response exchanges. |
For example, an analyst might find a Copilot reply in Splunk with no username and from.user = null. The collector-added queried_user_id identifies whose history contained the reply. The requestId lets the analyst find the corresponding prompt, and the sessionId lets them pull up the surrounding conversation. This is also useful when the prompt carries Teams meeting context and the reply’s contexts array is empty, a case Microsoft documents

Comments