Skip to content

perf(logs): filters before the entry is built, one expression for the event table - #18

Merged
fadwen merged 1 commit into
perf/ast-indexfrom
perf/agent-log
Oct 6, 2026
Merged

fadwen merged 1 commit into
perf/ast-indexfrom
perf/agent-log

Conversation

@fadwen

@fadwen fadwen commented Oct 6, 2026

Copy link
Copy Markdown
Owner

Summary

Get-IntuneAgentLog built an object for every record of every log, filtered afterwards, then classified each entry with a call that tried 44 regular expressions one by one. Each of those is a PowerShell statement run 80,000 times for a 15 MB agent log, and together they were most of what a read cost. The filters now run on the raw record before an entry is built, the event table is one expression matched once per message, and a rolled-over file last written before -After is not read. Results are unchanged.

Measured on a 15 MB log of 80,000 entries, fresh process, PowerShell 7.6.6, this change against main:

Read Before After
Every entry 62 s 30 s
One policy's entries, -Id 25 s 5 s
-EventName ScriptExit -Last 5 90 s 12 s
Get-IntuneAgentTimeline -Id 26 s 5 s

On Windows PowerShell 5.1 the same -Id read takes 2 s.

Stacked on #17 (base branch); the diff is this change only. Merge #16, #17, then this.

Changes

  • Private/ConvertFrom-IslCmTraceLog.ps1. Takes -Level, -After, -Before, -Pattern and -Id, the filters Get-IntuneAgentLog offers, and -Contains, and applies them to the raw record before the object is built. Line numbers are counted over the skipped text in one call per kept entry. The three attribute regexes become one compiled expression in the order every agent log writes them; the timestamp is one TryParseExact instead of two regexes and six conversions.
  • Private/Get-IslAgentLogEvent.ps1. Takes a batch of messages, one result each in order. The 44 patterns are compiled into one alternation with every capturing group named, so the matched alternative is the first successful group after group 0, found with Enumerable.FirstOrDefault and a Group.Success delegate rather than a loop. Alternation keeps the table's order at a position, so a message two patterns fit resolves to the earlier one, as before. Each definition also carries the literal text its pattern starts with.
  • Public/Get-IntuneAgentLog.ps1. Passes the filters to the parser; with -EventName, passes the wanted events' leading literals as -Contains when every one of them has one; classifies a file's kept messages in one call; skips a file whose last write is before -After.
  • Private/ConvertTo-IslCmTraceTime.ps1. One TryParseExact with M-d-yyyy H:mm:ss.FFFFFFF, the bias cut off first.
  • Tests. Each parser filter against the fixture log, with the line numbers of the kept entries; a batch of four messages, one result each in order; the relationship report, which two patterns fit, resolves to the earlier one; the rolled-over fixture file is not read for a later -After.

Verification

  • Unit and integration suites pass on PowerShell 7.6.6; the parser, classifier, log, timeline and diagnostic tests pass on Windows PowerShell 5.1 (107 of 107). PSScriptAnalyzer (Error and Warning) is clean; no line over 115 characters.
  • The before and after figures come from the same script run against the stashed and unstashed tree in fresh processes; entry counts matched in every case (80,000; 134; 5; 1).
  • Get-IntuneAgentLog's parameters and output are unchanged, so no help changes.

Notes

  • The remaining 30 s for every entry is object construction: about 100 µs per [pscustomobject] with eleven properties, 80,000 times. A class would halve it but changes the type name the format view binds to; left for a later change if anyone reads whole logs that size.

@fadwen fadwen left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Notes on the lines whose reason the diff does not show.

if ($wantedTypes -and $type -notin $wantedTypes) { continue }

$message = $groups['message'].Value
if ($Pattern -and $message -notmatch $Pattern) { continue }

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The -match operator on purpose, not a cached regex: Get-IntuneAgentLog applied -Pattern with -notmatch, which is case-insensitive, and a Regex object would be case-sensitive unless told otherwise. Same result as before for every pattern.


