From f2a30d6736ebd6ec817797c9def2cbdaa61e54d8 Mon Sep 17 00:00:00 2001 From: jschick04 Date: Sat, 18 Jul 2026 01:25:30 +0000 Subject: [PATCH 1/3] Read Servicing update failure codes from UserData in the Error Code histogram --- .../Common/Events/EventColumnStore.cs | 92 ++++++++++- .../Common/Events/EventColumnStoreReader.cs | 8 +- .../Common/Events/IEventColumnReader.cs | 16 +- .../Histogram/HistogramBuilder.cs | 9 +- .../LogTable/CombinedColumnView.cs | 15 +- .../LogTable/EventColumnView.cs | 5 +- .../LogTable/IEventColumnView.cs | 18 +- .../Common/Events/LegacyEventColumnReader.cs | 54 +++++- .../EventDataTestFactory.cs | 16 ++ .../Events/EventColumnStoreEventDataTests.cs | 154 ++++++++++++++++-- .../EventColumnStoreReaderParityTests.cs | 20 ++- .../Histogram/HistogramBuilderTests.cs | 25 +++ .../TestSupport/LegacyEventColumnView.cs | 8 +- 13 files changed, 395 insertions(+), 45 deletions(-) diff --git a/src/EventLogExpert.Eventing/Common/Events/EventColumnStore.cs b/src/EventLogExpert.Eventing/Common/Events/EventColumnStore.cs index 06689c7d..8840eba8 100644 --- a/src/EventLogExpert.Eventing/Common/Events/EventColumnStore.cs +++ b/src/EventLogExpert.Eventing/Common/Events/EventColumnStore.cs @@ -287,6 +287,7 @@ internal void BucketTimeTicksByEventDataHResult( int bucketCount, string fieldName, IReadOnlyCollection eligibleProviders, + IReadOnlyList userDataErrorCodePaths, long[] targetCodes, int slotCount, int[] slotCounts, @@ -297,6 +298,10 @@ internal void BucketTimeTicksByEventDataHResult( ? stackalloc int[eligibleProviders.Count] : new int[eligibleProviders.Count]; ReadOnlySpan eligible = ResolveEligibleSourceIndices(eligibleProviders, eligibleBuffer); + Span userDataPathBuffer = userDataErrorCodePaths.Count <= MaxStackProviderNames + ? stackalloc int[userDataErrorCodePaths.Count] + : new int[userDataErrorCodePaths.Count]; + ReadOnlySpan userDataPathIndices = ResolveUserDataPathIndices(userDataErrorCodePaths, userDataPathBuffer); int[] fieldIndexBySchema = NewSchemaFieldMemo(); int offset = 0; @@ -315,7 +320,8 @@ internal void BucketTimeTicksByEventDataHResult( // holds only real failure codes beyond the top-N rather than the non-failure population. if (eligible.BinarySearch(sourceColumn[row]) < 0) { continue; } - if (!TryGetSealedEventDataHResult(chunk, row, fieldName, fieldIndexBySchema, out long code)) { continue; } + if (!TryGetSealedEventDataHResult(chunk, row, fieldName, fieldIndexBySchema, out long code) + && !TryGetSealedUserDataHResult(chunk, row, userDataPathIndices, out code)) { continue; } int bucket = ToBucket(timeColumn[row], minTicks, bucketSpanTicks, bucketCount); slotCounts[(bucket * slotCount) + SlotForCode(code, targetCodes, otherSlot)]++; @@ -336,7 +342,8 @@ internal void BucketTimeTicksByEventDataHResult( if (!eligibleNames.Contains(pending.Source)) { continue; } - if (!TryGetPendingEventDataHResult(pending, fieldName, out long code)) { continue; } + if (!TryGetPendingEventDataHResult(pending, fieldName, out long code) + && !TryGetPendingUserDataHResult(pending, userDataErrorCodePaths, out code)) { continue; } int bucket = ToBucket(pending.TimeCreated.Ticks, minTicks, bucketSpanTicks, bucketCount); slotCounts[(bucket * slotCount) + SlotForCode(code, targetCodes, otherSlot)]++; @@ -666,6 +673,7 @@ internal void CountEventDataHResults( ReadOnlySpan rankByPhysical, string fieldName, IReadOnlyCollection eligibleProviders, + IReadOnlyList userDataErrorCodePaths, IDictionary counts, CancellationToken cancellationToken) { @@ -674,6 +682,10 @@ internal void CountEventDataHResults( : new int[eligibleProviders.Count]; ReadOnlySpan eligible = ResolveEligibleSourceIndices(eligibleProviders, eligibleBuffer); + Span userDataPathBuffer = userDataErrorCodePaths.Count <= MaxStackProviderNames + ? stackalloc int[userDataErrorCodePaths.Count] + : new int[userDataErrorCodePaths.Count]; + ReadOnlySpan userDataPathIndices = ResolveUserDataPathIndices(userDataErrorCodePaths, userDataPathBuffer); int[] fieldIndexBySchema = NewSchemaFieldMemo(); int offset = 0; @@ -689,7 +701,8 @@ internal void CountEventDataHResults( if (eligible.BinarySearch(sourceColumn[row]) < 0) { continue; } - if (TryGetSealedEventDataHResult(chunk, row, fieldName, fieldIndexBySchema, out long code)) + if (TryGetSealedEventDataHResult(chunk, row, fieldName, fieldIndexBySchema, out long code) + || TryGetSealedUserDataHResult(chunk, row, userDataPathIndices, out code)) { counts[code] = counts.TryGetValue(code, out int existing) ? existing + 1 : 1; } @@ -710,7 +723,8 @@ internal void CountEventDataHResults( if (!eligibleNames.Contains(pending.Source)) { continue; } - if (TryGetPendingEventDataHResult(pending, fieldName, out long code)) + if (TryGetPendingEventDataHResult(pending, fieldName, out long code) + || TryGetPendingUserDataHResult(pending, userDataErrorCodePaths, out code)) { counts[code] = counts.TryGetValue(code, out int existing) ? existing + 1 : 1; } @@ -1218,6 +1232,16 @@ private static (long Min, long Max) ComputeTimeRange(IReadOnlyList paths, string value) + { + for (int index = 0; index < paths.Count; index++) + { + if (string.Equals(paths[index], value, StringComparison.Ordinal)) { return true; } + } + + return false; + } + private static int FindChunk(int[] prefix, int index) { int low = 0; @@ -1331,6 +1355,28 @@ private static bool TryGetPendingEventDataHResult(ResolvedEvent pending, string return false; } + private static bool TryGetPendingUserDataHResult(ResolvedEvent pending, IReadOnlyList userDataErrorCodePaths, out long code) + { + if (!pending.UserData.IsDefaultOrEmpty) + { + foreach (UserDataField field in pending.UserData) + { + if (!ContainsOrdinal(userDataErrorCodePaths, field.Path)) { continue; } + + if (!field.Values.IsDefaultOrEmpty && EventFieldValue.TryParseHResult32(field.Values[0], out uint hresult) && hresult != 0) + { + code = hresult; + + return true; + } + } + } + + code = 0; + + return false; + } + private (EventColumnChunk Chunk, int Row) Locate(int index) { int[] prefix = SealedPrefix(); @@ -1493,6 +1539,18 @@ private int ResolveSchemaFieldIndex(int schemaId, string fieldName) return SchemaFieldAbsent; } + private ReadOnlySpan ResolveUserDataPathIndices(IReadOnlyList paths, Span buffer) + { + int count = 0; + + for (int index = 0; index < paths.Count; index++) + { + if (count < buffer.Length && _pool.TryGetIndex(paths[index], out int poolIndex)) { buffer[count++] = poolIndex; } + } + + return buffer[..count]; + } + private int[] SealedPrefix() { int[]? prefix = Volatile.Read(ref _sealedPrefix); @@ -1612,6 +1670,32 @@ private bool TryGetSealedEventDataHResult(EventColumnChunk chunk, int row, strin return TryGetHResult32FromRawField(chunk.RowEventDataField(row, fieldIndex), out code) && code != 0; } + private bool TryGetSealedUserDataHResult(EventColumnChunk chunk, int row, ReadOnlySpan targetPathIndices, out long code) + { + if (!targetPathIndices.IsEmpty) + { + int count = chunk.RowUserDataCount(row); + + for (int field = 0; field < count; field++) + { + if (targetPathIndices.IndexOf(chunk.RowUserDataPathIndex(row, field)) < 0) { continue; } + + ReadOnlySpan values = chunk.RowUserDataValues(row, field); + + if (values.Length > 0 && EventFieldValue.TryParseHResult32(PoolGet(values[0]), out uint hresult) && hresult != 0) + { + code = hresult; + + return true; + } + } + } + + code = 0; + + return false; + } + private bool TryGetWholeNumberFromRawField(in RawEventDataField field, out long code) { switch (field.Kind) diff --git a/src/EventLogExpert.Eventing/Common/Events/EventColumnStoreReader.cs b/src/EventLogExpert.Eventing/Common/Events/EventColumnStoreReader.cs index 65f02af3..14d269d0 100644 --- a/src/EventLogExpert.Eventing/Common/Events/EventColumnStoreReader.cs +++ b/src/EventLogExpert.Eventing/Common/Events/EventColumnStoreReader.cs @@ -66,12 +66,14 @@ public void BucketTimeTicksByEventDataHResult( int bucketCount, string fieldName, IReadOnlyCollection eligibleProviders, + IReadOnlyList userDataErrorCodePaths, long[] targetCodes, int[] slotCounts, CancellationToken cancellationToken) { ArgumentException.ThrowIfNullOrEmpty(fieldName); ArgumentNullException.ThrowIfNull(eligibleProviders); + ArgumentNullException.ThrowIfNull(userDataErrorCodePaths); ArgumentNullException.ThrowIfNull(targetCodes); ArgumentNullException.ThrowIfNull(slotCounts); ArgumentOutOfRangeException.ThrowIfNotEqual(rankByPhysical.Length, Count); @@ -81,7 +83,7 @@ public void BucketTimeTicksByEventDataHResult( int slotCount = targetCodes.Length + 1; ArgumentOutOfRangeException.ThrowIfLessThan(slotCounts.Length, bucketCount * slotCount); - _store.BucketTimeTicksByEventDataHResult(rankByPhysical, minTicks, bucketSpanTicks, bucketCount, fieldName, eligibleProviders, targetCodes, slotCount, slotCounts, cancellationToken); + _store.BucketTimeTicksByEventDataHResult(rankByPhysical, minTicks, bucketSpanTicks, bucketCount, fieldName, eligibleProviders, userDataErrorCodePaths, targetCodes, slotCount, slotCounts, cancellationToken); } public void BucketTimeTicksByEventId( @@ -211,15 +213,17 @@ public void CountEventDataHResults( ReadOnlySpan rankByPhysical, string fieldName, IReadOnlyCollection eligibleProviders, + IReadOnlyList userDataErrorCodePaths, IDictionary counts, CancellationToken cancellationToken) { ArgumentException.ThrowIfNullOrEmpty(fieldName); ArgumentNullException.ThrowIfNull(eligibleProviders); + ArgumentNullException.ThrowIfNull(userDataErrorCodePaths); ArgumentNullException.ThrowIfNull(counts); ArgumentOutOfRangeException.ThrowIfNotEqual(rankByPhysical.Length, Count); - _store.CountEventDataHResults(rankByPhysical, fieldName, eligibleProviders, counts, cancellationToken); + _store.CountEventDataHResults(rankByPhysical, fieldName, eligibleProviders, userDataErrorCodePaths, counts, cancellationToken); } public void CountEventDataValues(ReadOnlySpan rankByPhysical, string fieldName, IDictionary counts, CancellationToken cancellationToken) diff --git a/src/EventLogExpert.Eventing/Common/Events/IEventColumnReader.cs b/src/EventLogExpert.Eventing/Common/Events/IEventColumnReader.cs index 324ea762..360fd039 100644 --- a/src/EventLogExpert.Eventing/Common/Events/IEventColumnReader.cs +++ b/src/EventLogExpert.Eventing/Common/Events/IEventColumnReader.cs @@ -39,9 +39,11 @@ void BucketTimeTicksByEventData( /// /// HRESULT-code variant of for the ErrorCode dimension: each survivor - /// from a provider in whose field holds a nonzero - /// 32-bit HRESULT lands in its slot else the trailing "other" slot; every - /// ineligible-provider, absent-field, or zero/undecodable row is omitted (contributes to no slot). + /// from a provider in whose field - or, when that + /// EventData field is absent, whose UserData carries one of the curated + /// storage-key leaves (e.g. a servicing Cbs*/ErrorCode) - holds a nonzero 32-bit HRESULT lands in its + /// slot else the trailing "other" slot; every ineligible-provider, absent-field, or + /// zero/undecodable row is omitted (contributes to no slot). /// void BucketTimeTicksByEventDataHResult( ReadOnlySpan rankByPhysical, @@ -50,6 +52,7 @@ void BucketTimeTicksByEventDataHResult( int bucketCount, string fieldName, IReadOnlyCollection eligibleProviders, + IReadOnlyList userDataErrorCodePaths, long[] targetCodes, int[] slotCounts, CancellationToken cancellationToken); @@ -114,10 +117,11 @@ void BucketTimeTicksBySeverity( /// HRESULT-code variant of for the ErrorCode dimension: tallies survivors /// from a provider in by the nonzero 32-bit HRESULT in /// (a win:HexInt32 code that sign-extends negative is reinterpreted unsigned, and - /// hex / decimal string spellings fold to one code); ineligible-provider, absent-field, and zero/undecodable rows are - /// omitted. + /// hex / decimal string spellings fold to one code) or, when that EventData field is absent, in one of the curated + /// UserData storage-key leaves (e.g. a servicing Cbs*/ErrorCode hex + /// string); ineligible-provider, absent-field, and zero/undecodable rows are omitted. /// - void CountEventDataHResults(ReadOnlySpan rankByPhysical, string fieldName, IReadOnlyCollection eligibleProviders, IDictionary counts, CancellationToken cancellationToken); + void CountEventDataHResults(ReadOnlySpan rankByPhysical, string fieldName, IReadOnlyCollection eligibleProviders, IReadOnlyList userDataErrorCodePaths, IDictionary counts, CancellationToken cancellationToken); /// /// Group-by variant for a named EventData field of allowlisted numeric codes: tallies survivors by the field's diff --git a/src/EventLogExpert.Runtime/Histogram/HistogramBuilder.cs b/src/EventLogExpert.Runtime/Histogram/HistogramBuilder.cs index e3a3717c..b32ba85c 100644 --- a/src/EventLogExpert.Runtime/Histogram/HistogramBuilder.cs +++ b/src/EventLogExpert.Runtime/Histogram/HistogramBuilder.cs @@ -15,6 +15,11 @@ public static class HistogramBuilder // and servicing providers so a generic errorCode field on an unrelated provider does not pollute the failure view. private const string ErrorCodeFieldName = "errorCode"; + // Microsoft-Windows-Servicing stores its failure HRESULT in a UserData Cbs*ChangeState/ErrorCode leaf (a hex string) + // rather than an EventData errorCode field, so the ErrorCode dimension also probes these curated storage-key paths for + // an eligible row whose EventData lacks the code. CbsPackageInitiateChanges carries no ErrorCode and is not listed. + private static readonly string[] s_updateErrorCodeUserDataPaths = ["CbsPackageChangeState/ErrorCode", "CbsUpdateChangeState/ErrorCode"]; + private static readonly string[] s_updateProviders = ["Microsoft-Windows-WindowsUpdateClient", "Microsoft-Windows-Servicing"]; public static HistogramData? Build( @@ -75,7 +80,7 @@ private static HistogramData BuildByErrorCode( CancellationToken cancellationToken) { var counts = new Dictionary(); - view.CountEventDataHResults(ErrorCodeFieldName, s_updateProviders, counts, cancellationToken); + view.CountEventDataHResults(ErrorCodeFieldName, s_updateProviders, s_updateErrorCodeUserDataPaths, counts, cancellationToken); var minUtc = new DateTime(minTicks, DateTimeKind.Utc); var maxUtc = new DateTime(maxTicks, DateTimeKind.Utc); @@ -100,7 +105,7 @@ private static HistogramData BuildByErrorCode( int slotCount = targetCodes.Length + 1; int[] slotCounts = new int[bucketCount * slotCount]; - view.BucketTimeTicksByEventDataHResult(minTicks, bucketSpanTicks, bucketCount, ErrorCodeFieldName, s_updateProviders, targetCodes, slotCounts, cancellationToken); + view.BucketTimeTicksByEventDataHResult(minTicks, bucketSpanTicks, bucketCount, ErrorCodeFieldName, s_updateProviders, s_updateErrorCodeUserDataPaths, targetCodes, slotCounts, cancellationToken); int total = 0; diff --git a/src/EventLogExpert.Runtime/LogTable/CombinedColumnView.cs b/src/EventLogExpert.Runtime/LogTable/CombinedColumnView.cs index 2c1b44cf..235724ef 100644 --- a/src/EventLogExpert.Runtime/LogTable/CombinedColumnView.cs +++ b/src/EventLogExpert.Runtime/LogTable/CombinedColumnView.cs @@ -85,6 +85,7 @@ public void BucketTimeTicksByEventDataHResult( int bucketCount, string fieldName, IReadOnlyCollection eligibleProviders, + IReadOnlyList userDataErrorCodePaths, long[] targetCodes, int[] slotCounts, CancellationToken cancellationToken) @@ -96,6 +97,7 @@ public void BucketTimeTicksByEventDataHResult( bucketCount, fieldName, eligibleProviders, + userDataErrorCodePaths, targetCodes, slotCounts, cancellationToken); @@ -155,11 +157,20 @@ public void BucketTimeTicksBySeverity( } } - public void CountEventDataHResults(string fieldName, IReadOnlyCollection eligibleProviders, IDictionary counts, CancellationToken cancellationToken) + public void CountEventDataHResults( + string fieldName, + IReadOnlyCollection eligibleProviders, + IReadOnlyList userDataErrorCodePaths, + IDictionary counts, + CancellationToken cancellationToken) { foreach (var view in _views) { - view.CountEventDataHResults(fieldName, eligibleProviders, counts, cancellationToken); + view.CountEventDataHResults(fieldName, + eligibleProviders, + userDataErrorCodePaths, + counts, + cancellationToken); } } diff --git a/src/EventLogExpert.Runtime/LogTable/EventColumnView.cs b/src/EventLogExpert.Runtime/LogTable/EventColumnView.cs index 02565787..0ab31b3e 100644 --- a/src/EventLogExpert.Runtime/LogTable/EventColumnView.cs +++ b/src/EventLogExpert.Runtime/LogTable/EventColumnView.cs @@ -57,6 +57,7 @@ public void BucketTimeTicksByEventDataHResult( int bucketCount, string fieldName, IReadOnlyCollection eligibleProviders, + IReadOnlyList userDataErrorCodePaths, long[] targetCodes, int[] slotCounts, CancellationToken cancellationToken) => @@ -67,6 +68,7 @@ public void BucketTimeTicksByEventDataHResult( bucketCount, fieldName, eligibleProviders, + userDataErrorCodePaths, targetCodes, slotCounts, cancellationToken); @@ -119,9 +121,10 @@ public void BucketTimeTicksBySeverity( public void CountEventDataHResults( string fieldName, IReadOnlyCollection eligibleProviders, + IReadOnlyList userDataErrorCodePaths, IDictionary counts, CancellationToken cancellationToken) => - _reader.CountEventDataHResults(_rankByPhysical, fieldName, eligibleProviders, counts, cancellationToken); + _reader.CountEventDataHResults(_rankByPhysical, fieldName, eligibleProviders, userDataErrorCodePaths, counts, cancellationToken); public void CountEventDataValues( string fieldName, diff --git a/src/EventLogExpert.Runtime/LogTable/IEventColumnView.cs b/src/EventLogExpert.Runtime/LogTable/IEventColumnView.cs index c0a0c9ce..1fdce9f2 100644 --- a/src/EventLogExpert.Runtime/LogTable/IEventColumnView.cs +++ b/src/EventLogExpert.Runtime/LogTable/IEventColumnView.cs @@ -30,8 +30,10 @@ void BucketTimeTicksByEventData( /// /// HRESULT-code variant of for the ErrorCode dimension: only survivors - /// from a provider in whose field holds a nonzero - /// 32-bit HRESULT contribute (their target slot, else the trailing "other" slot); every other row is omitted. + /// from a provider in whose field - or, when that + /// EventData field is absent, whose UserData carries one of the curated + /// leaves - holds a nonzero 32-bit HRESULT contribute (their target slot, else the trailing "other" slot); every other + /// row is omitted. /// void BucketTimeTicksByEventDataHResult( long minTicks, @@ -39,6 +41,7 @@ void BucketTimeTicksByEventDataHResult( int bucketCount, string fieldName, IReadOnlyCollection eligibleProviders, + IReadOnlyList userDataErrorCodePaths, long[] targetCodes, int[] slotCounts, CancellationToken cancellationToken); @@ -79,9 +82,16 @@ void BucketTimeTicksBySeverity( /// /// HRESULT-code variant of for the ErrorCode dimension: tallies this view's /// survivors from a provider in by the nonzero 32-bit HRESULT in - /// (accumulating across a combined view); resolves the top-N failure codes. + /// or, when that EventData field is absent, one of the curated + /// UserData leaves (accumulating across a combined view); resolves the + /// top-N failure codes. /// - void CountEventDataHResults(string fieldName, IReadOnlyCollection eligibleProviders, IDictionary counts, CancellationToken cancellationToken); + void CountEventDataHResults( + string fieldName, + IReadOnlyCollection eligibleProviders, + IReadOnlyList userDataErrorCodePaths, + IDictionary counts, + CancellationToken cancellationToken); /// /// Tallies this view's rows by the whole-number code of a named EventData field (accumulating across a combined diff --git a/tests/Shared/EventLogExpert.Eventing.TestUtils/Common/Events/LegacyEventColumnReader.cs b/tests/Shared/EventLogExpert.Eventing.TestUtils/Common/Events/LegacyEventColumnReader.cs index e052f82d..0a6f3536 100644 --- a/tests/Shared/EventLogExpert.Eventing.TestUtils/Common/Events/LegacyEventColumnReader.cs +++ b/tests/Shared/EventLogExpert.Eventing.TestUtils/Common/Events/LegacyEventColumnReader.cs @@ -81,12 +81,14 @@ public void BucketTimeTicksByEventDataHResult( int bucketCount, string fieldName, IReadOnlyCollection eligibleProviders, + IReadOnlyList userDataErrorCodePaths, long[] targetCodes, int[] slotCounts, CancellationToken cancellationToken) { ArgumentException.ThrowIfNullOrEmpty(fieldName); ArgumentNullException.ThrowIfNull(eligibleProviders); + ArgumentNullException.ThrowIfNull(userDataErrorCodePaths); ArgumentNullException.ThrowIfNull(targetCodes); ArgumentNullException.ThrowIfNull(slotCounts); ArgumentOutOfRangeException.ThrowIfNotEqual(rankByPhysical.Length, Count); @@ -103,7 +105,7 @@ public void BucketTimeTicksByEventDataHResult( if (!eligibleNames.Contains(resolved.Source)) { continue; } - if (!(resolved.EventData.TryGetValue(fieldName, out EventFieldValue value) && value.TryGetHResult32(out uint hresult) && hresult != 0)) { continue; } + if (!TryGetHResult(resolved, fieldName, userDataErrorCodePaths, out long code)) { continue; } long bucket = (resolved.TimeCreated.Ticks - minTicks) / bucketSpanTicks; int clamped = bucket < 0 ? 0 : bucket >= bucketCount ? bucketCount - 1 : (int)bucket; @@ -111,7 +113,7 @@ public void BucketTimeTicksByEventDataHResult( for (int target = 0; target < targetCodes.Length; target++) { - if (targetCodes[target] == hresult) { slot = target; break; } + if (targetCodes[target] == code) { slot = target; break; } } slotCounts[(clamped * slotCount) + slot]++; @@ -283,10 +285,11 @@ public void CopyPoolIndexColumn(EventFieldId field, int[] poolIndices) } } - public void CountEventDataHResults(ReadOnlySpan rankByPhysical, string fieldName, IReadOnlyCollection eligibleProviders, IDictionary counts, CancellationToken cancellationToken) + public void CountEventDataHResults(ReadOnlySpan rankByPhysical, string fieldName, IReadOnlyCollection eligibleProviders, IReadOnlyList userDataErrorCodePaths, IDictionary counts, CancellationToken cancellationToken) { ArgumentException.ThrowIfNullOrEmpty(fieldName); ArgumentNullException.ThrowIfNull(eligibleProviders); + ArgumentNullException.ThrowIfNull(userDataErrorCodePaths); ArgumentNullException.ThrowIfNull(counts); ArgumentOutOfRangeException.ThrowIfNotEqual(rankByPhysical.Length, Count); @@ -300,9 +303,8 @@ public void CountEventDataHResults(ReadOnlySpan rankByPhysical, string fiel if (!eligibleNames.Contains(resolved.Source)) { continue; } - if (resolved.EventData.TryGetValue(fieldName, out EventFieldValue value) && value.TryGetHResult32(out uint hresult) && hresult != 0) + if (TryGetHResult(resolved, fieldName, userDataErrorCodePaths, out long code)) { - long code = hresult; counts[code] = counts.TryGetValue(code, out int existing) ? existing + 1 : 1; } } @@ -428,6 +430,16 @@ public bool TryGetTimeTicksRange( private static HashSet AsOrdinalSet(IReadOnlyCollection providers) => new(providers, StringComparer.Ordinal); + private static bool ContainsOrdinal(IReadOnlyList paths, string value) + { + for (int index = 0; index < paths.Count; index++) + { + if (string.Equals(paths[index], value, StringComparison.Ordinal)) { return true; } + } + + return false; + } + private static string? FieldValue(ResolvedEvent @event, EventFieldId field) => field switch { EventFieldId.Source => @event.Source, @@ -464,6 +476,38 @@ private static int SlotForString(string? value, string[] targets, int otherSlot) return otherSlot; } + // Mirrors EventColumnStore: an eligible row's failure HRESULT comes from its EventData errorCode field, or - when that + // field is absent (servicing rows store no EventData) - from a curated UserData Cbs*/ErrorCode leaf. 0x0 and empty + // parse to a zero/no code and are dropped like an absent field, so this oracle omits the same rows the store does. + private static bool TryGetHResult(ResolvedEvent resolved, string fieldName, IReadOnlyList userDataErrorCodePaths, out long code) + { + if (resolved.EventData.TryGetValue(fieldName, out EventFieldValue value) && value.TryGetHResult32(out uint hresult) && hresult != 0) + { + code = hresult; + + return true; + } + + if (!resolved.UserData.IsDefaultOrEmpty) + { + foreach (UserDataField field in resolved.UserData) + { + if (!ContainsOrdinal(userDataErrorCodePaths, field.Path)) { continue; } + + if (!field.Values.IsDefaultOrEmpty && EventFieldValue.TryParseHResult32(field.Values[0], out uint userDataHresult) && userDataHresult != 0) + { + code = userDataHresult; + + return true; + } + } + } + + code = 0; + + return false; + } + private LegacyStringPool BuildStringPool() { var indexByValue = new Dictionary(StringComparer.Ordinal); diff --git a/tests/Shared/EventLogExpert.Eventing.TestUtils/EventDataTestFactory.cs b/tests/Shared/EventLogExpert.Eventing.TestUtils/EventDataTestFactory.cs index 8b2cff2d..1cbd5145 100644 --- a/tests/Shared/EventLogExpert.Eventing.TestUtils/EventDataTestFactory.cs +++ b/tests/Shared/EventLogExpert.Eventing.TestUtils/EventDataTestFactory.cs @@ -5,6 +5,7 @@ using EventLogExpert.Eventing.Common.Events; using EventLogExpert.Eventing.Readers; using EventLogExpert.Eventing.Resolvers; +using EventLogExpert.Eventing.Structured; using System.Collections.Immutable; using System.Security; using System.Security.Principal; @@ -58,6 +59,21 @@ public static ResolvedEvent WithEventData(this ResolvedEvent source, params (str return source with { EventDataValues = builder.MoveToImmutable(), EventDataSchema = schema }; } + // Attaches deduped nested-UserData fields keyed by their storage-key path (the ToStorageKey form the extractor and + // the column store agree on, e.g. "CbsPackageChangeState/ErrorCode"), mirroring a servicing event that stores its + // code in UserData rather than EventData. A null value yields a present-but-valueless field. + public static ResolvedEvent WithUserData(this ResolvedEvent source, params (string Path, string? Value)[] fields) + { + var builder = ImmutableArray.CreateBuilder(fields.Length); + + foreach (var (path, value) in fields) + { + builder.Add(new UserDataField(path, value is null ? [] : [value], false)); + } + + return source with { UserData = builder.MoveToImmutable() }; + } + private static EventProperty ToEventProperty(object? value) => value switch { diff --git a/tests/Unit/EventLogExpert.Eventing.Tests/Common/Events/EventColumnStoreEventDataTests.cs b/tests/Unit/EventLogExpert.Eventing.Tests/Common/Events/EventColumnStoreEventDataTests.cs index e1abf477..6c3bf5ae 100644 --- a/tests/Unit/EventLogExpert.Eventing.Tests/Common/Events/EventColumnStoreEventDataTests.cs +++ b/tests/Unit/EventLogExpert.Eventing.Tests/Common/Events/EventColumnStoreEventDataTests.cs @@ -11,9 +11,10 @@ namespace EventLogExpert.Eventing.Tests.Common.Events; public sealed class EventColumnStoreEventDataTests { private const string WuClient = "Microsoft-Windows-WindowsUpdateClient"; - private static readonly EventLogId s_logId = EventLogId.Create(); + private static readonly EventLogId s_logId = EventLogId.Create(); private static readonly string[] s_updateProviders = [WuClient, "Microsoft-Windows-Servicing"]; + private static readonly string[] s_errorCodeUserDataPaths = ["CbsPackageChangeState/ErrorCode", "CbsUpdateChangeState/ErrorCode"]; [Fact] public void BucketTimeTicksByEventData_ClassifiesRowsByCodeWithOtherForNonTargets() @@ -62,6 +63,25 @@ public void BucketTimeTicksByEventData_IsAllocationFreeOnSealedRows() Assert.True(delta < 512, $"Per-row allocation detected: {delta} bytes over {events.Length} sealed rows."); } + [Fact] + public void BucketTimeTicksByEventDataHResult_ChartsServicingUserDataErrorCode_OmitsSuccessEmptyAndNoLeaf() + { + IEventColumnReader reader = ReaderFor( + ServicingEvent("CbsPackageChangeState/ErrorCode", "0x800f0816"), + ServicingEvent("CbsUpdateChangeState/ErrorCode", "0x800F0922"), + ServicingEvent("CbsPackageChangeState/ErrorCode", "0x0"), // success + ServicingEvent("CbsUpdateChangeState/ErrorCode", ""), // empty (event 7) + ServicingEvent("CbsPackageInitiateChanges/Client", "CbsTask")); // no ErrorCode leaf (event 1) + + long[] targetCodes = [0x800F0816L, 0x800F0922L]; + int[] slotCounts = new int[targetCodes.Length + 1]; + reader.BucketTimeTicksByEventDataHResult(AllSurvive(reader.Count), 0, long.MaxValue, 1, "errorCode", s_updateProviders, s_errorCodeUserDataPaths, targetCodes, slotCounts, CancellationToken.None); + + Assert.Equal(1, slotCounts[0]); // 0x800F0816 (CbsPackageChangeState) + Assert.Equal(1, slotCounts[1]); // 0x800F0922 (CbsUpdateChangeState) + Assert.Equal(0, slotCounts[2]); // success, empty, and the no-ErrorCode-leaf row are omitted + } + [Fact] public void BucketTimeTicksByEventDataHResult_ClassifiesEligibleFailures_OmitsIneligible() { @@ -74,7 +94,7 @@ public void BucketTimeTicksByEventDataHResult_ClassifiesEligibleFailures_OmitsIn long[] targetCodes = [0x800F081FL, 0x800F0823L]; int[] slotCounts = new int[targetCodes.Length + 1]; - reader.BucketTimeTicksByEventDataHResult(AllSurvive(reader.Count), 0, long.MaxValue, 1, "errorCode", s_updateProviders, targetCodes, slotCounts, CancellationToken.None); + reader.BucketTimeTicksByEventDataHResult(AllSurvive(reader.Count), 0, long.MaxValue, 1, "errorCode", s_updateProviders, s_errorCodeUserDataPaths, targetCodes, slotCounts, CancellationToken.None); Assert.Equal(1, slotCounts[0]); Assert.Equal(1, slotCounts[1]); @@ -96,10 +116,10 @@ public void BucketTimeTicksByEventDataHResult_IsAllocationFreeOnSealedRows() long[] targetCodes = [0x800F081FL, 0x800F0823L]; int[] slotCounts = new int[targetCodes.Length + 1]; - reader.BucketTimeTicksByEventDataHResult(rank, 0, long.MaxValue, 1, "errorCode", s_updateProviders, targetCodes, slotCounts, CancellationToken.None); + reader.BucketTimeTicksByEventDataHResult(rank, 0, long.MaxValue, 1, "errorCode", s_updateProviders, s_errorCodeUserDataPaths, targetCodes, slotCounts, CancellationToken.None); long before = GC.GetAllocatedBytesForCurrentThread(); - reader.BucketTimeTicksByEventDataHResult(rank, 0, long.MaxValue, 1, "errorCode", s_updateProviders, targetCodes, slotCounts, CancellationToken.None); + reader.BucketTimeTicksByEventDataHResult(rank, 0, long.MaxValue, 1, "errorCode", s_updateProviders, s_errorCodeUserDataPaths, targetCodes, slotCounts, CancellationToken.None); long delta = GC.GetAllocatedBytesForCurrentThread() - before; // The eligible-index buffer is stack-allocated and the schema memo is a fixed per-scan array, so the per-row path is @@ -127,7 +147,7 @@ public void CountEventDataHResults_CaseInsensitiveAllowlist_IsNormalizedToOrdina IEventColumnReader reader = store.CreateReader(s_logId); var counts = new Dictionary(); - reader.CountEventDataHResults(AllSurvive(reader.Count), "errorCode", caseInsensitive, counts, CancellationToken.None); + reader.CountEventDataHResults(AllSurvive(reader.Count), "errorCode", caseInsensitive, s_errorCodeUserDataPaths, counts, CancellationToken.None); Assert.Single(counts); Assert.Equal(1, counts[0x800F0823L]); @@ -143,7 +163,7 @@ public void CountEventDataHResults_FoldsHexAndDecimalStringSpellings() UpdateEvent(WuClient, "2148468767")); // decimal spelling of 0x800F081F var counts = new Dictionary(); - reader.CountEventDataHResults(AllSurvive(reader.Count), "errorCode", s_updateProviders, counts, CancellationToken.None); + reader.CountEventDataHResults(AllSurvive(reader.Count), "errorCode", s_updateProviders, s_errorCodeUserDataPaths, counts, CancellationToken.None); Assert.Single(counts); Assert.Equal(2, counts[0x800F081FL]); @@ -166,11 +186,11 @@ public void CountEventDataHResults_IsAllocationFreeOnPendingRows() int[] rank = AllSurvive(reader.Count); var counts = new Dictionary(); - reader.CountEventDataHResults(rank, "errorCode", s_updateProviders, counts, CancellationToken.None); + reader.CountEventDataHResults(rank, "errorCode", s_updateProviders, s_errorCodeUserDataPaths, counts, CancellationToken.None); counts.Clear(); long before = GC.GetAllocatedBytesForCurrentThread(); - reader.CountEventDataHResults(rank, "errorCode", s_updateProviders, counts, CancellationToken.None); + reader.CountEventDataHResults(rank, "errorCode", s_updateProviders, s_errorCodeUserDataPaths, counts, CancellationToken.None); long delta = GC.GetAllocatedBytesForCurrentThread() - before; // The pending provider match uses a scan-local set built once, not a per-row enumerator (which cost 32 bytes x 3000). @@ -187,7 +207,7 @@ public void CountEventDataHResults_OmitsZeroAbsentAndIneligibleProvider() UpdateEvent("Some-Other-Provider", unchecked((int)0x800F0823u))); var counts = new Dictionary(); - reader.CountEventDataHResults(AllSurvive(reader.Count), "errorCode", s_updateProviders, counts, CancellationToken.None); + reader.CountEventDataHResults(AllSurvive(reader.Count), "errorCode", s_updateProviders, s_errorCodeUserDataPaths, counts, CancellationToken.None); Assert.Single(counts); Assert.Equal(1, counts[0x800F0823L]); @@ -199,12 +219,97 @@ public void CountEventDataHResults_ReadsSignExtendedNegativeHexInt32() IEventColumnReader reader = ReaderFor(UpdateEvent(WuClient, unchecked((int)0x800F0823u))); var counts = new Dictionary(); - reader.CountEventDataHResults(AllSurvive(reader.Count), "errorCode", s_updateProviders, counts, CancellationToken.None); + reader.CountEventDataHResults(AllSurvive(reader.Count), "errorCode", s_updateProviders, s_errorCodeUserDataPaths, counts, CancellationToken.None); Assert.Single(counts); Assert.Equal(1, counts[0x800F0823L]); } + [Fact] + public void CountEventDataHResults_ServicingUserData_OmitsNonTargetErrorCodePath() + { + // The dimension keys on the two curated Cbs* paths, not any */ErrorCode leaf; an unlisted template is omitted. + IEventColumnReader reader = ReaderFor( + ServicingEvent("SomeOtherTemplate/ErrorCode", "0x800f0816"), + ServicingEvent("CbsPackageChangeState/ErrorCode", "0x800F0922")); + + var counts = new Dictionary(); + reader.CountEventDataHResults(AllSurvive(reader.Count), "errorCode", s_updateProviders, s_errorCodeUserDataPaths, counts, CancellationToken.None); + + Assert.Single(counts); + Assert.Equal(1, counts[0x800F0922L]); + Assert.DoesNotContain(0x800F0816L, counts.Keys); + } + + [Fact] + public void CountEventDataHResults_ServicingUserData_PathMatchIsOrdinal_SealedAndPendingOmitCaseVariant() + { + // Storage keys are canonical, so the sealed pool-index compare is exact; the pending mirror must be ordinal too, + // or a case-variant path would chart on one store but not the other. Both must omit it. + foreach (bool sealRows in new[] { true, false }) + { + ResolvedEvent[] corpus = [ServicingEvent("cbspackagechangestate/errorcode", "0x800f0816")]; + EventColumnStore store = sealRows + ? EventColumnStore.Build(corpus, generation: 0, contentVersion: 0) + : EventColumnStore.Build([], generation: 0, contentVersion: 0).Append(corpus); + IEventColumnReader reader = store.CreateReader(s_logId); + + var counts = new Dictionary(); + reader.CountEventDataHResults(AllSurvive(reader.Count), "errorCode", s_updateProviders, s_errorCodeUserDataPaths, counts, CancellationToken.None); + + Assert.Empty(counts); + } + } + + [Fact] + public void CountEventDataHResults_ServicingUserData_ResolvesTargetPathInternedInLaterChunk() + { + // The target UserData path is interned only by the final row, which lands in a later sealed chunk (Build chunks at + // 4096 rows); resolving it once against the shared store pool must still find it so the multi-chunk scan charts it. + const int chunkSize = 4096; + var events = new ResolvedEvent[chunkSize + 1]; + + for (int index = 0; index < chunkSize; index++) + { + events[index] = UpdateEvent(WuClient, 0, index); // WUClient success rows fill the first chunk (no Servicing path) + } + + events[chunkSize] = ServicingEvent("CbsPackageChangeState/ErrorCode", "0x800f0816", chunkSize); + + IEventColumnReader reader = EventColumnStore.Build(events, generation: 0, contentVersion: 0).CreateReader(s_logId); + + var counts = new Dictionary(); + reader.CountEventDataHResults(AllSurvive(reader.Count), "errorCode", s_updateProviders, s_errorCodeUserDataPaths, counts, CancellationToken.None); + + Assert.Single(counts); + Assert.Equal(1, counts[0x800F0816L]); + } + + [Fact] + public void CountEventDataHResults_ServicingUserData_SealedAndPending_ChartFailuresOmitSuccess() + { + foreach (bool sealRows in new[] { true, false }) + { + ResolvedEvent[] corpus = + [ + ServicingEvent("CbsPackageChangeState/ErrorCode", "0x800f0816"), + ServicingEvent("CbsUpdateChangeState/ErrorCode", "0x800F0922", 1), + ServicingEvent("CbsPackageChangeState/ErrorCode", "0x0", 2) + ]; + EventColumnStore store = sealRows + ? EventColumnStore.Build(corpus, generation: 0, contentVersion: 0) + : EventColumnStore.Build([], generation: 0, contentVersion: 0).Append(corpus); + IEventColumnReader reader = store.CreateReader(s_logId); + + var counts = new Dictionary(); + reader.CountEventDataHResults(AllSurvive(reader.Count), "errorCode", s_updateProviders, s_errorCodeUserDataPaths, counts, CancellationToken.None); + + Assert.Equal(2, counts.Count); + Assert.Equal(1, counts[0x800F0816L]); + Assert.Equal(1, counts[0x800F0922L]); + } + } + [Fact] public void CountEventDataValues_FoldsDecimalAndHexSpellingsOfOneCode() { @@ -260,6 +365,31 @@ public void CountEventDataValues_RejectsHexCodeThatOverflowsALong() Assert.DoesNotContain(-1L, counts.Keys); } + [Fact] + public void HResultScans_MixedEventDataAndUserData_CountAndBucketAgree() + { + // A WUClient EventData errorCode and a Servicing UserData ErrorCode in one store both chart; the EventData-first / + // UserData-fallback contributes exactly one slot per row, so the bucket sum equals the count total. + IEventColumnReader reader = ReaderFor( + UpdateEvent(WuClient, unchecked((int)0x800F081Fu)), + ServicingEvent("CbsPackageChangeState/ErrorCode", "0x800f0816", 1)); + + var counts = new Dictionary(); + reader.CountEventDataHResults(AllSurvive(reader.Count), "errorCode", s_updateProviders, s_errorCodeUserDataPaths, counts, CancellationToken.None); + + Assert.Equal(2, counts.Count); + Assert.Equal(1, counts[0x800F081FL]); + Assert.Equal(1, counts[0x800F0816L]); + + long[] targetCodes = [0x800F081FL, 0x800F0816L]; + int[] slotCounts = new int[targetCodes.Length + 1]; + reader.BucketTimeTicksByEventDataHResult(AllSurvive(reader.Count), 0, long.MaxValue, 1, "errorCode", s_updateProviders, s_errorCodeUserDataPaths, targetCodes, slotCounts, CancellationToken.None); + + Assert.Equal(1, slotCounts[0]); + Assert.Equal(1, slotCounts[1]); + Assert.Equal(0, slotCounts[2]); + } + private static int[] AllSurvive(int count) { int[] rank = new int[count]; @@ -279,6 +409,10 @@ private static ResolvedEvent EventWithoutData() => private static IEventColumnReader ReaderFor(params ResolvedEvent[] events) => EventColumnStore.Build(events, generation: 0, contentVersion: 0).CreateReader(s_logId); + private static ResolvedEvent ServicingEvent(string userDataPath, string? errorCode, int tick = 0) => + new ResolvedEvent("TestLog", LogPathType.Channel) { Id = 3, Source = "Microsoft-Windows-Servicing", TimeCreated = new DateTime(tick, DateTimeKind.Utc) } + .WithUserData((userDataPath, errorCode)); + private static ResolvedEvent UpdateEvent(string source, object errorCode, int tick = 0) => new ResolvedEvent("TestLog", LogPathType.Channel) { Id = 20, Source = source, TimeCreated = new DateTime(tick, DateTimeKind.Utc) } .WithEventData(("errorCode", errorCode)); diff --git a/tests/Unit/EventLogExpert.Eventing.Tests/Common/Events/EventColumnStoreReaderParityTests.cs b/tests/Unit/EventLogExpert.Eventing.Tests/Common/Events/EventColumnStoreReaderParityTests.cs index 67302c57..e12ebc56 100644 --- a/tests/Unit/EventLogExpert.Eventing.Tests/Common/Events/EventColumnStoreReaderParityTests.cs +++ b/tests/Unit/EventLogExpert.Eventing.Tests/Common/Events/EventColumnStoreReaderParityTests.cs @@ -146,6 +146,7 @@ public void GetUserDataIncomplete_SealedRows_MatchLegacyReader() public void HResultScans_SealedAndPending_MatchLegacyReader() { string[] providers = ["Microsoft-Windows-WindowsUpdateClient", "Microsoft-Windows-Servicing"]; + string[] userDataPaths = ["CbsPackageChangeState/ErrorCode", "CbsUpdateChangeState/ErrorCode"]; long[] targetCodes = [0x800F0823L, 0x800F081FL]; foreach (bool sealRows in new[] { true, false }) @@ -155,14 +156,14 @@ public void HResultScans_SealedAndPending_MatchLegacyReader() var legacyCounts = new Dictionary(); var columnCounts = new Dictionary(); - legacy.CountEventDataHResults(rank, "errorCode", providers, legacyCounts, CancellationToken.None); - column.CountEventDataHResults(rank, "errorCode", providers, columnCounts, CancellationToken.None); + legacy.CountEventDataHResults(rank, "errorCode", providers, userDataPaths, legacyCounts, CancellationToken.None); + column.CountEventDataHResults(rank, "errorCode", providers, userDataPaths, columnCounts, CancellationToken.None); Assert.Equal(legacyCounts.OrderBy(pair => pair.Key), columnCounts.OrderBy(pair => pair.Key)); int[] legacySlots = new int[targetCodes.Length + 1]; int[] columnSlots = new int[targetCodes.Length + 1]; - legacy.BucketTimeTicksByEventDataHResult(rank, 0, long.MaxValue, 1, "errorCode", providers, targetCodes, legacySlots, CancellationToken.None); - column.BucketTimeTicksByEventDataHResult(rank, 0, long.MaxValue, 1, "errorCode", providers, targetCodes, columnSlots, CancellationToken.None); + legacy.BucketTimeTicksByEventDataHResult(rank, 0, long.MaxValue, 1, "errorCode", providers, userDataPaths, targetCodes, legacySlots, CancellationToken.None); + column.BucketTimeTicksByEventDataHResult(rank, 0, long.MaxValue, 1, "errorCode", providers, userDataPaths, targetCodes, columnSlots, CancellationToken.None); Assert.Equal(legacySlots, columnSlots); } } @@ -448,7 +449,12 @@ private static ResolvedEvent[] BuildErrorCodeCorpus() => ErrorCodeEvent("Microsoft-Windows-WindowsUpdateClient", 0, 2), ErrorCodeEvent("Microsoft-Windows-WindowsUpdateClient", unchecked((int)0x80070005u), 3), new ResolvedEvent("TestLog", LogPathType.Channel) { Id = 44, Source = "Microsoft-Windows-WindowsUpdateClient", TimeCreated = s_time.AddMinutes(4) }, - ErrorCodeEvent("Some-Other-Provider", unchecked((int)0x800F0823u), 5) + ErrorCodeEvent("Some-Other-Provider", unchecked((int)0x800F0823u), 5), + // Servicing rows carry their code in UserData, not EventData: a package failure, a success (0x0, omitted), + // and a selectable-update failure - so the parity oracle and the store must agree on the UserData fallback. + ServicingUserDataEvent("CbsPackageChangeState/ErrorCode", "0x800f0816", 6), + ServicingUserDataEvent("CbsPackageChangeState/ErrorCode", "0x0", 7), + ServicingUserDataEvent("CbsUpdateChangeState/ErrorCode", "0x800F0922", 8) ]; private static (IEventColumnReader Legacy, IEventColumnReader Column) BuildErrorCodeReaders(bool sealRows) @@ -562,6 +568,10 @@ private static ResolvedEvent ErrorCodeEvent(string source, object errorCode, int return fields; } + private static ResolvedEvent ServicingUserDataEvent(string path, string errorCode, int index) => + new ResolvedEvent("TestLog", LogPathType.Channel) { Id = 3, Source = "Microsoft-Windows-Servicing", TimeCreated = s_time.AddMinutes(index) } + .WithUserData((path, errorCode)); + private static ResolvedEvent WithNamedProperties(ResolvedEvent source, params (string Name, EventProperty Value)[] fields) { string template = "