Public/Get-SnowSyslog.ps1

function Get-SnowSyslogNewRow {
    # Returns only rows not yet seen, given the newest timestamp and the sys_ids already emitted at that timestamp.
    param(
        [AllowEmptyCollection()][object[]]$Row = @(),
        [string]$LastCreated,
        [System.Collections.Generic.HashSet[string]]$SeenIds
    )

    foreach ($item in $Row) {
        $created = [string]$item.sys_created_on
        if ($LastCreated -and $created -lt $LastCreated) { continue }
        if ($LastCreated -and $created -eq $LastCreated -and $SeenIds.Contains([string]$item.sys_id)) { continue }
        $item
    }
}

function Write-SnowSyslogLine {
    param([Parameter(Mandatory)][object]$Row)

    $level = if ($Row.PSObject.Properties['level']) { [string]$Row.level } else { '' }
    $name = switch ($level) { { $_ -in '2', 'error' } { 'error' } { $_ -in '1', 'warning' } { 'warning' } { $_ -in '3', 'debug' } { 'debug' } default { 'info' } }
    $color = switch ($name) { 'error' { 'Red' } 'warning' { 'Yellow' } 'debug' { 'DarkGray' } default { 'Gray' } }
    $source = if ($Row.PSObject.Properties['source']) { [string]$Row.source } else { '' }
    $message = if ($Row.PSObject.Properties['message']) { [string]$Row.message } elseif ($Row.PSObject.Properties['url']) { [string]$Row.url } else { '' }
    Write-Host ('{0} {1,-7} {2} {3}' -f $Row.sys_created_on, $name.ToUpperInvariant(), $source, $message) -ForegroundColor $color
}

function Get-SnowSyslog {
    <#
        .SYNOPSIS
        Queries the ServiceNow system log, optionally following new entries like tail -f.
        .DESCRIPTION
        Rows are returned oldest first. A time or row limit is always applied: by default the
        last 15 minutes, capped at 100 rows. With -Wait the log is polled every -PollSeconds
        (minimum 2) and only new rows are emitted; the last seen sys_created_on plus the sys_ids
        seen at that timestamp prevent duplicates and gaps. Failed polls back off and retry.
        Press Ctrl+C to stop. Use -Pretty for coloured console output instead of objects.
        .PARAMETER Table
        syslog (default), syslog_transaction or syslog_app_scope.
        .PARAMETER Level
        error, warning, info or debug.
        .PARAMETER Source
        Filters on the log source.
        .PARAMETER Message
        Filters on text contained in the message.
        .PARAMETER User
        Filters on the user (sys_created_by).
        .PARAMETER Since
        Returns rows created after this time.
        .PARAMETER Last
        The maximum number of rows to return (default 100).
        .PARAMETER Wait
        Follows new rows until stopped.
        .PARAMETER PollSeconds
        Seconds between polls when -Wait is used (2-3600, default 5).
        .PARAMETER MaxPolls
        Stops after this many polls; 0 means unlimited.
        .PARAMETER Pretty
        Writes coloured lines to the console instead of returning objects.
        .PARAMETER Fields
        The fields to return.
        .PARAMETER Backend
        Rest, Sdk or Auto.
        .EXAMPLE
        Get-SnowSyslog -Level error -Last 50

        Shows the last 50 errors from the past 15 minutes.
        .EXAMPLE
        Get-SnowSyslog -Wait -Pretty -Source 'Flow Engine' -Message timeout -PollSeconds 10

        Watches for flow timeouts while you reproduce a problem.
        .EXAMPLE
        Get-SnowSyslog -Level error -Since (Get-Date).AddHours(-2) -Last 500 | Group-Object source | Sort-Object Count -Descending | Select-Object -First 5 Name, Count

        Finds the noisiest error sources in the last two hours.
    #>

    [CmdletBinding()]
    [OutputType([pscustomobject])]
    param(
        [ValidateSet('syslog', 'syslog_transaction', 'syslog_app_scope')][string]$Table = 'syslog',
        [ValidateSet('error', 'warning', 'info', 'debug')][string]$Level,
        [string]$Source,
        [string]$Message,
        [string]$User,
        [datetime]$Since,
        [ValidateRange(1, 10000)][int]$Last = 100,
        [switch]$Wait,
        [ValidateRange(2, 3600)][int]$PollSeconds = 5,
        [ValidateRange(0, 1000000)][int]$MaxPolls = 0,
        [switch]$Pretty,
        [string[]]$Fields,
        [ValidateSet('Rest', 'Sdk', 'Auto')][string]$Backend
    )

    if ($Table -eq 'syslog_transaction' -and ($Level -or $Source)) {
        throw [System.ArgumentException]::new('-Level and -Source are not supported for syslog_transaction.')
    }
    $levelValues = @{ info = '0'; warning = '1'; error = '2'; debug = '3' }
    $terms = [System.Collections.Generic.List[string]]::new()
    if ($Level) { $terms.Add("level=$($levelValues[$Level])") }
    if ($Source) { $terms.Add("source=$(ConvertTo-SnowQueryValue $Source)") }
    if ($Message) {
        $messageField = if ($Table -eq 'syslog_transaction') { 'url' } else { 'message' }
        $terms.Add("${messageField}LIKE$(ConvertTo-SnowQueryValue $Message)")
    }
    if ($User) { $terms.Add("sys_created_by=$(ConvertTo-SnowQueryValue $User)") }
    $sinceTime = if ($PSBoundParameters.ContainsKey('Since')) { $Since.ToUniversalTime() } else { [datetime]::UtcNow.AddMinutes(-15) }
    $format = 'yyyy-MM-dd HH:mm:ss'
    $sinceText = $sinceTime.ToString($format, [Globalization.CultureInfo]::InvariantCulture)
    $terms.Add("sys_created_on>=$sinceText")
    $filter = $terms -join '^'

    $request = @{ Table = $Table; Fields = $Fields; Backend = $Backend }
    if ($Fields) {
        $requiredFields = @('sys_id', 'sys_created_on')
        if ($Pretty) {
            if ($Table -eq 'syslog_transaction') { $requiredFields += 'url' }
            else { $requiredFields += @('level', 'source', 'message') }
        }
        $request.Fields = @($Fields + $requiredFields | Select-Object -Unique)
    }
    $emit = {
        param($row)
        if ($Pretty) { Write-SnowSyslogLine -Row $row }
        else {
            if ($row -is [System.Collections.IDictionary]) { $row = [pscustomobject]$row }
            if ($row -is [pscustomobject]) { $row.PSTypeNames.Insert(0, "Snowplow.Record.$Table") }
            $row
        }
    }

    # Initial query: newest -Last rows within the window, then oldest first.
    $initial = @(Invoke-SnowBackendRequest @request -Query "$filter^ORDERBYDESCsys_created_on^ORDERBYDESCsys_id" -PageSize ([Math]::Min($Last, 1000)) -First $Last)
    [array]::Reverse($initial)
    $lastCreated = $sinceText
    $seen = [System.Collections.Generic.HashSet[string]]::new()
    foreach ($row in $initial) {
        $created = [string]$row.sys_created_on
        if ($created -gt $lastCreated) { $lastCreated = $created; $seen.Clear() }
        if ($created -eq $lastCreated) { [void]$seen.Add([string]$row.sys_id) }
        & $emit $row
    }
    if (-not $Wait) { return }

    Write-Warning "Following $Table every $PollSeconds second(s). Each poll is a request against the instance; press Ctrl+C to stop."
    $failures = 0
    $polls = 0
    try {
        while ($true) {
            $delay = if ($failures) { [Math]::Min($PollSeconds * [Math]::Pow(2, $failures), 300) } else { $PollSeconds }
            Start-SnowSleep -Seconds $delay
            $polls++
            $pollFilter = (@($terms | Where-Object { $_ -notlike 'sys_created_on>=*' }) + "sys_created_on>=$lastCreated") -join '^'
            try {
                $rows = @(Invoke-SnowBackendRequest @request -Query "$pollFilter^ORDERBYsys_created_on^ORDERBYsys_id" -PageSize 1000)
                $failures = 0
            }
            catch {
                $failures++
                if ($failures -ge 5) { throw }
                Write-Warning "Syslog poll failed ($($_.Exception.Message)); backing off."
                $rows = @()
            }
            foreach ($row in @(Get-SnowSyslogNewRow -Row $rows -LastCreated $lastCreated -SeenIds $seen)) {
                $created = [string]$row.sys_created_on
                if ($created -gt $lastCreated) { $lastCreated = $created; $seen.Clear() }
                [void]$seen.Add([string]$row.sys_id)
                & $emit $row
            }
            if ($MaxPolls -and $polls -ge $MaxPolls) { break }
        }
    }
    finally {
        Write-Verbose 'Syslog follow stopped.'
    }
}