# The newlines between the last kept entry and this one, in one .NET call
if ($match.Index -gt $counted) {
$line += $text.Substring($counted, $match.Index - $counted).Split("`n").Length - 1

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Lines are counted lazily so a skipped entry costs nothing here; the gap can span many skipped records, so it is one Substring and Split rather than an IndexOf loop, which ran once per newline in PowerShell (3 s of the 80,000-entry read).


# component, then type, then thread, in the order every agent log writes them; context and file
# sit between and after them and are not read
$script:IslCmTraceAttributeRegex = [regex]::new('component="([^"]*)".*?type="(\d)".*?thread="(\d+)"',

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One expression in the agent's attribute order replaces three. If a record lacks any of the three the whole match fails and the entry falls back to the defaults (no component, Information, no thread), as it did when the individual match failed.

# The same pattern with its capturing groups named <prefix><n>, so every group of the
# combined expression below has a name and the matched alternative is the first group that
# succeeded. A "(" opens a capture unless it is escaped, inside a class, or followed by "?"
function ConvertTo-NamedGroup {

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Every group needs a name because .NET numbers unnamed groups before named ones: with the inner captures left unnamed, the first successful group after group 0 would be a capture from the matched alternative, not the alternative itself.

Event = $definition.Event
Regex = [regex]::new($definition.Pattern, $singleline)
Event = $definition.Event
Regex = [regex]::new($definition.Pattern, $singleline)

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The per-definition regex is kept for -ListEvent, which prints the pattern, and for the tests that check a message against one definition. The matching path no longer uses it.

$_.Name -eq 'Skip' -and $_.GetParameters()[1].ParameterType -eq [int]
} | Select-Object -First 1).MakeGenericMethod($groupType)
$first = ($enumerable.GetMethods() | Where-Object {
$_.Name -eq 'FirstOrDefault' -and $_.GetParameters().Count -eq 2 -and

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

.NET 6 added FirstOrDefault(source, defaultValue), also two parameters, so the overload is picked by its predicate type rather than by parameter count; on .NET Framework the filter is harmless.

$needles = @(foreach ($definition in $script:IslAgentLogEvents) {
if ($definition.Event -in $EventName) { $definition.Needle }
})
if ($needles.Count -eq @($EventName | Select-Object -Unique).Count) { $parseSplat.Contains = $needles }

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The shortcut is taken only when every wanted event has a literal. AppDetectionRule's pattern starts with a group and has none; asking for it alongside others disables the prefilter rather than dropping its messages.

… event table

Get-IntuneAgentLog built an object for every record of every log, then
filtered, then classified each entry with a call that tried 44 regular
expressions one by one. Each of those is a PowerShell statement run
80,000 times for a 15 MB log, and together they were most of what a read
cost: 62 s for every entry, 25 s for one policy's entries, 90 s for an
event filter with -Last.

ConvertFrom-IslCmTraceLog takes the filters Get-IntuneAgentLog offers
(-Level, -After, -Before, -Pattern, -Id, and -Contains for the literal
text the wanted events' patterns start with) and applies them to the raw
record, so an entry nobody wants is never built; the line number is
counted over the skipped text in one call per kept entry. The attribute
regexes are compiled once; the timestamp is one TryParseExact instead of
two regexes and six conversions. Get-IslAgentLogEvent takes a batch of
messages and matches one combined expression per message, every group
named, the matched alternative found with Enumerable.FirstOrDefault
rather than a loop over 44 candidates; alternation keeps the table's
order at a position, so a message two patterns fit resolves as before.
Get-IntuneAgentLog classifies a file's kept messages in one call and
skips a rolled-over file last written before -After.

Measured on the 15 MB log, fresh process, PowerShell 7.6: every entry
30 s against 62 s; -Id 5 s against 25 s; -EventName -Last 12 s against
90 s; a timeline by id 5 s against 26 s. Same entries in every case.
@fadwen
fadwen added this pull request to stack #24 October 6, 2026 16:17
@fadwen
fadwen merged commit 836f806 into main Oct 6, 2026
4 checks passed
@fadwen
fadwen deleted the perf/agent-log branch October 6, 2026 16:23
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant