Public/Send-FylgyrToLogAnalytics.ps1

function Send-FylgyrToLogAnalytics {
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseSingularNouns', '', Justification = 'LogAnalytics matches Azure product naming.')]
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSReviewUnusedParameter', 'StreamName', Justification = 'StreamName is consumed in ingestion endpoint construction.')]
    [CmdletBinding()]
    [OutputType([PSCustomObject])]
    param(
        [Parameter(Mandatory, ValueFromPipeline)]
        [string[]]$InputObject,

        [Parameter(Mandatory)]
        [ValidatePattern('^dcr-[a-zA-Z0-9-]+$')]
        [string]$DcrImmutableId,

        [string]$DceUri,

        [string]$DcrEndpointUri,

        [Parameter(Mandatory)]
        [ValidatePattern('^Custom-[A-Za-z0-9_]+$')]
        [string]$StreamName,

        [ValidatePattern('^[0-9a-fA-F-]{36}$')]
        [string]$ClientId,

        [ValidatePattern('^[0-9a-fA-F-]{36}$')]
        [string]$TenantId,

        [SecureString]$ClientSecret,

        [switch]$UseManagedIdentity,

        [string]$FederatedToken,

        [string]$FederatedTokenFile,

        [ValidateRange(1, 5000)]
        [int]$BatchSize = 500,

        [ValidateRange(1, 10)]
        [int]$MaxRetries = 5
    )

    begin {
        $lines = [System.Collections.Generic.List[string]]::new()
    }

    process {
        foreach ($line in @($InputObject)) {
            if ([string]::IsNullOrWhiteSpace($line)) {
                continue
            }

            # Accept both NDJSON-as-single-string and pre-split line arrays.
            foreach ($ndjsonLine in @([string]$line -split "`r?`n")) {
                if (-not [string]::IsNullOrWhiteSpace($ndjsonLine)) {
                    $lines.Add($ndjsonLine)
                }
            }
        }
    }

    end {
        if ($lines.Count -eq 0) {
            return [PSCustomObject]@{
                SentBatches = 0
                SentRecords = 0
                Endpoint = $null
            }
        }

        $baseIngestionUri = $null
        if ($DcrEndpointUri) {
            $baseIngestionUri = $DcrEndpointUri.TrimEnd('/')
        }
        elseif ($DceUri) {
            $baseIngestionUri = $DceUri.TrimEnd('/')
        }
        else {
            throw 'Provide either -DcrEndpointUri or -DceUri.'
        }

        $token = $null
        if ($UseManagedIdentity) {
            try {
                if (-not [string]::IsNullOrWhiteSpace($env:IDENTITY_ENDPOINT) -and -not [string]::IsNullOrWhiteSpace($env:IDENTITY_HEADER)) {
                    # App Service managed identity endpoint.
                    $msiUri = '{0}?api-version=2019-08-01&resource={1}' -f $env:IDENTITY_ENDPOINT, [System.Uri]::EscapeDataString('https://monitor.azure.com/')
                    if ($ClientId) {
                        $msiUri = "$msiUri&client_id=$ClientId"
                    }

                    $msiResponse = Invoke-RestMethod -Method GET -Uri $msiUri -Headers @{ 'X-IDENTITY-HEADER' = $env:IDENTITY_HEADER } -ErrorAction Stop
                }
                elseif (-not [string]::IsNullOrWhiteSpace($env:MSI_ENDPOINT) -and -not [string]::IsNullOrWhiteSpace($env:MSI_SECRET)) {
                    # Legacy App Service managed identity endpoint.
                    $msiUri = '{0}?api-version=2017-09-01&resource={1}' -f $env:MSI_ENDPOINT, [System.Uri]::EscapeDataString('https://monitor.azure.com/')
                    if ($ClientId) {
                        $msiUri = "$msiUri&clientid=$ClientId"
                    }

                    $msiResponse = Invoke-RestMethod -Method GET -Uri $msiUri -Headers @{ Secret = $env:MSI_SECRET } -ErrorAction Stop
                }
                else {
                    # IMDS endpoint fallback for environments where App Service identity endpoints are not exposed.
                    $msiUri = 'http://169.254.169.254/metadata/identity/oauth2/token?api-version=2018-02-01&resource=https%3A%2F%2Fmonitor.azure.com%2F'
                    if ($ClientId) {
                        $msiUri = "$msiUri&client_id=$ClientId"
                    }

                    $msiResponse = Invoke-RestMethod -Method GET -Uri $msiUri -Headers @{ Metadata = 'true' } -ErrorAction Stop
                }

                $token = [string]$msiResponse.access_token
            }
            catch {
                throw "Managed identity token acquisition failed: $($_.Exception.Message)"
            }
        }
        else {
            if (-not $TenantId -or -not $ClientId) {
                throw 'ClientId and TenantId are required for non-managed-identity authentication.'
            }

            if (-not $FederatedToken -and $FederatedTokenFile -and (Test-Path -Path $FederatedTokenFile -PathType Leaf)) {
                $FederatedToken = Get-Content -Path $FederatedTokenFile -Raw
            }

            if (-not $FederatedToken -and $env:AZURE_FEDERATED_TOKEN_FILE -and (Test-Path -Path $env:AZURE_FEDERATED_TOKEN_FILE -PathType Leaf)) {
                $FederatedToken = Get-Content -Path $env:AZURE_FEDERATED_TOKEN_FILE -Raw
            }

            try {
                if ($FederatedToken) {
                    $tokenResponse = Invoke-RestMethod -Method POST -Uri "https://login.microsoftonline.com/$TenantId/oauth2/v2.0/token" -Body @{
                        client_id = $ClientId
                        scope = 'https://monitor.azure.com//.default'
                        grant_type = 'client_credentials'
                        client_assertion_type = 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer'
                        client_assertion = $FederatedToken.Trim()
                    } -ContentType 'application/x-www-form-urlencoded' -ErrorAction Stop
                    $token = [string]$tokenResponse.access_token
                }
                else {
                    if (-not $ClientSecret) {
                        throw 'ClientSecret is required when no managed identity or federated token is provided.'
                    }

                    $plainSecret = [System.Net.NetworkCredential]::new('', $ClientSecret).Password
                    $tokenResponse = Invoke-RestMethod -Method POST -Uri "https://login.microsoftonline.com/$TenantId/oauth2/v2.0/token" -Body @{
                        client_id = $ClientId
                        client_secret = $plainSecret
                        scope = 'https://monitor.azure.com//.default'
                        grant_type = 'client_credentials'
                    } -ContentType 'application/x-www-form-urlencoded' -ErrorAction Stop
                    $token = [string]$tokenResponse.access_token
                }
            }
            catch {
                throw "Service principal token acquisition failed: $($_.Exception.Message)"
            }
        }

        if (-not $token) {
            throw 'Failed to obtain access token for Logs Ingestion API.'
        }

        $ingestionUri = '{0}/dataCollectionRules/{1}/streams/{2}?api-version=2023-01-01' -f $baseIngestionUri, $DcrImmutableId, $StreamName
        $headers = @{ Authorization = "Bearer $token" }

        $sentBatches = 0
        $sentRecords = 0
        for ($offset = 0; $offset -lt $lines.Count; $offset += $BatchSize) {
            $count = [Math]::Min($BatchSize, $lines.Count - $offset)
            $batchRecords = [System.Collections.Generic.List[object]]::new()

            for ($i = 0; $i -lt $count; $i++) {
                $line = $lines[$offset + $i]
                try {
                    $batchRecords.Add(($line | ConvertFrom-Json -Depth 25))
                }
                catch {
                    throw "Invalid NDJSON record at position $($offset + $i + 1): $($_.Exception.Message)"
                }
            }

            $payload = $batchRecords.ToArray() | ConvertTo-Json -Depth 25 -AsArray
            $attempt = 0
            $sent = $false
            while (-not $sent -and $attempt -lt $MaxRetries) {
                $attempt++
                try {
                    Invoke-RestMethod -Method POST -Uri $ingestionUri -Headers $headers -Body $payload -ContentType 'application/json' -ErrorAction Stop | Out-Null
                    $sent = $true
                }
                catch {
                    $message = $_.Exception.Message
                    $isTransient = $message -match '429|500|502|503|504|temporar|timeout'
                    if (-not $isTransient -or $attempt -ge $MaxRetries) {
                        throw "Log ingestion batch failed after $attempt attempt(s): $message"
                    }

                    $delay = [Math]::Pow(2, $attempt)
                    Start-Sleep -Seconds $delay
                }
            }

            $sentBatches++
            $sentRecords += $count
        }

        return [PSCustomObject]@{
            SentBatches = $sentBatches
            SentRecords = $sentRecords
            Endpoint = $ingestionUri
        }
    }
}