From b4a7e05a182a6c521683ab6d31c02c5c0496ccda Mon Sep 17 00:00:00 2001 From: jschick04 Date: Fri, 17 Jul 2026 22:29:59 +0000 Subject: [PATCH 1/2] Add Error Code histogram dimension for update failure HRESULTs --- .../Common/Events/EventColumnStore.cs | 243 +++++++++++++++++- .../Common/Events/EventColumnStoreReader.cs | 40 +++ .../Common/Events/EventFieldValue.cs | 51 ++++ .../Common/Events/IEventColumnReader.cs | 26 ++ .../Common/Display/EventDataValueDecoder.cs | 16 +- .../DetailsPane/EventFieldExplainer.cs | 13 +- .../Histogram/HistogramBuilder.cs | 78 ++++++ .../Histogram/HistogramData.cs | 4 + .../Histogram/HistogramDimension.cs | 3 +- .../Histogram/HistogramSummary.cs | 7 +- .../LogTable/CombinedColumnView.cs | 31 +++ .../LogTable/EventColumnView.cs | 27 ++ .../LogTable/IEventColumnView.cs | 22 ++ .../LogTable/Histogram/HistogramPane.razor | 7 +- .../LogTable/Histogram/HistogramPane.razor.cs | 21 +- .../Common/Events/LegacyEventColumnReader.cs | 71 +++++ .../Events/EventColumnStoreEventDataTests.cs | 153 +++++++++++ .../EventColumnStoreReaderParityTests.cs | 58 +++++ .../Common/Events/EventFieldValueTests.cs | 64 +++++ .../Display/EventDataValueDecoderTests.cs | 7 + .../DetailsPane/EventFieldExplainerTests.cs | 16 ++ .../Histogram/HistogramBuilderTests.cs | 85 ++++++ .../Histogram/HistogramSummaryTests.cs | 2 +- .../TestSupport/LegacyEventColumnView.cs | 6 + 24 files changed, 1037 insertions(+), 14 deletions(-) diff --git a/src/EventLogExpert.Eventing/Common/Events/EventColumnStore.cs b/src/EventLogExpert.Eventing/Common/Events/EventColumnStore.cs index 742d8722..10f027ba 100644 --- a/src/EventLogExpert.Eventing/Common/Events/EventColumnStore.cs +++ b/src/EventLogExpert.Eventing/Common/Events/EventColumnStore.cs @@ -75,6 +75,8 @@ public int GetHashCode(int[] obj) /// public sealed class EventColumnStore { + // Resolve the tiny update-provider allowlist on the stack; a larger eligibility set falls back to the heap. + private const int MaxStackProviderNames = 32; // Per-scan EventData field-index memo sentinels: a schema not yet looked up, and a schema that lacks the field. private const int SchemaFieldAbsent = -1; private const int SchemaFieldUnresolved = -2; @@ -278,6 +280,69 @@ internal void BucketTimeTicksByEventData( } } + internal void BucketTimeTicksByEventDataHResult( + ReadOnlySpan rankByPhysical, + long minTicks, + long bucketSpanTicks, + int bucketCount, + string fieldName, + IReadOnlyCollection eligibleProviders, + long[] targetCodes, + int slotCount, + int[] slotCounts, + CancellationToken cancellationToken) + { + int otherSlot = targetCodes.Length; + Span eligibleBuffer = eligibleProviders.Count <= MaxStackProviderNames + ? stackalloc int[eligibleProviders.Count] + : new int[eligibleProviders.Count]; + ReadOnlySpan eligible = ResolveEligibleSourceIndices(eligibleProviders, eligibleBuffer); + int[] fieldIndexBySchema = NewSchemaFieldMemo(); + int offset = 0; + + foreach (EventColumnChunk chunk in _sealedChunks) + { + cancellationToken.ThrowIfCancellationRequested(); + + ReadOnlySpan timeColumn = chunk.TimeTicksColumn; + ReadOnlySpan sourceColumn = chunk.PoolIndexColumn(EventColumnField.Source); + + for (int row = 0; row < timeColumn.Length; row++) + { + if (rankByPhysical[offset + row] < 0) { continue; } + + // Ineligible provider, absent field, and zero/undecodable code all contribute to no slot (omit), so "Other" + // 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; } + + int bucket = ToBucket(timeColumn[row], minTicks, bucketSpanTicks, bucketCount); + slotCounts[(bucket * slotCount) + SlotForCode(code, targetCodes, otherSlot)]++; + } + + offset += chunk.RowCount; + } + + if (_sealedCount >= Count) { return; } + + IReadOnlySet eligibleNames = AsOrdinalSet(eligibleProviders); + + for (int index = _sealedCount; index < Count; index++) + { + if (rankByPhysical[index] < 0) { continue; } + + ResolvedEvent pending = Pending(index); + + if (!eligibleNames.Contains(pending.Source)) { continue; } + + if (!TryGetPendingEventDataHResult(pending, fieldName, out long code)) { continue; } + + int bucket = ToBucket(pending.TimeCreated.Ticks, minTicks, bucketSpanTicks, bucketCount); + slotCounts[(bucket * slotCount) + SlotForCode(code, targetCodes, otherSlot)]++; + } + } + internal void BucketTimeTicksByEventId( ReadOnlySpan rankByPhysical, long minTicks, @@ -597,6 +662,61 @@ internal void CopyTimeTicks(long[] values, bool[] hasValue) } } + internal void CountEventDataHResults( + ReadOnlySpan rankByPhysical, + string fieldName, + IReadOnlyCollection eligibleProviders, + IDictionary counts, + CancellationToken cancellationToken) + { + Span eligibleBuffer = eligibleProviders.Count <= MaxStackProviderNames + ? stackalloc int[eligibleProviders.Count] + : new int[eligibleProviders.Count]; + + ReadOnlySpan eligible = ResolveEligibleSourceIndices(eligibleProviders, eligibleBuffer); + int[] fieldIndexBySchema = NewSchemaFieldMemo(); + int offset = 0; + + foreach (EventColumnChunk chunk in _sealedChunks) + { + cancellationToken.ThrowIfCancellationRequested(); + + ReadOnlySpan sourceColumn = chunk.PoolIndexColumn(EventColumnField.Source); + + for (int row = 0; row < sourceColumn.Length; row++) + { + if (rankByPhysical[offset + row] < 0) { continue; } + + if (eligible.BinarySearch(sourceColumn[row]) < 0) { continue; } + + if (TryGetSealedEventDataHResult(chunk, row, fieldName, fieldIndexBySchema, out long code)) + { + counts[code] = counts.TryGetValue(code, out int existing) ? existing + 1 : 1; + } + } + + offset += chunk.RowCount; + } + + if (_sealedCount >= Count) { return; } + + IReadOnlySet eligibleNames = AsOrdinalSet(eligibleProviders); + + for (int index = _sealedCount; index < Count; index++) + { + if (rankByPhysical[index] < 0) { continue; } + + ResolvedEvent pending = Pending(index); + + if (!eligibleNames.Contains(pending.Source)) { continue; } + + if (TryGetPendingEventDataHResult(pending, fieldName, out long code)) + { + counts[code] = counts.TryGetValue(code, out int existing) ? existing + 1 : 1; + } + } + } + internal void CountEventDataValues(ReadOnlySpan rankByPhysical, string fieldName, IDictionary counts, CancellationToken cancellationToken) { int[] fieldIndexBySchema = NewSchemaFieldMemo(); @@ -1060,6 +1180,11 @@ internal bool TryGetTimeTicksRange( internal EventColumnStore WithReloadGeneration(IReadOnlyList batch) => Build(batch, Generation + 1, ContentVersion + 1); + // A pending row's Source is a raw string (not yet pooled), so match it against a scan-local set built once per scan. + // Always normalize to Ordinal so pending matching stays byte-identical to the sealed pool's ordinal lookup, regardless + // of the caller collection's own comparer. + private static HashSet AsOrdinalSet(IReadOnlyCollection providers) => new(providers, StringComparer.Ordinal); + private static (EventColumnChunk Chunk, EventColumnPool Pool, ImmutableArray Schemas) BuildChunk( IReadOnlyList events, int start, @@ -1192,6 +1317,20 @@ private static bool TryGetPendingEventDataCode(ResolvedEvent pending, string fie return false; } + private static bool TryGetPendingEventDataHResult(ResolvedEvent pending, string fieldName, out long code) + { + if (pending.EventData.TryGetValue(fieldName, out EventFieldValue value) && value.TryGetHResult32(out uint hresult) && hresult != 0) + { + code = hresult; + + return true; + } + + code = 0; + + return false; + } + private (EventColumnChunk Chunk, int Row) Locate(int index) { int[] prefix = SealedPrefix(); @@ -1323,6 +1462,23 @@ private ImmutableArray ReconstructUserData(int index) return fields.MoveToImmutable(); } + // The pool dedups, so each provider name resolves to at most one index; sort the resolved set for O(log n) per-row + // membership. Names absent from this store's pool are dropped (no row here can carry them). + private ReadOnlySpan ResolveEligibleSourceIndices(IReadOnlyCollection eligibleProviders, Span buffer) + { + int count = 0; + + foreach (string provider in eligibleProviders) + { + if (count < buffer.Length && _pool.TryGetIndex(provider, out int index)) { buffer[count++] = index; } + } + + Span resolved = buffer[..count]; + resolved.Sort(); + + return resolved; + } + private int ResolveSchemaFieldIndex(int schemaId, string fieldName) { int[] nameIndices = _schemas[schemaId]; @@ -1352,6 +1508,61 @@ private int[] SealedPrefix() return prefix; } + private bool TryGetHResult32FromRawField(in RawEventDataField field, out long code) + { + switch (field.Kind) + { + case StoredFieldKind.SByte: + case StoredFieldKind.Int16: + case StoredFieldKind.Int32: + case StoredFieldKind.Int64: + // A win:HexInt32 failure code (high bit set) sign-extends to a negative Int64; reinterpret the low 32 bits. + if (field.Bits is >= int.MinValue and <= uint.MaxValue) + { + code = (uint)field.Bits; + + return true; + } + + code = 0; + + return false; + case StoredFieldKind.Byte: + case StoredFieldKind.UInt16: + case StoredFieldKind.UInt32: + case StoredFieldKind.UInt64: + case StoredFieldKind.SizeT: + ulong unsigned = unchecked((ulong)field.Bits); + + if (unsigned <= uint.MaxValue) + { + code = (uint)unsigned; + + return true; + } + + code = 0; + + return false; + case StoredFieldKind.String: + case StoredFieldKind.StringForm: + if (EventFieldValue.TryParseHResult32(PoolGet(field.RefIndex), out uint parsed)) + { + code = parsed; + + return true; + } + + code = 0; + + return false; + default: + code = 0; + + return false; + } + } + private bool TryGetSealedEventDataCode(EventColumnChunk chunk, int row, string fieldName, int[] fieldIndexBySchema, out long code) { int schemaId = chunk.RowEventDataSchemaId(row); @@ -1376,6 +1587,31 @@ private bool TryGetSealedEventDataCode(EventColumnChunk chunk, int row, string f return TryGetWholeNumberFromRawField(chunk.RowEventDataField(row, fieldIndex), out code); } + private bool TryGetSealedEventDataHResult(EventColumnChunk chunk, int row, string fieldName, int[] fieldIndexBySchema, out long code) + { + int schemaId = chunk.RowEventDataSchemaId(row); + + if ((uint)schemaId >= (uint)fieldIndexBySchema.Length) { code = 0; return false; } + + int fieldIndex = fieldIndexBySchema[schemaId]; + + if (fieldIndex == SchemaFieldUnresolved) + { + fieldIndex = ResolveSchemaFieldIndex(schemaId, fieldName); + fieldIndexBySchema[schemaId] = fieldIndex; + } + + if (fieldIndex < 0 || fieldIndex >= chunk.RowEventDataCount(row)) + { + code = 0; + + return false; + } + + // A zero code is success (S_OK); the ErrorCode dimension charts failures only, so it is dropped like an absent field. + return TryGetHResult32FromRawField(chunk.RowEventDataField(row, fieldIndex), out code) && code != 0; + } + private bool TryGetWholeNumberFromRawField(in RawEventDataField field, out long code) { switch (field.Kind) @@ -1394,7 +1630,12 @@ private bool TryGetWholeNumberFromRawField(in RawEventDataField field, out long case StoredFieldKind.SizeT: ulong unsigned = unchecked((ulong)field.Bits); - if (unsigned <= long.MaxValue) { code = (long)unsigned; return true; } + if (unsigned <= long.MaxValue) + { + code = (long)unsigned; + + return true; + } code = 0; diff --git a/src/EventLogExpert.Eventing/Common/Events/EventColumnStoreReader.cs b/src/EventLogExpert.Eventing/Common/Events/EventColumnStoreReader.cs index 853af847..65f02af3 100644 --- a/src/EventLogExpert.Eventing/Common/Events/EventColumnStoreReader.cs +++ b/src/EventLogExpert.Eventing/Common/Events/EventColumnStoreReader.cs @@ -59,6 +59,31 @@ public void BucketTimeTicksByEventData( _store.BucketTimeTicksByEventData(rankByPhysical, minTicks, bucketSpanTicks, bucketCount, fieldName, targetCodes, slotCount, slotCounts, cancellationToken); } + public void BucketTimeTicksByEventDataHResult( + ReadOnlySpan rankByPhysical, + long minTicks, + long bucketSpanTicks, + int bucketCount, + string fieldName, + IReadOnlyCollection eligibleProviders, + long[] targetCodes, + int[] slotCounts, + CancellationToken cancellationToken) + { + ArgumentException.ThrowIfNullOrEmpty(fieldName); + ArgumentNullException.ThrowIfNull(eligibleProviders); + ArgumentNullException.ThrowIfNull(targetCodes); + ArgumentNullException.ThrowIfNull(slotCounts); + ArgumentOutOfRangeException.ThrowIfNotEqual(rankByPhysical.Length, Count); + ArgumentOutOfRangeException.ThrowIfLessThan(bucketSpanTicks, 1); + ArgumentOutOfRangeException.ThrowIfLessThan(bucketCount, 1); + + int slotCount = targetCodes.Length + 1; + ArgumentOutOfRangeException.ThrowIfLessThan(slotCounts.Length, bucketCount * slotCount); + + _store.BucketTimeTicksByEventDataHResult(rankByPhysical, minTicks, bucketSpanTicks, bucketCount, fieldName, eligibleProviders, targetCodes, slotCount, slotCounts, cancellationToken); + } + public void BucketTimeTicksByEventId( ReadOnlySpan rankByPhysical, long minTicks, @@ -182,6 +207,21 @@ public void CopyPoolIndexColumn(EventFieldId field, int[] poolIndices) } } + public void CountEventDataHResults( + ReadOnlySpan rankByPhysical, + string fieldName, + IReadOnlyCollection eligibleProviders, + IDictionary counts, + CancellationToken cancellationToken) + { + ArgumentException.ThrowIfNullOrEmpty(fieldName); + ArgumentNullException.ThrowIfNull(eligibleProviders); + ArgumentNullException.ThrowIfNull(counts); + ArgumentOutOfRangeException.ThrowIfNotEqual(rankByPhysical.Length, Count); + + _store.CountEventDataHResults(rankByPhysical, fieldName, eligibleProviders, counts, cancellationToken); + } + public void CountEventDataValues(ReadOnlySpan rankByPhysical, string fieldName, IDictionary counts, CancellationToken cancellationToken) { ArgumentException.ThrowIfNullOrEmpty(fieldName); diff --git a/src/EventLogExpert.Eventing/Common/Events/EventFieldValue.cs b/src/EventLogExpert.Eventing/Common/Events/EventFieldValue.cs index 2b861970..8930097b 100644 --- a/src/EventLogExpert.Eventing/Common/Events/EventFieldValue.cs +++ b/src/EventLogExpert.Eventing/Common/Events/EventFieldValue.cs @@ -111,6 +111,57 @@ internal static bool TryParseWholeNumber(string? text, out long value) return true; } + /// + /// Reads this value as a 32-bit HRESULT / Win32 error code, canonicalized to . Unlike + /// , this accepts the negative that a + /// win:HexInt32 failure code (high bit set, e.g. 0x800F0823) sign-extends to, and the hex or decimal + /// form other providers store. Values outside the 32-bit range are rejected. + /// Allocation-free. + /// + public bool TryGetHResult32(out uint value) + { + switch (_kind) + { + case EventFieldValueKind.Int64 when _bits is >= int.MinValue and <= uint.MaxValue: + case EventFieldValueKind.UInt64 when unchecked((ulong)_bits) <= uint.MaxValue: + value = unchecked((uint)_bits); + + return true; + case EventFieldValueKind.String: + return TryParseHResult32(_reference as string, out value); + default: + value = 0; + + return false; + } + } + + internal static bool TryParseHResult32(string? text, out uint value) + { + value = 0; + + if (string.IsNullOrEmpty(text)) { return false; } + + ReadOnlySpan span = text.AsSpan().Trim(); + + if (span.StartsWith("0x", StringComparison.OrdinalIgnoreCase)) + { + return uint.TryParse(span[2..], NumberStyles.AllowHexSpecifier, CultureInfo.InvariantCulture, out value); + } + + if (uint.TryParse(span, NumberStyles.None, CultureInfo.InvariantCulture, out value)) { return true; } + + // A signed-decimal rendering of a high-bit HRESULT (e.g. "-2146498525" for 0x800F0823) reinterprets to uint. + if (int.TryParse(span, NumberStyles.AllowLeadingSign, CultureInfo.InvariantCulture, out int signed)) + { + value = unchecked((uint)signed); + + return true; + } + + return false; + } + public bool TryGetDouble(out double value) { if (_kind == EventFieldValueKind.Double) { value = BitConverter.Int64BitsToDouble(_bits); return true; } diff --git a/src/EventLogExpert.Eventing/Common/Events/IEventColumnReader.cs b/src/EventLogExpert.Eventing/Common/Events/IEventColumnReader.cs index b7a9ebce..324ea762 100644 --- a/src/EventLogExpert.Eventing/Common/Events/IEventColumnReader.cs +++ b/src/EventLogExpert.Eventing/Common/Events/IEventColumnReader.cs @@ -37,6 +37,23 @@ void BucketTimeTicksByEventData( int[] slotCounts, CancellationToken cancellationToken); + /// + /// 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). + /// + void BucketTimeTicksByEventDataHResult( + ReadOnlySpan rankByPhysical, + long minTicks, + long bucketSpanTicks, + int bucketCount, + string fieldName, + IReadOnlyCollection eligibleProviders, + long[] targetCodes, + int[] slotCounts, + CancellationToken cancellationToken); + /// Group-by variant keyed on the numeric event id; ( length + 1) slots per bin. void BucketTimeTicksByEventId( ReadOnlySpan rankByPhysical, @@ -93,6 +110,15 @@ void BucketTimeTicksBySeverity( /// void CopyPoolIndexColumn(EventFieldId field, int[] poolIndices); + /// + /// 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. + /// + void CountEventDataHResults(ReadOnlySpan rankByPhysical, string fieldName, IReadOnlyCollection eligibleProviders, IDictionary counts, CancellationToken cancellationToken); + /// /// Group-by variant for a named EventData field of allowlisted numeric codes: tallies survivors by the field's /// whole-number code (decimal and 0x-hex spellings canonicalize to one code), so the histogram can split, for diff --git a/src/EventLogExpert.Runtime/Common/Display/EventDataValueDecoder.cs b/src/EventLogExpert.Runtime/Common/Display/EventDataValueDecoder.cs index a555d52d..3d531566 100644 --- a/src/EventLogExpert.Runtime/Common/Display/EventDataValueDecoder.cs +++ b/src/EventLogExpert.Runtime/Common/Display/EventDataValueDecoder.cs @@ -20,9 +20,23 @@ public static class EventDataValueDecoder { if (string.Equals(fieldName, "LogonType", StringComparison.OrdinalIgnoreCase)) { return DecodeLogonType(code); } - return string.Equals(fieldName, "TicketEncryptionType", StringComparison.OrdinalIgnoreCase) ? DecodeTicketEncryptionType(code) : null; + if (string.Equals(fieldName, "TicketEncryptionType", StringComparison.OrdinalIgnoreCase)) { return DecodeTicketEncryptionType(code); } + + return string.Equals(fieldName, "errorCode", StringComparison.OrdinalIgnoreCase) ? DecodeHResult(code) : null; } + // Curated update/servicing failure HRESULTs; an unrecognized code falls back to its hex form at the call site. + private static string? DecodeHResult(long code) => (uint)code switch + { + 0x800F081F => "CBS_E_SOURCE_MISSING", + 0x800F0922 => "CBS_E_INSTALLERS_FAILED", + 0x800F0823 => "CBS_E_NEW_SERVICING_STACK_REQUIRED", + 0x80073712 => "ERROR_SXS_COMPONENT_STORE_CORRUPT", + 0x80D05001 => "DO_E_HTTP_BLOCKSIZE_MISMATCH", + 0x80246007 => "WU_E_DM_NOTDOWNLOADED", + _ => null + }; + private static string? DecodeLogonType(long code) => code switch { 0 => "System", diff --git a/src/EventLogExpert.Runtime/DetailsPane/EventFieldExplainer.cs b/src/EventLogExpert.Runtime/DetailsPane/EventFieldExplainer.cs index 97314c91..4829ad7a 100644 --- a/src/EventLogExpert.Runtime/DetailsPane/EventFieldExplainer.cs +++ b/src/EventLogExpert.Runtime/DetailsPane/EventFieldExplainer.cs @@ -104,8 +104,17 @@ public static bool TryExplain( return null; } - private static string? TryDecodeValue(string fieldName, in EventFieldValue value) => - value.TryGetWholeNumber(out long code) ? EventDataValueDecoder.TryDecodeLabel(fieldName, code) : null; + private static string? TryDecodeValue(string fieldName, in EventFieldValue value) + { + // errorCode is a win:HexInt32 whose high-bit failure codes read as a negative Int32 that TryGetWholeNumber rejects, + // so it decodes through the HRESULT-32 reader; every other decoded field keeps the strict whole-number parse. + if (string.Equals(fieldName, "errorCode", StringComparison.OrdinalIgnoreCase)) + { + return value.TryGetHResult32(out uint hresult) ? EventDataValueDecoder.TryDecodeLabel(fieldName, hresult) : null; + } + + return value.TryGetWholeNumber(out long code) ? EventDataValueDecoder.TryDecodeLabel(fieldName, code) : null; + } private sealed record GlossaryEntry(string? Provider, int? EventId, string Field, string Description); } diff --git a/src/EventLogExpert.Runtime/Histogram/HistogramBuilder.cs b/src/EventLogExpert.Runtime/Histogram/HistogramBuilder.cs index 17ac80d4..e3a3717c 100644 --- a/src/EventLogExpert.Runtime/Histogram/HistogramBuilder.cs +++ b/src/EventLogExpert.Runtime/Histogram/HistogramBuilder.cs @@ -10,6 +10,13 @@ namespace EventLogExpert.Runtime.Histogram; public static class HistogramBuilder { + private const string ErrorCodeEventNoun = "error-code events"; + // The ErrorCode dimension keys on the lowercase win:HexInt32 / win:UnicodeString errorCode field, scoped to the update + // and servicing providers so a generic errorCode field on an unrelated provider does not pollute the failure view. + private const string ErrorCodeFieldName = "errorCode"; + + private static readonly string[] s_updateProviders = ["Microsoft-Windows-WindowsUpdateClient", "Microsoft-Windows-Servicing"]; + public static HistogramData? Build( IEventColumnView view, HistogramDimension dimension, @@ -31,6 +38,11 @@ public static class HistogramBuilder return BuildByEventData(view, ToEventDataFieldName(dimension), minTicks, maxTicks, bucketSpanTicks, bucketCount, cancellationToken); } + if (dimension is HistogramDimension.ErrorCode) + { + return BuildByErrorCode(view, minTicks, maxTicks, bucketSpanTicks, bucketCount, cancellationToken); + } + (int[] slotCounts, int slotCount, IReadOnlyList groups) = dimension switch { HistogramDimension.Severity => ScanSeverity(view, minTicks, bucketSpanTicks, bucketCount, cancellationToken), @@ -54,6 +66,64 @@ public static class HistogramBuilder groups); } + private static HistogramData BuildByErrorCode( + IEventColumnView view, + long minTicks, + long maxTicks, + long bucketSpanTicks, + int bucketCount, + CancellationToken cancellationToken) + { + var counts = new Dictionary(); + view.CountEventDataHResults(ErrorCodeFieldName, s_updateProviders, counts, cancellationToken); + + var minUtc = new DateTime(minTicks, DateTimeKind.Utc); + var maxUtc = new DateTime(maxTicks, DateTimeKind.Utc); + + if (counts.Count == 0) + { + // Total = 0 is the honest failure-subset count (not view.Count), and the noun keeps the always-present region + // announcement accurate ("0 error-code events") over a view that may still hold successful (errorCode = 0) rows. + return new HistogramData([], 0, bucketCount, minUtc, maxUtc, 0, bucketSpanTicks, []) + { + GroupingFieldAbsent = true, + EventNoun = ErrorCodeEventNoun + }; + } + + long[] targetCodes = counts + .OrderByDescending(pair => pair.Value) + .ThenBy(pair => pair.Key) + .Take(HistogramConstants.MaxGroupByCategories) + .Select(pair => pair.Key) + .ToArray(); + + int slotCount = targetCodes.Length + 1; + int[] slotCounts = new int[bucketCount * slotCount]; + view.BucketTimeTicksByEventDataHResult(minTicks, bucketSpanTicks, bucketCount, ErrorCodeFieldName, s_updateProviders, targetCodes, slotCounts, cancellationToken); + + int total = 0; + + foreach (int count in slotCounts) { total += count; } + + // Key = the raw invariant code (stable toggle key); Label = the 8-digit hex form plus its curated HRESULT symbol. + string[] keys = Array.ConvertAll(targetCodes, code => code.ToString(CultureInfo.InvariantCulture)); + string[] labels = Array.ConvertAll(targetCodes, FormatErrorCodeLabel); + + return new HistogramData( + slotCounts, + slotCount, + bucketCount, + minUtc, + maxUtc, + total, + bucketSpanTicks, + HistogramGroups.ForCategories(keys, labels)) + { + EventNoun = ErrorCodeEventNoun + }; + } + private static HistogramData BuildByEventData( IEventColumnView view, string fieldName, @@ -110,6 +180,14 @@ private static HistogramData BuildByEventData( HistogramGroups.ForCategories(keys, labels)); } + private static string FormatErrorCodeLabel(long code) + { + string hex = "0x" + ((uint)code).ToString("X8", CultureInfo.InvariantCulture); + string? symbol = EventDataValueDecoder.TryDecodeLabel(ErrorCodeFieldName, code); + + return symbol is null ? hex : $"{hex} {symbol}"; + } + private static (int[] SlotCounts, int SlotCount, IReadOnlyList Groups) ScanByEventId( IEventColumnView view, long minTicks, diff --git a/src/EventLogExpert.Runtime/Histogram/HistogramData.cs b/src/EventLogExpert.Runtime/Histogram/HistogramData.cs index 96f62c5a..1835d230 100644 --- a/src/EventLogExpert.Runtime/Histogram/HistogramData.cs +++ b/src/EventLogExpert.Runtime/Histogram/HistogramData.cs @@ -18,4 +18,8 @@ public sealed record HistogramData( // whole-number value (the field is absent from every row, or every occurrence is non-numeric or out of range), so the // pane shows an explicit empty-state rather than a single, meaningless "Other" band. public bool GroupingFieldAbsent { get; init; } + + // The unit Total and the group / bin counts represent, for the accessible summaries. A subset dimension (ErrorCode + // charts only failure rows) sets a narrower noun so the announcements say "N error-code events", not the whole-view total. + public string EventNoun { get; init; } = "events"; } diff --git a/src/EventLogExpert.Runtime/Histogram/HistogramDimension.cs b/src/EventLogExpert.Runtime/Histogram/HistogramDimension.cs index 223917d9..e50485dd 100644 --- a/src/EventLogExpert.Runtime/Histogram/HistogramDimension.cs +++ b/src/EventLogExpert.Runtime/Histogram/HistogramDimension.cs @@ -12,5 +12,6 @@ public enum HistogramDimension Opcode, Log, LogonType, - TicketEncryptionType + TicketEncryptionType, + ErrorCode } diff --git a/src/EventLogExpert.Runtime/Histogram/HistogramSummary.cs b/src/EventLogExpert.Runtime/Histogram/HistogramSummary.cs index 493e78a4..ee51bfc4 100644 --- a/src/EventLogExpert.Runtime/Histogram/HistogramSummary.cs +++ b/src/EventLogExpert.Runtime/Histogram/HistogramSummary.cs @@ -13,19 +13,20 @@ public static string RegionLabel(HistogramData data, TimeZoneInfo displayZone) var min = TimeZoneInfo.ConvertTimeFromUtc(data.MinUtc, displayZone); var max = TimeZoneInfo.ConvertTimeFromUtc(data.MaxUtc, displayZone); - return $"Timeline: {data.Total} events from {min:g} to {max:g}{GroupBreakdown(GroupTotals(data), data.Groups)}."; + return $"Timeline: {data.Total} {data.EventNoun} from {min:g} to {max:g}{GroupBreakdown(GroupTotals(data), data.Groups)}."; } - public static string WindowAnnouncement(HistogramRender render, IReadOnlyList groups, TimeZoneInfo displayZone) + public static string WindowAnnouncement(HistogramRender render, IReadOnlyList groups, string eventNoun, TimeZoneInfo displayZone) { ArgumentNullException.ThrowIfNull(render); ArgumentNullException.ThrowIfNull(groups); + ArgumentException.ThrowIfNullOrEmpty(eventNoun); ArgumentNullException.ThrowIfNull(displayZone); var start = TimeZoneInfo.ConvertTimeFromUtc(new DateTime(render.WindowStartTicks, DateTimeKind.Utc), displayZone); var end = TimeZoneInfo.ConvertTimeFromUtc(new DateTime(render.WindowEndTicks, DateTimeKind.Utc), displayZone); - return $"Showing {start:g} to {end:g}: {render.WindowTotal} events{GroupBreakdown(render.WindowGroupTotals, groups)}."; + return $"Showing {start:g} to {end:g}: {render.WindowTotal} {eventNoun}{GroupBreakdown(render.WindowGroupTotals, groups)}."; } private static string GroupBreakdown(int[] totals, IReadOnlyList groups) diff --git a/src/EventLogExpert.Runtime/LogTable/CombinedColumnView.cs b/src/EventLogExpert.Runtime/LogTable/CombinedColumnView.cs index 143ba14e..2c1b44cf 100644 --- a/src/EventLogExpert.Runtime/LogTable/CombinedColumnView.cs +++ b/src/EventLogExpert.Runtime/LogTable/CombinedColumnView.cs @@ -79,6 +79,29 @@ public void BucketTimeTicksByEventData( } } + public void BucketTimeTicksByEventDataHResult( + long minTicks, + long bucketSpanTicks, + int bucketCount, + string fieldName, + IReadOnlyCollection eligibleProviders, + long[] targetCodes, + int[] slotCounts, + CancellationToken cancellationToken) + { + foreach (var view in _views) + { + view.BucketTimeTicksByEventDataHResult(minTicks, + bucketSpanTicks, + bucketCount, + fieldName, + eligibleProviders, + targetCodes, + slotCounts, + cancellationToken); + } + } + public void BucketTimeTicksByEventId( long minTicks, long bucketSpanTicks, @@ -132,6 +155,14 @@ public void BucketTimeTicksBySeverity( } } + public void CountEventDataHResults(string fieldName, IReadOnlyCollection eligibleProviders, IDictionary counts, CancellationToken cancellationToken) + { + foreach (var view in _views) + { + view.CountEventDataHResults(fieldName, eligibleProviders, counts, cancellationToken); + } + } + public void CountEventDataValues(string fieldName, IDictionary counts, CancellationToken cancellationToken) { foreach (var view in _views) diff --git a/src/EventLogExpert.Runtime/LogTable/EventColumnView.cs b/src/EventLogExpert.Runtime/LogTable/EventColumnView.cs index 43a053c8..02565787 100644 --- a/src/EventLogExpert.Runtime/LogTable/EventColumnView.cs +++ b/src/EventLogExpert.Runtime/LogTable/EventColumnView.cs @@ -51,6 +51,26 @@ public void BucketTimeTicksByEventData( slotCounts, cancellationToken); + public void BucketTimeTicksByEventDataHResult( + long minTicks, + long bucketSpanTicks, + int bucketCount, + string fieldName, + IReadOnlyCollection eligibleProviders, + long[] targetCodes, + int[] slotCounts, + CancellationToken cancellationToken) => + _reader.BucketTimeTicksByEventDataHResult( + _rankByPhysical, + minTicks, + bucketSpanTicks, + bucketCount, + fieldName, + eligibleProviders, + targetCodes, + slotCounts, + cancellationToken); + public void BucketTimeTicksByEventId( long minTicks, long bucketSpanTicks, @@ -96,6 +116,13 @@ public void BucketTimeTicksBySeverity( slotCounts, cancellationToken); + public void CountEventDataHResults( + string fieldName, + IReadOnlyCollection eligibleProviders, + IDictionary counts, + CancellationToken cancellationToken) => + _reader.CountEventDataHResults(_rankByPhysical, fieldName, eligibleProviders, counts, cancellationToken); + public void CountEventDataValues( string fieldName, IDictionary counts, diff --git a/src/EventLogExpert.Runtime/LogTable/IEventColumnView.cs b/src/EventLogExpert.Runtime/LogTable/IEventColumnView.cs index d31e50c1..c0a0c9ce 100644 --- a/src/EventLogExpert.Runtime/LogTable/IEventColumnView.cs +++ b/src/EventLogExpert.Runtime/LogTable/IEventColumnView.cs @@ -28,6 +28,21 @@ void BucketTimeTicksByEventData( int[] slotCounts, CancellationToken cancellationToken); + /// + /// 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. + /// + void BucketTimeTicksByEventDataHResult( + long minTicks, + long bucketSpanTicks, + int bucketCount, + string fieldName, + IReadOnlyCollection eligibleProviders, + long[] targetCodes, + int[] slotCounts, + CancellationToken cancellationToken); + /// Group-by variant keyed on the numeric event id; (targetIds length + 1) slots per bin. void BucketTimeTicksByEventId( long minTicks, @@ -61,6 +76,13 @@ void BucketTimeTicksBySeverity( int[] slotCounts, CancellationToken cancellationToken); + /// + /// 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. + /// + void CountEventDataHResults(string fieldName, IReadOnlyCollection eligibleProviders, IDictionary counts, CancellationToken cancellationToken); + /// /// Tallies this view's rows by the whole-number code of a named EventData field (accumulating across a combined /// view, since a numeric code is store-independent); resolves the top-N group-by categories for the histogram. diff --git a/src/EventLogExpert.UI/LogTable/Histogram/HistogramPane.razor b/src/EventLogExpert.UI/LogTable/Histogram/HistogramPane.razor index 285872ce..726eb4b2 100644 --- a/src/EventLogExpert.UI/LogTable/Histogram/HistogramPane.razor +++ b/src/EventLogExpert.UI/LogTable/Histogram/HistogramPane.razor @@ -12,6 +12,7 @@ ToStringFunc="@DimensionLabel" Value="_dimension" ValueChanged="OnDimensionSelected"> + @@ -182,7 +183,11 @@ } else if (_baseData is { GroupingFieldAbsent: true }) { -
No @DimensionLabel(_dimension) values in this view.
+
@EmptyStateMessage(_dimension, false)
+ } + else if (_render is { Bins.Count: > 0, WindowTotal: 0 }) + { +
@EmptyStateMessage(_dimension, true)
} else { diff --git a/src/EventLogExpert.UI/LogTable/Histogram/HistogramPane.razor.cs b/src/EventLogExpert.UI/LogTable/Histogram/HistogramPane.razor.cs index 692a0483..11d9775d 100644 --- a/src/EventLogExpert.UI/LogTable/Histogram/HistogramPane.razor.cs +++ b/src/EventLogExpert.UI/LogTable/Histogram/HistogramPane.razor.cs @@ -246,9 +246,22 @@ protected override void OnInitialized() HistogramDimension.LogonType => "Logon Type", HistogramDimension.TaskCategory => "Task Category", HistogramDimension.TicketEncryptionType => "Ticket Encryption Type", + HistogramDimension.ErrorCode => "Error Code", _ => dimension.ToString() }; + // ErrorCode charts a failure subset, so its empty-state reports the absence of failures (not of the field, which may be + // present as errorCode = 0); visibleRange distinguishes a zoomed window with no failures from the whole view having none. + private static string EmptyStateMessage(HistogramDimension dimension, bool visibleRange) => dimension switch + { + HistogramDimension.ErrorCode => visibleRange + ? "No update error codes in the visible range." + : "No update error codes in this view.", + _ => visibleRange + ? "No events to chart in the current view." + : $"No {DimensionLabel(dimension)} values in this view." + }; + private static string FindMarkerPoints(double centerX) => $"{FormatCoordinate(centerX - 3)},0 {FormatCoordinate(centerX + 3)},0 {FormatCoordinate(centerX)},5"; @@ -273,7 +286,7 @@ private void AggregateAndRender(bool syncScrollbar = true) { // Match the visible empty-state so a screen reader is told why the timeline is blank, and drop any stale bin readout. _binAnnouncement = string.Empty; - _announcement = $"No {DimensionLabel(_dimension)} values in this view."; + _announcement = EmptyStateMessage(_dimension, visibleRange: false); } StateHasChanged(); @@ -301,7 +314,7 @@ await InvokeAsync(() => { if (generation != _announceGeneration || _disposed || _render is not { } render || _baseData is not { } data) { return; } - _announcement = HistogramSummary.WindowAnnouncement(render, data.Groups, _timeZone); + _announcement = HistogramSummary.WindowAnnouncement(render, data.Groups, data.EventNoun, _timeZone); StateHasChanged(); }); } @@ -394,7 +407,7 @@ private string BarTooltip(HistogramRenderBin bin) string startText = crossesDay ? $"{start:d} {start:HH:mm:ss}" : $"{start:HH:mm:ss}"; string endText = crossesDay ? $"{end:d} {end:HH:mm:ss}" : $"{end:HH:mm:ss}"; - return $"{bin.Total} events{GroupBreakdown(bin)}, {startText} - {endText}"; + return $"{bin.Total} {_baseData?.EventNoun ?? "events"}{GroupBreakdown(bin)}, {startText} - {endText}"; } private string BinCursorAnnouncement(HistogramRenderBin bin) @@ -403,7 +416,7 @@ private string BinCursorAnnouncement(HistogramRenderBin bin) var end = ToDisplay(new DateTime(Math.Max(bin.StartTicks, bin.EndTicks), DateTimeKind.Utc)); string anomaly = bin.IsAnomaly ? ", spike" : string.Empty; - return $"{start:g} to {end:g}: {bin.Total} events{GroupBreakdown(bin)}{anomaly}."; + return $"{start:g} to {end:g}: {bin.Total} {_baseData?.EventNoun ?? "events"}{GroupBreakdown(bin)}{anomaly}."; } private void ClearBinCursor() diff --git a/tests/Shared/EventLogExpert.Eventing.TestUtils/Common/Events/LegacyEventColumnReader.cs b/tests/Shared/EventLogExpert.Eventing.TestUtils/Common/Events/LegacyEventColumnReader.cs index fcfe0338..e052f82d 100644 --- a/tests/Shared/EventLogExpert.Eventing.TestUtils/Common/Events/LegacyEventColumnReader.cs +++ b/tests/Shared/EventLogExpert.Eventing.TestUtils/Common/Events/LegacyEventColumnReader.cs @@ -74,6 +74,50 @@ public void BucketTimeTicksByEventData( } } + public void BucketTimeTicksByEventDataHResult( + ReadOnlySpan rankByPhysical, + long minTicks, + long bucketSpanTicks, + int bucketCount, + string fieldName, + IReadOnlyCollection eligibleProviders, + long[] targetCodes, + int[] slotCounts, + CancellationToken cancellationToken) + { + ArgumentException.ThrowIfNullOrEmpty(fieldName); + ArgumentNullException.ThrowIfNull(eligibleProviders); + ArgumentNullException.ThrowIfNull(targetCodes); + ArgumentNullException.ThrowIfNull(slotCounts); + ArgumentOutOfRangeException.ThrowIfNotEqual(rankByPhysical.Length, Count); + + int slotCount = targetCodes.Length + 1; + int otherSlot = targetCodes.Length; + IReadOnlySet eligibleNames = AsOrdinalSet(eligibleProviders); + + for (int index = 0; index < _events.Count; index++) + { + if (rankByPhysical[index] < 0) { continue; } + + ResolvedEvent resolved = _events[index]; + + if (!eligibleNames.Contains(resolved.Source)) { continue; } + + if (!(resolved.EventData.TryGetValue(fieldName, out EventFieldValue value) && value.TryGetHResult32(out uint hresult) && hresult != 0)) { continue; } + + long bucket = (resolved.TimeCreated.Ticks - minTicks) / bucketSpanTicks; + int clamped = bucket < 0 ? 0 : bucket >= bucketCount ? bucketCount - 1 : (int)bucket; + int slot = otherSlot; + + for (int target = 0; target < targetCodes.Length; target++) + { + if (targetCodes[target] == hresult) { slot = target; break; } + } + + slotCounts[(clamped * slotCount) + slot]++; + } + } + public void BucketTimeTicksByEventId( ReadOnlySpan rankByPhysical, long minTicks, @@ -239,6 +283,31 @@ public void CopyPoolIndexColumn(EventFieldId field, int[] poolIndices) } } + public void CountEventDataHResults(ReadOnlySpan rankByPhysical, string fieldName, IReadOnlyCollection eligibleProviders, IDictionary counts, CancellationToken cancellationToken) + { + ArgumentException.ThrowIfNullOrEmpty(fieldName); + ArgumentNullException.ThrowIfNull(eligibleProviders); + ArgumentNullException.ThrowIfNull(counts); + ArgumentOutOfRangeException.ThrowIfNotEqual(rankByPhysical.Length, Count); + + IReadOnlySet eligibleNames = AsOrdinalSet(eligibleProviders); + + for (int index = 0; index < _events.Count; index++) + { + if (rankByPhysical[index] < 0) { continue; } + + ResolvedEvent resolved = _events[index]; + + if (!eligibleNames.Contains(resolved.Source)) { continue; } + + if (resolved.EventData.TryGetValue(fieldName, out EventFieldValue value) && value.TryGetHResult32(out uint hresult) && hresult != 0) + { + long code = hresult; + counts[code] = counts.TryGetValue(code, out int existing) ? existing + 1 : 1; + } + } + } + public void CountEventDataValues(ReadOnlySpan rankByPhysical, string fieldName, IDictionary counts, CancellationToken cancellationToken) { ArgumentException.ThrowIfNullOrEmpty(fieldName); @@ -357,6 +426,8 @@ public bool TryGetTimeTicksRange( return any; } + private static HashSet AsOrdinalSet(IReadOnlyCollection providers) => new(providers, StringComparer.Ordinal); + private static string? FieldValue(ResolvedEvent @event, EventFieldId field) => field switch { EventFieldId.Source => @event.Source, diff --git a/tests/Unit/EventLogExpert.Eventing.Tests/Common/Events/EventColumnStoreEventDataTests.cs b/tests/Unit/EventLogExpert.Eventing.Tests/Common/Events/EventColumnStoreEventDataTests.cs index e92b92f4..e1abf477 100644 --- a/tests/Unit/EventLogExpert.Eventing.Tests/Common/Events/EventColumnStoreEventDataTests.cs +++ b/tests/Unit/EventLogExpert.Eventing.Tests/Common/Events/EventColumnStoreEventDataTests.cs @@ -10,8 +10,11 @@ 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 string[] s_updateProviders = [WuClient, "Microsoft-Windows-Servicing"]; + [Fact] public void BucketTimeTicksByEventData_ClassifiesRowsByCodeWithOtherForNonTargets() { @@ -59,6 +62,149 @@ public void BucketTimeTicksByEventData_IsAllocationFreeOnSealedRows() Assert.True(delta < 512, $"Per-row allocation detected: {delta} bytes over {events.Length} sealed rows."); } + [Fact] + public void BucketTimeTicksByEventDataHResult_ClassifiesEligibleFailures_OmitsIneligible() + { + IEventColumnReader reader = ReaderFor( + UpdateEvent(WuClient, unchecked((int)0x800F081Fu)), + UpdateEvent(WuClient, unchecked((int)0x800F0823u)), + UpdateEvent(WuClient, unchecked((int)0x80070005u)), + UpdateEvent(WuClient, 0), + UpdateEvent("Some-Other-Provider", unchecked((int)0x800F081Fu))); + + 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); + + Assert.Equal(1, slotCounts[0]); + Assert.Equal(1, slotCounts[1]); + Assert.Equal(1, slotCounts[2]); // Other holds only 0x80070005; the zero and ineligible rows are omitted, not bucketed + } + + [Fact] + public void BucketTimeTicksByEventDataHResult_IsAllocationFreeOnSealedRows() + { + var events = new ResolvedEvent[8192]; + + for (int index = 0; index < events.Length; index++) + { + events[index] = UpdateEvent(WuClient, index % 3 == 0 ? unchecked((int)0x800F081Fu) : unchecked((int)0x800F0823u), index); + } + + IEventColumnReader reader = EventColumnStore.Build(events, generation: 0, contentVersion: 0).CreateReader(s_logId); + int[] rank = AllSurvive(reader.Count); + 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); + + long before = GC.GetAllocatedBytesForCurrentThread(); + reader.BucketTimeTicksByEventDataHResult(rank, 0, long.MaxValue, 1, "errorCode", s_updateProviders, 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 + // allocation-free; a single per-row byte would cost 8192. + Assert.True(delta < 512, $"Per-row allocation detected: {delta} bytes over {events.Length} sealed rows."); + } + + [Fact] + public void CountEventDataHResults_CaseInsensitiveAllowlist_IsNormalizedToOrdinal() + { + // A caller-supplied OrdinalIgnoreCase set must be normalized to ordinal so pending matching stays case-sensitive like + // the sealed pool lookup; otherwise a case-variant provider would match pending rows but not sealed rows. + var caseInsensitive = new HashSet([WuClient], StringComparer.OrdinalIgnoreCase); + ResolvedEvent[] events = + [ + UpdateEvent(WuClient, unchecked((int)0x800F0823u)), + UpdateEvent("MICROSOFT-WINDOWS-WINDOWSUPDATECLIENT", unchecked((int)0x800F081Fu)) + ]; + + foreach (bool sealRows in new[] { true, false }) + { + EventColumnStore store = sealRows + ? EventColumnStore.Build(events, generation: 0, contentVersion: 0) + : EventColumnStore.Build([], generation: 0, contentVersion: 0).Append(events); + IEventColumnReader reader = store.CreateReader(s_logId); + + var counts = new Dictionary(); + reader.CountEventDataHResults(AllSurvive(reader.Count), "errorCode", caseInsensitive, counts, CancellationToken.None); + + Assert.Single(counts); + Assert.Equal(1, counts[0x800F0823L]); + Assert.DoesNotContain(0x800F081FL, counts.Keys); + } + } + + [Fact] + public void CountEventDataHResults_FoldsHexAndDecimalStringSpellings() + { + IEventColumnReader reader = ReaderFor( + UpdateEvent(WuClient, "0x800F081F"), + UpdateEvent(WuClient, "2148468767")); // decimal spelling of 0x800F081F + + var counts = new Dictionary(); + reader.CountEventDataHResults(AllSurvive(reader.Count), "errorCode", s_updateProviders, counts, CancellationToken.None); + + Assert.Single(counts); + Assert.Equal(2, counts[0x800F081FL]); + } + + [Fact] + public void CountEventDataHResults_IsAllocationFreeOnPendingRows() + { + // Stay below TargetChunkSize so Append leaves every row pending (unsealed), exercising the pending provider-match path. + var events = new ResolvedEvent[3000]; + + for (int index = 0; index < events.Length; index++) + { + events[index] = UpdateEvent(WuClient, index % 3 == 0 ? unchecked((int)0x800F081Fu) : unchecked((int)0x800F0823u), index); + } + + EventColumnStore store = EventColumnStore.Build([], generation: 0, contentVersion: 0).Append(events); + Assert.Equal(0, store.SealedCount); + IEventColumnReader reader = store.CreateReader(s_logId); + int[] rank = AllSurvive(reader.Count); + var counts = new Dictionary(); + + reader.CountEventDataHResults(rank, "errorCode", s_updateProviders, counts, CancellationToken.None); + + counts.Clear(); + long before = GC.GetAllocatedBytesForCurrentThread(); + reader.CountEventDataHResults(rank, "errorCode", s_updateProviders, 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). + Assert.True(delta < 4096, $"Per-row allocation detected on pending rows: {delta} bytes over {events.Length} rows."); + } + + [Fact] + public void CountEventDataHResults_OmitsZeroAbsentAndIneligibleProvider() + { + IEventColumnReader reader = ReaderFor( + UpdateEvent(WuClient, unchecked((int)0x800F0823u)), + UpdateEvent(WuClient, 0), + UpdateEventNoData(WuClient), + UpdateEvent("Some-Other-Provider", unchecked((int)0x800F0823u))); + + var counts = new Dictionary(); + reader.CountEventDataHResults(AllSurvive(reader.Count), "errorCode", s_updateProviders, counts, CancellationToken.None); + + Assert.Single(counts); + Assert.Equal(1, counts[0x800F0823L]); + } + + [Fact] + 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); + + Assert.Single(counts); + Assert.Equal(1, counts[0x800F0823L]); + } + [Fact] public void CountEventDataValues_FoldsDecimalAndHexSpellingsOfOneCode() { @@ -132,4 +278,11 @@ private static ResolvedEvent EventWithoutData() => private static IEventColumnReader ReaderFor(params ResolvedEvent[] events) => EventColumnStore.Build(events, generation: 0, contentVersion: 0).CreateReader(s_logId); + + 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)); + + private static ResolvedEvent UpdateEventNoData(string source) => + new("TestLog", LogPathType.Channel) { Id = 44, Source = source, TimeCreated = new DateTime(0, DateTimeKind.Utc) }; } diff --git a/tests/Unit/EventLogExpert.Eventing.Tests/Common/Events/EventColumnStoreReaderParityTests.cs b/tests/Unit/EventLogExpert.Eventing.Tests/Common/Events/EventColumnStoreReaderParityTests.cs index cef3a53e..67302c57 100644 --- a/tests/Unit/EventLogExpert.Eventing.Tests/Common/Events/EventColumnStoreReaderParityTests.cs +++ b/tests/Unit/EventLogExpert.Eventing.Tests/Common/Events/EventColumnStoreReaderParityTests.cs @@ -142,6 +142,31 @@ public void GetUserDataIncomplete_SealedRows_MatchLegacyReader() AssertUserDataIncompleteParity(legacy, column, corpus.Length); } + [Fact] + public void HResultScans_SealedAndPending_MatchLegacyReader() + { + string[] providers = ["Microsoft-Windows-WindowsUpdateClient", "Microsoft-Windows-Servicing"]; + long[] targetCodes = [0x800F0823L, 0x800F081FL]; + + foreach (bool sealRows in new[] { true, false }) + { + (IEventColumnReader legacy, IEventColumnReader column) = BuildErrorCodeReaders(sealRows); + int[] rank = AllSurvive(legacy.Count); + + var legacyCounts = new Dictionary(); + var columnCounts = new Dictionary(); + legacy.CountEventDataHResults(rank, "errorCode", providers, legacyCounts, CancellationToken.None); + column.CountEventDataHResults(rank, "errorCode", providers, 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); + Assert.Equal(legacySlots, columnSlots); + } + } + [Fact] public void Surface_PendingStore_MatchesLegacyReader() { @@ -174,6 +199,15 @@ public void TryGetEventData_SealedRowsForEveryProbeName_MatchLegacyReader() AssertEventDataParity(legacy, column, corpus); } + private static int[] AllSurvive(int count) + { + int[] rank = new int[count]; + + for (int index = 0; index < count; index++) { rank[index] = index; } + + return rank; + } + private static void AssertEventDataEnumerationParity(IEventColumnReader legacy, IEventColumnReader column, int count) { for (int index = 0; index < count; index++) @@ -407,6 +441,26 @@ private static ResolvedEvent[] BuildCorpus() => "v0", "v1", "v2") ]; + private static ResolvedEvent[] BuildErrorCodeCorpus() => + [ + ErrorCodeEvent("Microsoft-Windows-WindowsUpdateClient", unchecked((int)0x800F0823u), 0), + ErrorCodeEvent("Microsoft-Windows-Servicing", "0x800F081F", 1), + 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) + ]; + + private static (IEventColumnReader Legacy, IEventColumnReader Column) BuildErrorCodeReaders(bool sealRows) + { + ResolvedEvent[] corpus = BuildErrorCodeCorpus(); + EventColumnStore store = sealRows + ? EventColumnStore.Build(corpus, Generation, ContentVersion) + : EventColumnStore.Build([], Generation, ContentVersion).Append(corpus); + + return (new LegacyEventColumnReader(s_logId, store.Generation, store.ContentVersion, corpus), new EventColumnStoreReader(s_logId, store)); + } + private static (IEventColumnReader Legacy, IEventColumnReader Column, ResolvedEvent[] Corpus) BuildPending() { ResolvedEvent[] corpus = BuildCorpus(); @@ -471,6 +525,10 @@ private static List CollectUserDataKeys(ResolvedEvent[] corpus) return [.. keys]; } + private static ResolvedEvent ErrorCodeEvent(string source, object errorCode, int index) => + new ResolvedEvent("TestLog", LogPathType.Channel) { Id = 20, Source = source, TimeCreated = s_time.AddMinutes(index) } + .WithEventData(("errorCode", errorCode)); + private static List<(string Name, EventFieldValueKind Kind, string Value)> MaterializeEventData( IEventColumnReader reader, EventLocator locator) diff --git a/tests/Unit/EventLogExpert.Eventing.Tests/Common/Events/EventFieldValueTests.cs b/tests/Unit/EventLogExpert.Eventing.Tests/Common/Events/EventFieldValueTests.cs index dca79f95..e9fcbbf6 100644 --- a/tests/Unit/EventLogExpert.Eventing.Tests/Common/Events/EventFieldValueTests.cs +++ b/tests/Unit/EventLogExpert.Eventing.Tests/Common/Events/EventFieldValueTests.cs @@ -217,4 +217,68 @@ public void TryGetGuid_ReturnsStoredValue() Assert.True(value.TryGetGuid(out Guid result)); Assert.Equal(guid, result); } + + [Fact] + public void TryGetHResult32_HexInt32SignExtendedNegative_ReinterpretsUnsigned() + { + // A win:HexInt32 failure code (high bit set) is stored as a negative Int32; TryGetWholeNumber rejects it, but the + // HRESULT reader reinterprets the low 32 bits: 0x800F0823 == -2146498525 signed. + EventFieldValue value = EventFieldValue.FromProperty(-2146498525); + + Assert.False(value.TryGetWholeNumber(out _)); + Assert.True(value.TryGetHResult32(out uint code)); + Assert.Equal(0x800F0823u, code); + } + + [Fact] + public void TryGetHResult32_NonNumericString_Rejected() + { + Assert.False(EventFieldValue.FromProperty((EventProperty)"not-a-code").TryGetHResult32(out uint code)); + Assert.Equal(0u, code); + } + + [Fact] + public void TryGetHResult32_SignedNarrowNegative_SignExtendsToThirtyTwoBits() + { + Assert.True(EventFieldValue.FromProperty((sbyte)-1).TryGetHResult32(out uint fromSByte)); + Assert.Equal(0xFFFFFFFFu, fromSByte); + + Assert.True(EventFieldValue.FromProperty((short)-1).TryGetHResult32(out uint fromInt16)); + Assert.Equal(0xFFFFFFFFu, fromInt16); + } + + [Theory] + [InlineData("0x800F081F", 0x800F081Fu)] + [InlineData("2148468771", 0x800F0823u)] + [InlineData("-2146498525", 0x800F0823u)] + [InlineData("0", 0u)] + public void TryGetHResult32_StringSpellings_FoldToOneCode(string text, uint expected) + { + Assert.True(EventFieldValue.FromProperty((EventProperty)text).TryGetHResult32(out uint code)); + Assert.Equal(expected, code); + } + + [Fact] + public void TryGetHResult32_UInt64OverUintMax_Rejected() + { + Assert.False(EventFieldValue.FromProperty(ulong.MaxValue).TryGetHResult32(out uint code)); + Assert.Equal(0u, code); + } + + [Fact] + public void TryGetHResult32_UnsignedIntegralAndSizeT_Read() + { + Assert.True(EventFieldValue.FromProperty(5u).TryGetHResult32(out uint fromUInt)); + Assert.Equal(5u, fromUInt); + + Assert.True(EventFieldValue.FromProperty((nuint)5).TryGetHResult32(out uint fromSizeT)); + Assert.Equal(5u, fromSizeT); + } + + [Fact] + public void TryGetHResult32_Zero_ReadsAsSuccess() + { + Assert.True(EventFieldValue.FromProperty(0).TryGetHResult32(out uint code)); + Assert.Equal(0u, code); + } } diff --git a/tests/Unit/EventLogExpert.Runtime.Tests/Common/Display/EventDataValueDecoderTests.cs b/tests/Unit/EventLogExpert.Runtime.Tests/Common/Display/EventDataValueDecoderTests.cs index 0b8c52c4..558e3828 100644 --- a/tests/Unit/EventLogExpert.Runtime.Tests/Common/Display/EventDataValueDecoderTests.cs +++ b/tests/Unit/EventLogExpert.Runtime.Tests/Common/Display/EventDataValueDecoderTests.cs @@ -18,6 +18,12 @@ public void TryDecodeLabel_FieldNameIsCaseInsensitive() => [InlineData("TicketEncryptionType", 17, "AES128")] [InlineData("TicketEncryptionType", 18, "AES256")] [InlineData("TicketEncryptionType", 23, "RC4")] + [InlineData("errorCode", 0x800F081FL, "CBS_E_SOURCE_MISSING")] + [InlineData("errorCode", 0x800F0922L, "CBS_E_INSTALLERS_FAILED")] + [InlineData("errorCode", 0x800F0823L, "CBS_E_NEW_SERVICING_STACK_REQUIRED")] + [InlineData("errorCode", 0x80073712L, "ERROR_SXS_COMPONENT_STORE_CORRUPT")] + [InlineData("errorCode", 0x80D05001L, "DO_E_HTTP_BLOCKSIZE_MISMATCH")] + [InlineData("errorCode", 0x80246007L, "WU_E_DM_NOTDOWNLOADED")] public void TryDecodeLabel_KnownCode_ReturnsFriendlyLabel(string fieldName, long code, string expected) => Assert.Equal(expected, EventDataValueDecoder.TryDecodeLabel(fieldName, code)); @@ -28,6 +34,7 @@ public void TryDecodeLabel_UndecodedField_ReturnsNull() => [Theory] [InlineData("LogonType", 99)] [InlineData("TicketEncryptionType", 99)] + [InlineData("errorCode", 0x80070005L)] public void TryDecodeLabel_UnrecognizedCode_ReturnsNull(string fieldName, long code) => Assert.Null(EventDataValueDecoder.TryDecodeLabel(fieldName, code)); } diff --git a/tests/Unit/EventLogExpert.Runtime.Tests/DetailsPane/EventFieldExplainerTests.cs b/tests/Unit/EventLogExpert.Runtime.Tests/DetailsPane/EventFieldExplainerTests.cs index a4a652ef..64242575 100644 --- a/tests/Unit/EventLogExpert.Runtime.Tests/DetailsPane/EventFieldExplainerTests.cs +++ b/tests/Unit/EventLogExpert.Runtime.Tests/DetailsPane/EventFieldExplainerTests.cs @@ -11,6 +11,22 @@ public sealed class EventFieldExplainerTests { private const string SecurityAuditing = "Microsoft-Windows-Security-Auditing"; + [Fact] + public void ErrorCode_NegativeHexInt32_DecodesToSymbol() + { + // The high-bit HexInt32 that the LogonType (TryGetWholeNumber) path rejects must decode through the HResult reader, + // so the details pane shows the same curated symbol the histogram legend does. + Assert.True(EventFieldExplainer.TryExplain("Microsoft-Windows-WindowsUpdateClient", 20, "errorCode", ValueOf(unchecked((int)0x800F081Fu)), out EventFieldExplanation explanation)); + Assert.Equal("CBS_E_SOURCE_MISSING", explanation.DecodedLabel); + } + + [Fact] + public void ErrorCode_UnknownCode_IsNotDecoded() + { + Assert.False(EventFieldExplainer.TryExplain("Microsoft-Windows-WindowsUpdateClient", 20, "errorCode", ValueOf(unchecked((int)0x80070005u)), out EventFieldExplanation explanation)); + Assert.Null(explanation.DecodedLabel); + } + [Fact] public void Explain_AmbiguousBareName_IsNotExplained() { diff --git a/tests/Unit/EventLogExpert.Runtime.Tests/Histogram/HistogramBuilderTests.cs b/tests/Unit/EventLogExpert.Runtime.Tests/Histogram/HistogramBuilderTests.cs index 7eba585b..4173cd0b 100644 --- a/tests/Unit/EventLogExpert.Runtime.Tests/Histogram/HistogramBuilderTests.cs +++ b/tests/Unit/EventLogExpert.Runtime.Tests/Histogram/HistogramBuilderTests.cs @@ -102,6 +102,68 @@ public void Build_FollowLatest_BucketsEverySurvivorOverTheSurvivorSpan() Assert.True(data.BucketSpanTicks >= 1); } + [Fact] + public void Build_GroupByErrorCode_NoFailures_SignalsEmptyStateWithZeroTotalAndNoun() + { + var view = DisplayViewTestFactory.Build(s_logId, ErrorCodeEvents( + ("Microsoft-Windows-WindowsUpdateClient", 0, 4))); + + HistogramData? data = HistogramBuilder.Build(view, HistogramDimension.ErrorCode, maxBuckets: 100, CancellationToken.None); + + Assert.NotNull(data); + Assert.True(data!.GroupingFieldAbsent); + Assert.Empty(data.Groups); + Assert.Equal(0, data.Total); // the failure-subset count, not view.Count, so the region label never overstates + Assert.Equal("error-code events", data.EventNoun); + } + + [Fact] + public void Build_GroupByErrorCode_OmitsSuccessesAndIneligibleProviders() + { + var view = DisplayViewTestFactory.Build(s_logId, ErrorCodeEvents( + ("Microsoft-Windows-WindowsUpdateClient", unchecked((int)0x800F0823u), 2), + ("Microsoft-Windows-WindowsUpdateClient", 0, 5), + ("Some-Other-Provider", unchecked((int)0x800F0823u), 3))); + + HistogramData? data = HistogramBuilder.Build(view, HistogramDimension.ErrorCode, maxBuckets: 100, CancellationToken.None); + + Assert.NotNull(data); + Assert.Equal(2, data!.Total); // only the eligible failures contribute; successes and other providers are omitted + Assert.Equal(2, GroupTotal(data, 1)); + } + + [Fact] + public void Build_GroupByErrorCode_SplitsByHexLabelWithCuratedSymbol() + { + var view = DisplayViewTestFactory.Build(s_logId, ErrorCodeEvents( + ("Microsoft-Windows-WindowsUpdateClient", unchecked((int)0x800F081Fu), 2), + ("Microsoft-Windows-Servicing", "0x800F0823", 1))); + + HistogramData? data = HistogramBuilder.Build(view, HistogramDimension.ErrorCode, maxBuckets: 100, CancellationToken.None); + + Assert.NotNull(data); + Assert.False(data!.GroupingFieldAbsent); + Assert.Equal("error-code events", data.EventNoun); + Assert.Equal("0x800F081F CBS_E_SOURCE_MISSING", data.Groups[1].Label); + Assert.Equal("cat:2148468767", data.Groups[1].Key); + Assert.Equal(2, GroupTotal(data, 1)); + Assert.Equal("0x800F0823 CBS_E_NEW_SERVICING_STACK_REQUIRED", data.Groups[2].Label); + Assert.Equal(1, GroupTotal(data, 2)); + Assert.Equal(3, data.Total); + } + + [Fact] + public void Build_GroupByErrorCode_UnrecognizedCode_UsesHexLabelWithoutSymbol() + { + var view = DisplayViewTestFactory.Build(s_logId, ErrorCodeEvents( + ("Microsoft-Windows-WindowsUpdateClient", unchecked((int)0x80070005u), 1))); + + HistogramData? data = HistogramBuilder.Build(view, HistogramDimension.ErrorCode, maxBuckets: 100, CancellationToken.None); + + Assert.NotNull(data); + Assert.Equal("0x80070005", data!.Groups[1].Label); + } + [Fact] public void Build_GroupByEventId_LabelsCategoriesWithTheNumericIds() { @@ -288,6 +350,29 @@ public void Build_RecordsPerSeverityCountsInTheBaseBuffer() Assert.Equal(4, data.Total); } + private static ResolvedEvent[] ErrorCodeEvents(params (string Source, object? ErrorCode, int Count)[] groups) + { + var events = new List(); + long ticks = 0; + + foreach ((string source, object? errorCode, int count) in groups) + { + for (int index = 0; index < count; index++) + { + var @event = new ResolvedEvent("TestLog", LogPathType.Channel) + { + Id = 20, + TimeCreated = new DateTime(ticks++, DateTimeKind.Utc), + Source = source + }; + + events.Add(errorCode is null ? @event : @event.WithEventData(("errorCode", errorCode))); + } + } + + return [.. events]; + } + private static ResolvedEvent EventAt(long ticks, string level) => new("TestLog", LogPathType.Channel) { diff --git a/tests/Unit/EventLogExpert.Runtime.Tests/Histogram/HistogramSummaryTests.cs b/tests/Unit/EventLogExpert.Runtime.Tests/Histogram/HistogramSummaryTests.cs index ac026c0b..c8ede9f7 100644 --- a/tests/Unit/EventLogExpert.Runtime.Tests/Histogram/HistogramSummaryTests.cs +++ b/tests/Unit/EventLogExpert.Runtime.Tests/Histogram/HistogramSummaryTests.cs @@ -89,7 +89,7 @@ [new HistogramRenderBin(0, 10, 15, [4, 9, 2])], 15, [4, 9, 2]); - string announcement = HistogramSummary.WindowAnnouncement(render, HistogramGroups.Severity, TimeZoneInfo.Utc); + string announcement = HistogramSummary.WindowAnnouncement(render, HistogramGroups.Severity, "events", TimeZoneInfo.Utc); Assert.Contains("Showing", announcement); Assert.Contains("15 events", announcement); diff --git a/tests/Unit/EventLogExpert.Runtime.Tests/LogTable/TestSupport/LegacyEventColumnView.cs b/tests/Unit/EventLogExpert.Runtime.Tests/LogTable/TestSupport/LegacyEventColumnView.cs index ba3d8683..7c3741a4 100644 --- a/tests/Unit/EventLogExpert.Runtime.Tests/LogTable/TestSupport/LegacyEventColumnView.cs +++ b/tests/Unit/EventLogExpert.Runtime.Tests/LogTable/TestSupport/LegacyEventColumnView.cs @@ -24,6 +24,9 @@ internal sealed class LegacyEventColumnView( public void BucketTimeTicksByEventData(long minTicks, long bucketSpanTicks, int bucketCount, string fieldName, long[] targetCodes, int[] slotCounts, CancellationToken cancellationToken) => _reader.BucketTimeTicksByEventData(AllSurvive(), minTicks, bucketSpanTicks, bucketCount, fieldName, targetCodes, slotCounts, cancellationToken); + public void BucketTimeTicksByEventDataHResult(long minTicks, long bucketSpanTicks, int bucketCount, string fieldName, IReadOnlyCollection eligibleProviders, long[] targetCodes, int[] slotCounts, CancellationToken cancellationToken) => + _reader.BucketTimeTicksByEventDataHResult(AllSurvive(), minTicks, bucketSpanTicks, bucketCount, fieldName, eligibleProviders, targetCodes, slotCounts, cancellationToken); + public void BucketTimeTicksByEventId(long minTicks, long bucketSpanTicks, int bucketCount, int[] targetIds, int[] slotCounts, CancellationToken cancellationToken) => _reader.BucketTimeTicksByEventId(AllSurvive(), minTicks, bucketSpanTicks, bucketCount, targetIds, slotCounts, cancellationToken); @@ -33,6 +36,9 @@ public void BucketTimeTicksByField(long minTicks, long bucketSpanTicks, int buck public void BucketTimeTicksBySeverity(long minTicks, long bucketSpanTicks, int bucketCount, int[] slotCounts, CancellationToken cancellationToken) => _reader.BucketTimeTicksBySeverity(AllSurvive(), minTicks, bucketSpanTicks, bucketCount, slotCounts, cancellationToken); + public void CountEventDataHResults(string fieldName, IReadOnlyCollection eligibleProviders, IDictionary counts, CancellationToken cancellationToken) => + _reader.CountEventDataHResults(AllSurvive(), fieldName, eligibleProviders, counts, cancellationToken); + public void CountEventDataValues(string fieldName, IDictionary counts, CancellationToken cancellationToken) => _reader.CountEventDataValues(AllSurvive(), fieldName, counts, cancellationToken); From 99d73564e2d81ec9d20f5a58f8bd22da9c3b33b0 Mon Sep 17 00:00:00 2001 From: jschick04 Date: Fri, 17 Jul 2026 23:16:51 +0000 Subject: [PATCH 2/2] Make the HRESULT reinterpret casts explicitly unchecked --- src/EventLogExpert.Eventing/Common/Events/EventColumnStore.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/EventLogExpert.Eventing/Common/Events/EventColumnStore.cs b/src/EventLogExpert.Eventing/Common/Events/EventColumnStore.cs index 10f027ba..06689c7d 100644 --- a/src/EventLogExpert.Eventing/Common/Events/EventColumnStore.cs +++ b/src/EventLogExpert.Eventing/Common/Events/EventColumnStore.cs @@ -1519,7 +1519,7 @@ private bool TryGetHResult32FromRawField(in RawEventDataField field, out long co // A win:HexInt32 failure code (high bit set) sign-extends to a negative Int64; reinterpret the low 32 bits. if (field.Bits is >= int.MinValue and <= uint.MaxValue) { - code = (uint)field.Bits; + code = unchecked((uint)field.Bits); return true; } @@ -1536,7 +1536,7 @@ private bool TryGetHResult32FromRawField(in RawEventDataField field, out long co if (unsigned <= uint.MaxValue) { - code = (uint)unsigned; + code = unchecked((uint)unsigned); return true; }