public/Get-MsecPurviewActivity.ps1

function Get-MsecPurviewActivity {
    <#
    .SYNOPSIS
        Purview Activity Explorer events - DLP rule matches, label applications and file activity
        - one row each, with policy, rule and label names resolved.

    .DESCRIPTION
        The only way to measure DLP and labelling in a tenant without Defender for Cloud Apps.
        Advanced hunting has no DLP table, and CloudAppEvents is empty unless Defender for Cloud
        Apps is onboarded, so Export-ActivityExplorerData is where these questions are answered:
        how often a DLP rule actually fires, which policy fired it, who triggered it, and which
        sensitivity labels are being applied where.

        THE API HAS THREE WAYS OF SILENTLY RETURNING NOTHING, and this command exists mostly to
        stop each of them from reading as "there was no activity".

        A WINDOW OF 30 DAYS OR MORE RETURNS A COMPLETELY EMPTY RESPONSE - no rows, no total, no
        result code, and NO error. 29 days works and returns everything. Measured directly: 29
        days returned 143,952 events and 30 days returned nothing at all. -Days is therefore
        capped at 29 rather than the 30 the documentation implies, because the 30th day does not
        return less, it returns silence.

        THE FILTER TAKES THE ActivityId TOKEN, NOT THE DISPLAYED NAME. 'DLPRuleMatch' returns
        matches; 'DLP rule matched' - the string the portal and the Activity column both show -
        returns an empty result rather than an error. -Activity therefore validates against the
        token form and tells you the mapping, so a filter can't quietly match nothing.

        A SINGLE CALL RETURNS ONE PAGE, NOT THE RESULT SET. The response carries
        TotalResultCount for the whole query and at most PageSize rows, and paging continues
        through WaterMark until LastPage. Reading one page and summarising it gives an answer
        that looks complete and is a sample - a 5,000-row page of a 29,000-row week is 17% of
        it. This command pages to the end and warns if it stopped early, and the row count it
        returns is always comparable with TotalResultCount.

        NESTED FIELDS ARE FLATTENED because the useful ones are not top-level. PolicyName and
        RuleName live inside PolicyMatchInfo, so grouping by PolicyName on the raw output
        silently groups everything under one blank key. SensitivityLabel is a bare GUID, which
        is resolved to the label's display name - and left as the GUID, prefixed, when the label
        no longer exists, because a deleted label still appears in historical events.

        Runs as the msec app through the compliance endpoint, like the other Purview commands.
        Needs a role group that exposes Export-ActivityExplorerData - Global Reader is enough.

    .PARAMETER Days
        How far back to look. Default 7, maximum 29 - see the description; 30 returns silence.

    .PARAMETER Activity
        One or more ActivityId tokens, filtered server-side. Observed in this tenant:
        DLPRuleMatch, DlpClassification, LabelApplied, LabelChanged, FileCreated, FileModified,
        FileRead, FileRenamed, FileArchived, FilePrinted, FileCopiedToNetworkShare,
        FileUploadedToCloud, ArchiveCreated, CopilotInteraction. Not a ValidateSet - Microsoft
        adds activity types, and rejecting an unknown one here would hide activity rather than
        reveal it.

    .PARAMETER MaxEvents
        Row ceiling. Default 50000. Hitting it warns, because a truncated pull that looks
        complete is the failure this command is built around.

    .EXAMPLE
        Get-MsecPurviewActivity -Days 7 -Activity DLPRuleMatch |
            Group-Object PolicyName, RuleName, Workload | Select-Object Count, Name

        Which DLP policies actually fired, where, and how often.

    .EXAMPLE
        Get-MsecPurviewActivity -Days 7 -Activity LabelApplied |
            Group-Object SensitivityLabelName, Workload | Sort-Object Count -Descending

        Where each sensitivity label is really being applied - the denominator for any
        label-conditioned DLP policy.

    .EXAMPLE
        Get-MsecPurviewActivity -Days 7 -Activity DLPRuleMatch |
            Where-Object PolicyName -like '*Confidential*' |
            Select-Object Happened, User, Workload, ItemName

        Whether a specific policy has fired at all. An empty result here is a real answer,
        because the pull is complete and the window is within the measured ceiling.

    .EXAMPLE
        Get-MsecPurviewActivity -Days 1 | Group-Object Activity | Sort-Object Count -Descending

        What Activity Explorer is recording at all, to find the token for a narrower query.

    .OUTPUTS
        PSCustomObject per event, PSTypeName 'MsecPurviewActivity'.

    .NOTES
        Connect-MsecPurview must have been run.

        SENSITIVE-INFO DETAIL COMES IN TWO SHAPES AND AN EVENT CARRIES ONE OR THE OTHER. A
        DLPRuleMatch carries SensitiveInfoTypeData - per-detection id, instance count and
        confidence - returned as RawSensitiveInfo. A DlpClassification carries ONLY
        SensitiveInfoTypeBucketsData - id plus Low/Medium/High bucket counts, no confidence -
        returned as RawSensitiveBuckets. Reading only the first finds nothing on a
        classification event, which looks like "no sensitive information was detected".

        PolicyMatchInfo is returned whole as RawPolicyMatch for the detail not flattened out.
    #>

    [CmdletBinding()]
    [OutputType([PSCustomObject])]
    param(
        [Parameter()]
        [ValidateRange(1, 29)]
        [int] $Days = 7,

        [Parameter()]
        [string[]] $Activity,

        [Parameter()]
        [ValidateRange(1, 500000)]
        [int] $MaxEvents = 50000
    )

    if (-not (Get-Command Export-ActivityExplorerData -ErrorAction SilentlyContinue)) {
        throw 'Export-ActivityExplorerData is not available. Run Connect-MsecPurview first; if it has been run, the connecting identity is in no role group that exposes Activity Explorer.'
    }

    # The displayed name is not the filter token, and passing one returns an empty result rather
    # than an error - the single most expensive mistake available against this API.
    $displayToToken = @{
        'DLP rule matched'             = 'DLPRuleMatch'
        'Classification stamped'       = 'DlpClassification'
        'Label applied'                = 'LabelApplied'
        'Label changed'                = 'LabelChanged'
        'File created'                 = 'FileCreated'
        'File modified'                = 'FileModified'
        'File read'                    = 'FileRead'
        'File renamed'                 = 'FileRenamed'
        'File printed'                 = 'FilePrinted'
        'File copied to network share' = 'FileCopiedToNetworkShare'
        'File copied to cloud'         = 'FileUploadedToCloud'
        'Archive created'              = 'ArchiveCreated'
        'Copilot Interaction'          = 'CopilotInteraction'
    }
    foreach ($a in $Activity) {
        if ($displayToToken.ContainsKey($a)) {
            throw "-Activity takes the ActivityId token, not the displayed name. Use '$($displayToToken[$a])' instead of '$a' - the displayed form returns an empty result rather than an error."
        }
    }

    $start = (Get-Date).AddDays(-$Days).Date
    $end   = (Get-Date).Date

    $events = [System.Collections.Generic.List[object]]::new()
    $watermark = $null
    $total = $null
    $pages = 0
    $lastPage = $false

    do {
        $p = @{ StartTime = $start; EndTime = $end; OutputFormat = 'Json'; PageSize = 5000 }
        if ($Activity)  { $p['Filter1']    = @('Activity') + $Activity }
        if ($watermark) { $p['PageCookie'] = $watermark }

        $response = Export-ActivityExplorerData @p

        # The 30-day failure mode: a response object with every field empty. Checking the rows
        # alone cannot tell it apart from a genuinely quiet window, so the result code is what
        # is tested - a successful query always sets one.
        if ($pages -eq 0 -and -not $response.ResultCode) {
            throw ("Activity Explorer returned an empty response for a {0}-day window (no rows, no total, no result code, no error). Windows of 30 days or more do this; 29 days and under work. Reduce -Days." -f $Days)
        }

        if ($null -eq $total) { $total = [int]($response.TotalResultCount ?? 0) }

        # A valid empty result carries a null ResultData - zero events matched the filter, which
        # is an answer, not a failure. ConvertFrom-Json throws on null, so it is guarded rather
        # than letting "nothing matched" surface as an error.
        if ($response.ResultData) {
            $rows = $response.ResultData | ConvertFrom-Json
            if ($rows) { $events.AddRange(@($rows)) }
        }

        $watermark = $response.WaterMark
        $lastPage  = [bool]$response.LastPage
        $pages++
    } while ($watermark -and -not $lastPage -and $events.Count -lt $MaxEvents)

    if ($events.Count -ge $MaxEvents) {
        Write-Warning ("Stopped at the -MaxEvents ceiling of {0}; {1} event(s) match. Anything you measure from this is a sample, not the window." -f $MaxEvents, $total)
    }
    elseif ($total -and $events.Count -lt $total) {
        # Activity Explorer's own total drifts slightly between pages as new events land, so a
        # small shortfall is normal and a large one is not. Reporting both numbers lets the
        # caller judge rather than trusting a count that might be 17% of the answer.
        $shortfall = $total - $events.Count
        if ($shortfall -gt [math]::Max(200, $total * 0.02)) {
            Write-Warning ("Pulled {0} of {1} event(s) over {2} page(s) - {3} short. Treat aggregates as a sample." -f $events.Count, $total, $pages, $shortfall)
        }
    }

    Write-Verbose ("Pulled {0} of {1} event(s) over {2} page(s) for a {3}-day window." -f $events.Count, $total, $pages, $Days)

    # Label GUID -> display name. Built once; a label deleted since the event was recorded will
    # not resolve, and is reported as its GUID rather than as a blank that reads like "no label".
    $labelNames = @{}
    try {
        foreach ($l in Get-Label -ErrorAction Stop) { $labelNames[[string]$l.Guid] = [string]$l.DisplayName }
    }
    catch {
        Write-Verbose "Could not read sensitivity labels; SensitivityLabelName will carry GUIDs."
    }

    foreach ($e in $events) {
        $labelId = [string]$e.SensitivityLabel
        $labelName = if (-not $labelId) { $null }
                     elseif ($labelNames.ContainsKey($labelId)) { $labelNames[$labelId] }
                     else { "(deleted or unknown label $labelId)" }

        [PSCustomObject]@{
            PSTypeName           = 'MsecPurviewActivity'
            Happened             = if ($e.Happened) { [datetime]$e.Happened } else { $null }
            Activity             = $e.Activity
            ActivityId           = $e.ActivityId
            User                 = $e.User
            UserType             = $e.UserType
            Workload             = $e.Workload
            ItemName             = $e.ItemName
            FilePath             = $e.FilePath
            FileExtension        = $e.FileExtension
            # Flattened out of PolicyMatchInfo - grouping by these on the raw API output puts
            # every row under one blank key, which reads as "no policy matched".
            PolicyName           = $e.PolicyMatchInfo.PolicyName
            RuleName             = $e.PolicyMatchInfo.RuleName
            PolicyMode           = $e.PolicyMatchInfo.PolicyMode
            SensitivityLabelId   = if ($labelId) { $labelId } else { $null }
            SensitivityLabelName = $labelName
            # Manual, Auto, or blank. 'Auto' here is label INHERITANCE or a second workload
            # recording the same human action - it does not imply an auto-labeling policy.
            HowApplied           = $e.HowApplied
            EmailSender          = $e.EmailInfo.Sender
            EmailSubject         = $e.EmailInfo.Subject
            EmailRecipients      = @($e.EmailInfo.Receivers)
            RawPolicyMatch       = $e.PolicyMatchInfo
            # TWO SHAPES, AND AN ACTIVITY TYPE CARRIES ONE OR THE OTHER. A DLPRuleMatch has
            # SensitiveInfoTypeData - per-detection id, instance count and confidence. A
            # DlpClassification has ONLY SensitiveInfoTypeBucketsData - id plus Low/Medium/High
            # bucket counts and no confidence. Exposing just the first drops the sensitive-info
            # detail from every classification event, which reads as "nothing was detected".
            RawSensitiveInfo     = $e.SensitiveInfoTypeData
            RawSensitiveBuckets  = $e.SensitiveInfoTypeBucketsData
            RecordIdentity       = $e.RecordIdentity
        }
    }
}