diff --git a/docs/EventSourceAndCounters.md b/docs/EventSourceAndCounters.md index 36afedf91a23..fb2416531eec 100644 --- a/docs/EventSourceAndCounters.md +++ b/docs/EventSourceAndCounters.md @@ -169,7 +169,34 @@ namespace Microsoft.AspNetCore.Authentication.Internal ## Automated Testing of EventSources -EventSources can be tested using the `EventSourceTestBase` base class in `Microsoft.AspNetCore.InternalTesting`. An example test is below: +### Validating Event ID Consistency + +All `EventSource` subclasses should have a test that validates the `[Event(N)]` attribute IDs match the `WriteEvent(N, ...)` call arguments. This catches drift caused by bad merge resolution or missed updates that would otherwise surface only as runtime errors. + +Use the `EventSourceValidator` utility in `Microsoft.AspNetCore.InternalTesting.Tracing`: + +```csharp +using Microsoft.AspNetCore.InternalTesting.Tracing; + +public class MyEventSourceTests +{ + [Fact] + public void EventIdsAreConsistent() + { + EventSourceValidator.ValidateEventSourceIds(); + } +} +``` + +The validator: +* Uses `EventSource.GenerateManifest` with `EventManifestOptions.Strict` to perform IL-level validation that each `WriteEvent(id, ...)` call argument matches the `[Event(id)]` attribute — the same validation the .NET runtime uses internally +* Checks for duplicate event IDs across methods + +> **Important:** Every new `EventSource` class should include this one-line validation test. + +### Functional Testing with EventSourceTestBase + +EventSources can also be functionally tested using the `EventSourceTestBase` base class in `Microsoft.AspNetCore.InternalTesting`. An example test is below: ```csharp // The base class MUST be used for EventSource testing because EventSources are global and parallel tests can cause issues. diff --git a/src/Hosting/Hosting/test/Internal/HostingEventSourceTests.cs b/src/Hosting/Hosting/test/Internal/HostingEventSourceTests.cs index 2fd4b40376da..d3edd323b854 100644 --- a/src/Hosting/Hosting/test/Internal/HostingEventSourceTests.cs +++ b/src/Hosting/Hosting/test/Internal/HostingEventSourceTests.cs @@ -6,12 +6,19 @@ using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Internal; using Microsoft.AspNetCore.InternalTesting; +using Microsoft.AspNetCore.InternalTesting.Tracing; using Microsoft.Extensions.Logging; namespace Microsoft.AspNetCore.Hosting; public class HostingEventSourceTests : LoggedTest { + [Fact] + public void EventIdsAreConsistent() + { + EventSourceValidator.ValidateEventSourceIds(typeof(HostingEventSource)); + } + [Fact] public void MatchesNameAndGuid() { diff --git a/src/Servers/Kestrel/Core/test/KestrelEventSourceTests.cs b/src/Servers/Kestrel/Core/test/KestrelEventSourceTests.cs index 4a81affd241f..11a69c0efabc 100644 --- a/src/Servers/Kestrel/Core/test/KestrelEventSourceTests.cs +++ b/src/Servers/Kestrel/Core/test/KestrelEventSourceTests.cs @@ -5,6 +5,7 @@ using System.Diagnostics.Tracing; using System.Globalization; using System.Reflection; +using Microsoft.AspNetCore.InternalTesting.Tracing; using Xunit; namespace Microsoft.AspNetCore.Server.Kestrel.Core.Tests; @@ -26,4 +27,16 @@ public void ExistsWithCorrectId() Assert.Equal(Guid.Parse("bdeb4676-a36e-5442-db99-4764e2326c7d", CultureInfo.InvariantCulture), EventSource.GetGuid(esType)); Assert.NotEmpty(EventSource.GenerateManifest(esType, "assemblyPathToIncludeInManifest")); } + + [Fact] + public void EventIdsAreConsistent() + { + var esType = typeof(KestrelServer).Assembly.GetType( + "Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Infrastructure.KestrelEventSource", + throwOnError: true, + ignoreCase: false + ); + + EventSourceValidator.ValidateEventSourceIds(esType); + } } diff --git a/src/Shared/test/Shared.Tests/CertificateManagerEventSourceTests.cs b/src/Shared/test/Shared.Tests/CertificateManagerEventSourceTests.cs new file mode 100644 index 000000000000..b23eab74809c --- /dev/null +++ b/src/Shared/test/Shared.Tests/CertificateManagerEventSourceTests.cs @@ -0,0 +1,16 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.AspNetCore.Certificates.Generation; +using Microsoft.AspNetCore.InternalTesting.Tracing; + +namespace Microsoft.AspNetCore.Internal.Tests; + +public class CertificateManagerEventSourceTests +{ + [Fact] + public void EventIdsAreConsistent() + { + EventSourceValidator.ValidateEventSourceIds(); + } +} diff --git a/src/SignalR/common/Http.Connections/test/Internal/HttpConnectionsEventSourceTests.cs b/src/SignalR/common/Http.Connections/test/Internal/HttpConnectionsEventSourceTests.cs index 40b4ce1b4c8b..567ac8c75809 100644 --- a/src/SignalR/common/Http.Connections/test/Internal/HttpConnectionsEventSourceTests.cs +++ b/src/SignalR/common/Http.Connections/test/Internal/HttpConnectionsEventSourceTests.cs @@ -6,6 +6,7 @@ using System.Diagnostics.Tracing; using System.Globalization; using Microsoft.AspNetCore.Internal; +using Microsoft.AspNetCore.InternalTesting.Tracing; using Microsoft.Extensions.Internal; using Xunit; @@ -13,6 +14,12 @@ namespace Microsoft.AspNetCore.Http.Connections.Internal; public class HttpConnectionsEventSourceTests { + [Fact] + public void EventIdsAreConsistent() + { + EventSourceValidator.ValidateEventSourceIds(typeof(HttpConnectionsEventSource)); + } + [Fact] public void MatchesNameAndGuid() { diff --git a/src/Testing/src/Tracing/EventSourceValidator.cs b/src/Testing/src/Tracing/EventSourceValidator.cs new file mode 100644 index 000000000000..459f5edeb696 --- /dev/null +++ b/src/Testing/src/Tracing/EventSourceValidator.cs @@ -0,0 +1,106 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.Diagnostics.Tracing; +using System.Reflection; +using Xunit; + +namespace Microsoft.AspNetCore.InternalTesting.Tracing; + +/// +/// Validates that -derived classes have consistent +/// values and WriteEvent call arguments. +/// This catches drift caused by bad merge resolution or missed updates that would +/// otherwise surface only as runtime errors. +/// +public static class EventSourceValidator +{ + /// + /// Validates all [Event]-attributed methods on . + /// + /// A type that derives from . + public static void ValidateEventSourceIds() where T : EventSource + => ValidateEventSourceIds(typeof(T)); + + /// + /// Validates all [Event]-attributed methods on the given -derived type. + /// + /// Uses with + /// to perform IL-level validation that the integer + /// argument passed to each WriteEvent call matches the [Event(id)] attribute + /// on the calling method. This is the same validation the .NET runtime itself uses. + /// + /// + /// Additionally checks for duplicate values across methods. + /// + /// + /// A type that derives from . + public static void ValidateEventSourceIds(Type eventSourceType) + { + if (eventSourceType is null) + { + throw new ArgumentNullException(nameof(eventSourceType)); + } + + Assert.True( + typeof(EventSource).IsAssignableFrom(eventSourceType), + $"Type '{eventSourceType.FullName}' does not derive from EventSource."); + + var errors = new List(); + + // Check for duplicate Event IDs across methods. + var seenIds = new Dictionary(); + var methods = eventSourceType.GetMethods( + BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly); + + foreach (var method in methods) + { + var eventAttr = method.GetCustomAttribute(); + if (eventAttr is null) + { + continue; + } + + if (seenIds.TryGetValue(eventAttr.EventId, out var existingMethod)) + { + errors.Add( + $"Duplicate EventId {eventAttr.EventId}: methods '{existingMethod}' and '{method.Name}' share the same ID."); + } + else + { + seenIds[eventAttr.EventId] = method.Name; + } + } + + // Use GenerateManifest with Strict mode to validate that each method's + // WriteEvent(id, ...) call uses an ID that matches its [Event(id)] attribute. + // Internally this uses GetHelperCallFirstArg to IL-inspect the method body + // and extract the integer constant passed to WriteEvent — the same validation + // the .NET runtime performs when constructing an EventSource. + try + { + var manifest = EventSource.GenerateManifest( + eventSourceType, + assemblyPathToIncludeInManifest: "assemblyPathForValidation", + flags: EventManifestOptions.Strict); + + if (manifest is null) + { + errors.Add("GenerateManifest returned null, indicating the type is not a valid EventSource."); + } + } + catch (ArgumentException ex) + { + errors.Add(ex.Message); + } + + if (errors.Count > 0) + { + Assert.Fail( + $"EventSource '{eventSourceType.FullName}' has event ID validation error(s):" + + Environment.NewLine + string.Join(Environment.NewLine, errors)); + } + } +} diff --git a/src/Testing/test/EventSourceValidatorTests.cs b/src/Testing/test/EventSourceValidatorTests.cs new file mode 100644 index 000000000000..b6a1d3130141 --- /dev/null +++ b/src/Testing/test/EventSourceValidatorTests.cs @@ -0,0 +1,119 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Diagnostics.Tracing; +using Microsoft.AspNetCore.InternalTesting.Tracing; +using Xunit; + +namespace Microsoft.AspNetCore.InternalTesting; + +public class EventSourceValidatorTests +{ + [Fact] + public void ValidateEventSourceIds_PassesForCorrectEventSource() + { + EventSourceValidator.ValidateEventSourceIds(); + } + + [Fact] + public void ValidateEventSourceIds_FailsForMismatchedWriteEventId() + { + // GenerateManifest(Strict) detects the mismatch via IL inspection + // and our validator surfaces it through Assert.Fail. + // The exact runtime error message varies by .NET version, so we + // only verify the validator rejects the bad source. + Assert.ThrowsAny( + () => EventSourceValidator.ValidateEventSourceIds()); + } + + [Fact] + public void ValidateEventSourceIds_FailsForDuplicateEventIds() + { + // The duplicate ID message is produced by our validator code. + var ex = Assert.ThrowsAny( + () => EventSourceValidator.ValidateEventSourceIds()); + + Assert.Contains("Duplicate EventId 1", ex.Message); + Assert.Contains("EventAlpha", ex.Message); + Assert.Contains("EventBeta", ex.Message); + } + + [Fact] + public void ValidateEventSourceIds_FailsForNonEventSourceType() + { + // The guard clause message is produced by our validator code. + var ex = Assert.ThrowsAny( + () => EventSourceValidator.ValidateEventSourceIds(typeof(string))); + + Assert.Contains("does not derive from EventSource", ex.Message); + } + + [Fact] + public void ValidateEventSourceIds_PassesForEventSourceWithNoEvents() + { + EventSourceValidator.ValidateEventSourceIds(); + } + + [Fact] + public void ValidateEventSourceIds_PassesForEventSourceWithMultipleParameterTypes() + { + EventSourceValidator.ValidateEventSourceIds(); + } + + // -- Test-only EventSource implementations -- + + [EventSource(Name = "Test-Correct")] + private sealed class CorrectEventSource : EventSource + { + [Event(1, Level = EventLevel.Informational)] + public void EventOne(string message) => WriteEvent(1, message); + + [Event(2, Level = EventLevel.Verbose)] + public void EventTwo(int count) => WriteEvent(2, count); + + [Event(3, Level = EventLevel.Warning)] + public void EventThree() => WriteEvent(3); + } + + [EventSource(Name = "Test-MismatchedId")] + private sealed class MismatchedIdEventSource : EventSource + { + [Event(1, Level = EventLevel.Informational)] + public void EventOne(int value) => WriteEvent(99, value); + } + + [EventSource(Name = "Test-DuplicateId")] + private sealed class DuplicateIdEventSource : EventSource + { + [Event(1, Level = EventLevel.Informational)] + public void EventAlpha(string message) => WriteEvent(1, message); + + [Event(1, Level = EventLevel.Informational)] + public void EventBeta(int count) => WriteEvent(1, count); + } + + [EventSource(Name = "Test-Empty")] + private sealed class EmptyEventSource : EventSource + { + } + + [EventSource(Name = "Test-MultiParam")] + private sealed class MultiParamEventSource : EventSource + { + [Event(1, Level = EventLevel.Informational)] + public void EventWithString(string value) => WriteEvent(1, value); + + [Event(2, Level = EventLevel.Informational)] + public void EventWithInt(int value) => WriteEvent(2, value); + + [Event(3, Level = EventLevel.Informational)] + public void EventWithLong(long value) => WriteEvent(3, value); + + [Event(4, Level = EventLevel.Informational)] + public void EventWithMultiple(string name, int count) => WriteEvent(4, name, count); + + [Event(5, Level = EventLevel.Informational)] + public void EventWithNoArgs() => WriteEvent(5); + } +}