Integrations

SharePoint Activity History

Extracting activity history from SharePoint's REST APIs

2025-09-03
Mail Stuck in a Tarpit

On more than one occasion, we've encountered events where hundreds of empty folders suddenly reappear within the folder structure of a SharePoint site. This clutters up the document library and causes confusion for site members and sharing recipients. In at least one instance, the event was triggered when a site member logged into a computer that had not been used for over a year. While reconciling changes, the OneDrive client created numerous stale folders on the remote side rather than deleting them locally. There are several posts on Reddit and the Microsoft Q&A describing this phenomenon. It's likely a bug in the OneDrive client, but our Microsoft support tickets have all closed without any resolution. Further complicating the issue, the events bypass the admin audit log in M365 (another ticket that closed without resolution).

Now what?

We can approximate when and why the issue occurred, but how can we clean up the mess? One solution is to identify and delete any empty folders in a site collection, and there are plenty of scripts out there that will do this. However, we want to be certain that the client didn't also create stale files in addition to empty folders. While the activity is missing from the admin audit log, it is present in the SharePoint activity log. These can be viewed from the info tab of the document library.

SharePoint Activity Log

The activity log loads 25 events at a time as you scroll through it in the browser. It's impractical to manually review hundreds or thousands of events this way, but it can be helpful to approximate a start and end time for the event which we'll note for later use. In most cases, the synchronization event will take a few minutes and lots of changes will be recorded in that timespan.

Querying the Activity Logs

On the Network tab in dev tools, we can see the route that SharePoint queries when loading the activity logs:

DevTools Requests

Scrolling the list, we see multiple requests to URLs like the one below. The query string parameters confirm it's pulling 25 records at a time and providing a skip token value to advance to the next set.

https://<org>.sharepoint.com/_api/v2.0/drives/<id>/root/activities?%24expand=driveItem(%24select%3Did%2Cname%2CwebUrl%2CparentReference%2Cfile%2Cfolder)&%24top=25&%24skiptoken=<token>

This route is part of the SharePoint REST API. Accessing the route requires an authorization token. This can be found by scrolling through the request headers in dev tools as shown below.

DevTools Authorization

With the approximate date and time of the event, the authorization token (e.g. bearer eyJ0eXA...) and the route in hand, we can query the activity log with PowerShell. Pay attention to the comments in the extended snippet below and be sure to substitue the <org>, <upn>, <token> and <driveId> values with your own values.

# Provide a date and (optionally) time just prior to when the event occurred and just after
$startDate = (Get-Date "9/4/2025")
$endDate = (Get-Date "9/5/2025")
$userUPN = "<upn>" # UPN of the affected user

# Set your token and URL. Be sure to remove the skipToken parameter if present
# Note that we're also pulling the top 250 instead of just 25 records at a time
$token = "bearer <token>"
$url = "https://<org>.sharepoint.com/_api/v2.0/drives/<driveId>/items/<itemid>/activities?%24expand=driveItem(%24select%3Did%2Cname%2CwebUrl%2CparentReference%2Cfile%2Cfolder)&%24top=250"
$header = @{
    "authorization" = $token
    "accept" = "application/json"
}

# Create an empty array to store our values
$activities = @()
$results = Invoke-RestMethod -Uri $url -Headers $header
if($results.value){
    $activities += $results.value
}

# Results are paginated. If there are more than 250 activity logs,  continue
# fetching them until the entire list is retrieved, or until the events re-
# turned exceed the scope of the start and end dates.
do{
    $results = Invoke-RestMethod -Uri $results.'@odata.nextLink' -Headers $header
    if($results.value){
        if($results.value[0].times.recordedTime -gt $startDate -and $results.value[-1].times.recordedTime -lt $endDate){
            $activities += $results.value
        }
        else{
            break
        }
    }
} While ($results.'@odata.nextLink')

# To further narrow your results, you can filter out those initiated by the 
# user whose client initiated them. You can also narrow the scope further by
# filtering on $_.times.recordedTime.

$activities = $activities | Where-Object {$_.actor.user.email -eq $userUPN}

# Identify folders that were created in the filtered list:
$folders = @()
foreach($item in $activities){
    if($item.action.create -and $item.driveItem.folder){
        $folders += $item
    }
}

Cleaning Up

Now that we've identified the folders that were generated as part of the event, we need to determine if they are empty and delete them if so. I find it easiest to do this with PnP Online, but there are likely ways to handle this using the SharePoint API as well. PnP PowerShell requires an Azure app registration and client ID. Follow the Getting up and Running guide if you don't already have this set up in your environment.

# Connect with PnP
connect-pnponline -url https://<org>.sharepoint.com/sites/<site_name> -ClientId <client_id> -Interactive

# Check each folder and its parent. If both are empty, force deletion of the parent
foreach($folder in $folders){
    # URL formatting in PnP differs from what is returned by the SharePoint API. 
    # Replace <org> with your organization below
    $decode = [uri]::UnescapeDataString($folder.driveItem.webUrl.Replace("https://<org>.sharepoint.com",""))
    if($pnpFolder = Get-PnPFolder -Url $decode -ErrorAction SilentlyContinue){
        if($pnpFolder.ItemCount -eq 0){
            $parent = (Split-Path $pnpfolder.ServerRelativeUrl -parent).replace("\","/")
            try{
                $res = Remove-PnPFolder -Name $pnpFolder.Name -Folder $parent -Recycle -Force
            }
            catch{
                Write-Host "Failed to delete folder $($decode)"
            }
        }
    }
}

Note that the $res object includes the RecycleBinItemId. This can be appended to the $folder object if it's needed for reporting purposes.

What About Files?

I mentioned that we also want to determine if any files were created by the malfunctioning OneDrive client. To do so, run the loop below against the $activities list.

# Identify files that were created in the filtered list:
$files = @()
foreach($item in $activities){
    if($item.action.create -and $item.driveItem.file){
        $files += $item
    }
}

The items in the $files array need to be manually reviewed to determine if they are stale files or if the user happened to make changes at the time of the event.