Get-AzDevOpsActivityLogs.ps1


function Get-AzDevOpsActivityLogs {

    <#
    .SYNOPSIS
    The Get-AzDevOpsActivityLogs function dumps in JSON files Azure DevOps activity logs for a specific time range.
 
    .EXAMPLE
 
    PS C:\>$appId = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
    PS C:\>$tenant = "example.onmicrosoft.com"
    PS C:\>$certificatePath = "./example.pfx"
    PS C:\>$endDate = Get-Date
    PS C:\>$startDate = $endDate.AddDays(-90)
 
    PS C:\>Get-AzDevOpsActivityLogs -startDate $startDate -endDate $endDate -appId $appId -tenant $tenant -certificatePath $certificatePath
 
    Dump all Azure DevOps activity logs for the last 90 days.
    #>


    param (
        [Parameter(Mandatory = $true)]
        [DateTime]$startDate,
        [Parameter(Mandatory = $true)]
        [DateTime]$endDate,
        [Parameter(Mandatory = $true)]
        [String]$certificatePath,
        [Parameter(Mandatory = $true)]
        [String]$appId,
        [Parameter(Mandatory = $true)]
        [String]$tenant,
        [Parameter(Mandatory = $false)]
        [String]$logFile = "Get-AzDevOpsActivityLogs.log"
    )
    $currentPath = (Get-Location).path

    $cert, $needPassword, $certificateSecurePassword = Import-Certificate -certificatePath $certificatePath -logFile $logFile

    Connect-AzApplication -logFile $logFile -certificatePath $certificatePath -certificateSecurePassword $certificateSecurePassword -needPassword $needPassword -tenant $tenant -appId $appId
    $token = Get-AzAccessToken -ResourceUrl "499b84ac-1321-427f-aa17-267ca6975798" -AsSecureString:$false -ErrorAction Stop
    $tenantId = (Get-AzTenant).Id

    $azureDevOpsOrganizationsFolder = $currentPath + "\azure_DevOps_orgs"
    if ((Test-Path $azureDevOpsOrganizationsFolder) -eq $false){
        New-Item $azureDevOpsOrganizationsFolder -Type Directory | Out-Null
    }

    $azureDevOpsOrganizationsRaw = Invoke-RestMethod -Headers @{Authorization = "Bearer $($token.Token)"} -Method Get -ContentType "application/json" -ErrorAction Stop -Uri "https://aexprodweu1.vsaex.visualstudio.com/_apis/EnterpriseCatalog/Organizations?tenantId=$tenantId"
    if ($azureDevOpsOrganizationsRaw.Contains("Azure DevOps Services | Sign In")){
        Write-Warning "Could not enumerate the organizations the application has access to (this is a known bug from Microsoft). Please enter the name of the subscriptions manually"
        "Could not enumerate the organizations the application has access to (this is a known bug from Microsoft). Please enter the name of the subscriptions manually" | Write-Log -LogPath $logFile
        [System.Collections.ArrayList]$wantedOrganizationsNameAndId = @{}
        $read = $True
        Write-Host "Leave Blank and press 'Enter' to Stop"
        while ($read){
            $inputOrganizationName = Read-Host "Please enter the organization names, one by one, and press 'Enter'"
            if ($inputOrganizationName){
                $selectedInput = @{
                    "Organization Name" = $inputOrganizationName ;
                    "Organization Id" = "000000000000000000"
                }
                $wantedOrganizationsNameAndId.Add($selectedInput) | Out-Null
                Write-Host "Added $inputOrganizationName"
            }
            else {
                $read = $False
            }
        }
    }
    else {
        $outputFile = $azureDevOpsOrganizationsFolder + "\AzdevopsOrgs_" + $tenant + ".json"
        $azureDevOpsOrganizationsRaw | ConvertFrom-CSV | ConvertTo-Json -Depth 99 | Out-File $outputFile -Encoding UTF8
        $azureDevOpsOrganizationsNameAndId = $azureDevOpsOrganizationsRaw | ConvertFrom-CSV | ForEach-Object {$_ | Select-Object "Organization Name", "Organization Id"}
        Write-Host "The following organizations are accessible in your Entra ID tenant:"
        "The following organizations are accessible in your Entra ID tenant:" | Write-Log -LogPath $logFile
        $azureDevOpsOrganizationsNameAndId | Out-Host
        $azureDevOpsOrganizationsNameAndId | Write-Log -LogPath $logFile
        $choice = Read-Host "Do you want to collect Azure DevOps activity logs for all [a], specific [s] or no [N] organizations ? [a/s/N]"
        if ($choice.ToUpper() -eq "S"){
            [System.Collections.ArrayList]$wantedOrganizationsNameAndId = @{}
            $read = $True
            Write-Host "Leave Blank and press 'Enter' to Stop"
            while ($read){
                $potentialOrganizationId = Read-Host "Please enter the organization IDs, one by one, and press 'Enter'"
                if ($potentialOrganizationId){
                    $selectedInput = $azureDevOpsOrganizationsNameAndId | Where-Object {$_."Organization Id" -eq $potentialOrganizationId}
                    if ($null -ne $selectedInput){
                        $wantedOrganizationsNameAndId.Add($selectedInput) | Out-Null
                        Write-Host "Added $potentialOrganizationId"
                    }
                    else {
                        Write-Warning "Invalid organization ID, please try again"
                    }
                }
                else {
                    $read = $False
                }
            }
        }
        elseif ($choice.ToUpper() -eq "A"){
            $wantedOrganizationsNameAndId = $azureDevOpsOrganizationsNameAndId
        }
        else {
            Write-Error "No organization was selected. Exiting"
            "No organization was selected. Exiting" | Write-Log -LogPath $logFile -LogLevel Error
            exit
        }
    }

    $launchSearch =
    {
        param($newStartDate, $newEndDate, $currentPath, $organizationName, $appId, $tenant, $certificatePath, [SecureString]$certificateSecurePassword, [Bool]$needPassword)

        $dateToProcess = ($newStartdate.ToString("yyyy-MM-dd"))
        $logFile = $currentPath + "\AzDevOps_" + $organizationName + "_" + $dateToProcess + ".log"

        $azureDevOpsActivityFolder = $currentPath + "\azure_DevOps_activity"
        if ((Test-Path $azureDevOpsActivityFolder) -eq $false){
            New-Item $azureDevOpsActivityFolder -Type Directory
        }

        $totalHours = [Math]::Floor((New-TimeSpan -Start $newStartDate -End $newEndDate).TotalHours)
        if ($totalHours -eq 24){
            $totalHours--
        }
        for ($h=0; $h -le $totalHours; $h++){
            if ($h -eq 0){
                $newStartHour = $newStartDate
                $newEndHour = $newStartDate.AddMinutes(59 - $newStartDate.Minute).AddSeconds(60 - $newStartDate.Second)
            }
            elseif ($h -eq $totalHours){
                $newStartHour = $newEndHour
                $newEndHour = $newEndDate
            }
            else {
                $newStartHour = $newEndHour
                $newEndHour = $newStartHour.addHours(1)
            }
            "Processing Azure DevOps activity logs between {0:yyyy-MM-dd} {0:HH:mm:ss} and {1:yyyy-MM-dd} {1:HH:mm:ss}" -f ($newStartHour, $newEndHour) | Write-Log -LogPath $logFile

            $outputDate = "{0:yyyy-MM-dd}_{0:HH-00-00}" -f ($newStartHour)

            $auditStart = "{0:s}" -f $newStartHour + "Z"
            $auditEnd = "{0:s}" -f $newEndhour + "Z"
            $uri = "https://auditservice.dev.azure.com/$($organizationName)/_apis/audit/auditlog?startTime=$($auditStart)&endTime=$($auditEnd)&api-version=7.1-preview.1"

            $azureDevOpsActivityEvents = Get-AzDevOpsAuditLogs -certificatePath $certificatePath -certificateSecurePassword $certificateSecurePassword -needPassword $needPassword -tenant $tenant -appId $appId -uri $uri -logFile $logFile

            $folderToProcess = $azureDevOpsActivityFolder + "\" + $dateToProcess
            if ((Test-Path $folderToProcess) -eq $false){
                New-Item $folderToProcess -Type Directory
            }
            $outputFile = $folderToProcess + "\AzDevOps_" + $tenant + "_" + $organizationName + "_" + $outputDate + ".json"
            if ($azureDevOpsActivityEvents){
                $nbAzureDevOpsActivityEvents = ($azureDevOpsActivityEvents | Measure-Object).Count
                "Dumping $($nbAzureDevOpsActivityEvents) Azure DevOps activity logs events to $($outputFile)" | Write-Log -LogPath $logFile
                $azureDevOpsActivityEvents | ConvertTo-Json -Depth 99 | Out-File $outputFile -Encoding UTF8
            }
            else {
                "No Azure DevOps activity logs event to dump to $($outputFile)" | Write-Log -LogPath $logFile -LogLevel "Warning"
            }
        }
    }

    $totalTimeSpan = (New-TimeSpan -Start $startDate -End $endDate)

    if (($totalTimeSpan.Hours -eq 0) -and ($totalTimeSpan.Minutes -eq 0) -and ($totalTimeSpan.Seconds -eq 0)){
        $totaldays = $totalTimeSpan.days
        $totalLoops = $totaldays
    }
    else {
        $totaldays = $totalTimeSpan.days + 1
        $totalLoops = $totalTimeSpan.days
    }

    Get-RSJob | Remove-RSJob -Force

    foreach ($organization in $wantedOrganizationsNameAndId){
        Write-Host "Starting processing Azure DevOps activity logs for $($organization.'Organization Name') ($($organization.'Organization Id')) Azure DevOps organization"
        "Starting processing Azure DevOps activity logs for $($organization.'Organization Name') ($($organization.'Organization Id')) Azure DevOps organization" | Write-Log -LogPath $logFile

        for ($d=0; $d -le $totalLoops; $d++){
            if ($d -eq 0){
                $newStartDate = $startDate
                $newEndDate = Get-Date("{0:yyyy-MM-dd} 00:00:00.000" -f ($newStartDate.AddDays(1)))
            }
            elseif ($d -eq $totaldays){
                $newEndDate = $endDate
                $newStartDate = Get-Date("{0:yyyy-MM-dd} 00:00:00.000" -f ($newEndDate))
            }
            else {
                $newStartDate = $newEndDate
                $newEndDate = $newEndDate.AddDays(1)
            }

            "Lauching job number $($d) with startDate {0:yyyy-MM-dd} {0:HH:mm:ss} and endDate {1:yyyy-MM-dd} {1:HH:mm:ss}" -f ($newStartDate, $newEndDate) | Write-Log -LogPath $logFile
            $dateToProcess = ($newStartDate.ToString("yyyy-MM-dd"))
            $organizationName = $organization.'Organization Name'
            $jobName = "AzDevOps_" + $organizationName + "_" + $dateToProcess

            Start-RSJob -Name $jobName -ScriptBlock $Launchsearch -FunctionsToImport Write-Log, Get-AzDevOpsAuditLogs -ArgumentList $newStartDate, $newEndDate, $currentPath, $organizationName, $appId, $tenant, $certificatePath, $certificateSecurePassword, $needPassword

            $maxJobRunning = 3

            $jobRunningCount = (Get-RSJob | Where-Object {$_.State -eq "Running"} | Measure-Object).Count
            while ($jobRunningCount -ge $maxJobRunning){
                Start-Sleep -Seconds 1
                $jobRunningCount = (Get-RSJob | Where-Object {$_.State -eq "Running"} | Measure-Object).Count
            }
            $jobsDone = Get-RSJob | Where-Object {$_.State -eq "Completed"}
            if ($jobsDone){
                foreach ($jobDone in $jobsDone){
                    "Runspace Job $($jobDone.Name) has finished - dumping log" | Write-Log -LogPath $logFile
                    $logFileName = $jobDone.Name + ".log"
                    Get-Content $logFileName | Out-File $logFile -Encoding UTF8 -Append
                    Remove-Item $logFileName -Confirm:$false -Force
                    $jobDone | Remove-RSJob
                    "Runspace Job $($jobDone.Name) finished - job removed" | Write-Log -LogPath $logFile
                }
            }
            $jobsFailed = Get-RSJob | Where-Object {$_.State -eq "Failed"}
            if ($jobsFailed){
                foreach ($jobFailed in $jobsFailed){
                    "Runspace Job $($jobFailed.Name) failed with error $($jobFailed.Error)" | Write-Log -LogPath $logFile -LogLevel "Error"
                    "Runspace Job $($jobFailed.Name) failed - dumping log" | Write-Log -LogPath $logFile -LogLevel "Error"
                    $logFileName = $jobFailed.Name + ".log"
                    Get-Content $logFileName | Out-File $logFile -Encoding UTF8 -Append
                    Remove-Item $logFileName -Confirm:$false -Force
                    $jobFailed | Remove-RSJob
                    "Runspace Job $($jobFailed.Name) failed - job removed" | Write-Log -LogPath $logFile -LogLevel "Error"
                }
            }
        }

        # Waiting for final jobs to complete
        $jobRunningCount = (Get-RSJob | Where-Object {$_.State -eq "Running"} | Measure-Object).Count
        while ($jobRunningCount -ge 1){
            Start-Sleep -Seconds 1
            $jobRunningCount = (Get-RSJob | Where-Object {$_.State -eq "Running"} | Measure-Object).Count
        }
        $jobsDone = Get-RSJob | Where-Object {$_.State -eq "Completed"}
        if ($jobsDone){
            foreach ($jobDone in $jobsDone){
                "Runspace Job $($jobDone.Name) has finished - dumping log" | Write-Log -LogPath $logFile
                $logFileName = $jobDone.Name + ".log"
                Get-Content $logFileName | Out-File $logFile -Encoding UTF8 -Append
                Remove-Item $logFileName -Confirm:$false -Force
                $jobDone | Remove-RSJob
                "Runspace Job $($jobDone.Name) finished - job removed" | Write-Log -LogPath $logFile
            }
        }
        $jobsFailed = Get-RSJob | Where-Object {$_.State -eq "Failed"}
        if ($jobsFailed){
            foreach ($jobFailed in $jobsFailed){
                "Runspace Job $($jobFailed.Name) failed with error $($jobFailed.Error)" | Write-Log -LogPath $logFile -LogLevel "Error"
                "Runspace Job $($jobFailed.Name) failed - dumping log" | Write-Log -LogPath $logFile -LogLevel "Error"
                $logFileName = $jobFailed.Name + ".log"
                Get-Content $logFileName | Out-File $logFile -Encoding UTF8 -Append
                Remove-Item $logFileName -Confirm:$false -Force
                $jobFailed | Remove-RSJob
                "Runspace Job $($jobFailed.Name) failed - job removed" | Write-Log -LogPath $logFile -LogLevel "Error"
            }
        }
    }
}