From ff45fb88a4932df231a48d431fc4410082357d24 Mon Sep 17 00:00:00 2001 From: rosebyte Date: Sun, 14 Jun 2026 07:26:21 +0200 Subject: [PATCH 1/2] add tracing configuration --- .../Common/src/System/ExceptionPolyfills.cs | 21 + ...oft.Extensions.Diagnostics.Abstractions.cs | 49 ++ ...Extensions.Diagnostics.Abstractions.csproj | 2 + .../src/Tracing/ActivityListenerBuilder.cs | 61 ++ .../src/Tracing/ActivitySourceScopes.cs | 31 + .../src/Tracing/ITracingBuilder.cs | 20 + .../TracingBuilderExtensions.Listeners.cs | 90 +++ .../Tracing/TracingBuilderExtensions.Rules.cs | 77 +++ .../src/Tracing/TracingOptions.cs | 18 + .../src/Tracing/TracingRule.cs | 95 +++ .../TracingBuilderExtensionsRulesTests.cs | 133 ++++ .../README.md | 2 + .../ref/Microsoft.Extensions.Diagnostics.cs | 17 + .../ActivityListenerConfigurationFactory.cs | 26 + ...ultActivityListenerConfigurationFactory.cs | 33 + .../TracingBuilderConfigurationExtensions.cs | 43 ++ .../Configuration/TracingConfiguration.cs | 18 + .../Configuration/TracingConfigureOptions.cs | 116 ++++ .../Tracing/DefaultActivitySourceFactory.cs | 609 ++++++++++++++++++ .../src/Tracing/TracingServiceExtensions.cs | 69 ++ .../DefaultActivitySourceFactoryTests.cs | 457 +++++++++++++ ...rosoft.Extensions.Diagnostics.Tests.csproj | 1 + .../tests/TracingConfigureOptionsTests.cs | 192 ++++++ ...em.Diagnostics.DiagnosticSourceActivity.cs | 15 +- .../src/Resources/Strings.resx | 6 + ...System.Diagnostics.DiagnosticSource.csproj | 1 + .../System/Diagnostics/ActivityListener.cs | 41 +- .../src/System/Diagnostics/ActivitySource.cs | 173 ++++- .../Diagnostics/ActivitySourceFactory.cs | 101 +++ .../Diagnostics/ActivitySourceOptions.cs | 6 + .../tests/ActivitySourceTests.cs | 405 ++++++++++++ 31 files changed, 2909 insertions(+), 19 deletions(-) create mode 100644 src/libraries/Microsoft.Extensions.Diagnostics.Abstractions/src/Tracing/ActivityListenerBuilder.cs create mode 100644 src/libraries/Microsoft.Extensions.Diagnostics.Abstractions/src/Tracing/ActivitySourceScopes.cs create mode 100644 src/libraries/Microsoft.Extensions.Diagnostics.Abstractions/src/Tracing/ITracingBuilder.cs create mode 100644 src/libraries/Microsoft.Extensions.Diagnostics.Abstractions/src/Tracing/TracingBuilderExtensions.Listeners.cs create mode 100644 src/libraries/Microsoft.Extensions.Diagnostics.Abstractions/src/Tracing/TracingBuilderExtensions.Rules.cs create mode 100644 src/libraries/Microsoft.Extensions.Diagnostics.Abstractions/src/Tracing/TracingOptions.cs create mode 100644 src/libraries/Microsoft.Extensions.Diagnostics.Abstractions/src/Tracing/TracingRule.cs create mode 100644 src/libraries/Microsoft.Extensions.Diagnostics.Abstractions/tests/TracingBuilderExtensionsRulesTests.cs create mode 100644 src/libraries/Microsoft.Extensions.Diagnostics/src/Tracing/Configuration/ActivityListenerConfigurationFactory.cs create mode 100644 src/libraries/Microsoft.Extensions.Diagnostics/src/Tracing/Configuration/DefaultActivityListenerConfigurationFactory.cs create mode 100644 src/libraries/Microsoft.Extensions.Diagnostics/src/Tracing/Configuration/TracingBuilderConfigurationExtensions.cs create mode 100644 src/libraries/Microsoft.Extensions.Diagnostics/src/Tracing/Configuration/TracingConfiguration.cs create mode 100644 src/libraries/Microsoft.Extensions.Diagnostics/src/Tracing/Configuration/TracingConfigureOptions.cs create mode 100644 src/libraries/Microsoft.Extensions.Diagnostics/src/Tracing/DefaultActivitySourceFactory.cs create mode 100644 src/libraries/Microsoft.Extensions.Diagnostics/src/Tracing/TracingServiceExtensions.cs create mode 100644 src/libraries/Microsoft.Extensions.Diagnostics/tests/DefaultActivitySourceFactoryTests.cs create mode 100644 src/libraries/Microsoft.Extensions.Diagnostics/tests/TracingConfigureOptionsTests.cs create mode 100644 src/libraries/System.Diagnostics.DiagnosticSource/src/System/Diagnostics/ActivitySourceFactory.cs diff --git a/src/libraries/Common/src/System/ExceptionPolyfills.cs b/src/libraries/Common/src/System/ExceptionPolyfills.cs index 03837b2ad0097f..9ac5ee379150a9 100644 --- a/src/libraries/Common/src/System/ExceptionPolyfills.cs +++ b/src/libraries/Common/src/System/ExceptionPolyfills.cs @@ -39,6 +39,27 @@ private static void ThrowArgumentNullException(string? paramName) => private static void ThrowArgumentOutOfRangeException(string? paramName) => throw new ArgumentOutOfRangeException(paramName); + extension(ArgumentException) + { + public static void ThrowIfNullOrEmpty([NotNull] string? argument, [CallerArgumentExpression(nameof(argument))] string? paramName = null) + { + if (string.IsNullOrEmpty(argument)) + { + ThrowNullOrEmptyException(argument, paramName); + } + } + } + + [DoesNotReturn] + private static void ThrowNullOrEmptyException(string? argument, string? paramName) + { + if (argument is null) + { + ThrowArgumentNullException(paramName); + } + throw new ArgumentException("The value cannot be an empty string.", paramName); + } + extension(ObjectDisposedException) { public static void ThrowIf([DoesNotReturnIf(true)] bool condition, object instance) diff --git a/src/libraries/Microsoft.Extensions.Diagnostics.Abstractions/ref/Microsoft.Extensions.Diagnostics.Abstractions.cs b/src/libraries/Microsoft.Extensions.Diagnostics.Abstractions/ref/Microsoft.Extensions.Diagnostics.Abstractions.cs index 3a2dc49933deeb..449c11a9f8f565 100644 --- a/src/libraries/Microsoft.Extensions.Diagnostics.Abstractions/ref/Microsoft.Extensions.Diagnostics.Abstractions.cs +++ b/src/libraries/Microsoft.Extensions.Diagnostics.Abstractions/ref/Microsoft.Extensions.Diagnostics.Abstractions.cs @@ -73,3 +73,52 @@ public class MetricsOptions public IList Rules { get; } = null!; } } +namespace Microsoft.Extensions.Diagnostics.Tracing +{ + public interface ITracingBuilder + { + Microsoft.Extensions.DependencyInjection.IServiceCollection Services { get; } + } + public class TracingRule + { + public TracingRule(string? sourceName, string? operationName, string? listenerName, ActivitySourceScopes scopes, bool enable) { } + public string? SourceName { get; } + public string? OperationName { get; } + public string? ListenerName { get; } + public ActivitySourceScopes Scopes { get; } + public bool Enable { get; } + } + [Flags] + public enum ActivitySourceScopes + { + None = 0, + Global = 1, + Local = 2 + } + public sealed class ActivityListenerBuilder + { + internal ActivityListenerBuilder(string name) { } + public string Name { get; } + public System.Diagnostics.SampleActivity? Sample { get; set; } + public System.Diagnostics.SampleActivity? SampleUsingParentId { get; set; } + public Action? ActivityStarted { get; set; } + public Action? ActivityStopped { get; set; } + public System.Diagnostics.ExceptionRecorder? ExceptionRecorder { get; set; } + } + public static partial class TracingBuilderExtensions + { + public static ITracingBuilder AddListener(this ITracingBuilder builder, string name, Action configure) { throw null!; } + public static ITracingBuilder AddListener(this ITracingBuilder builder, string name, Action configure) { throw null!; } + public static ITracingBuilder ClearListeners(this ITracingBuilder builder) { throw null!; } + + public static ITracingBuilder EnableTracing(this ITracingBuilder builder, string? sourceName = null, string? operationName = null, string? listenerName = null, ActivitySourceScopes scopes = ActivitySourceScopes.Global | ActivitySourceScopes.Local) => throw null!; + public static TracingOptions EnableTracing(this TracingOptions options, string? sourceName = null, string? operationName = null, string? listenerName = null, ActivitySourceScopes scopes = ActivitySourceScopes.Global | ActivitySourceScopes.Local) => throw null!; + + public static ITracingBuilder DisableTracing(this ITracingBuilder builder, string? sourceName = null, string? operationName = null, string? listenerName = null, ActivitySourceScopes scopes = ActivitySourceScopes.Global | ActivitySourceScopes.Local) => throw null!; + public static TracingOptions DisableTracing(this TracingOptions options, string? sourceName = null, string? operationName = null, string? listenerName = null, ActivitySourceScopes scopes = ActivitySourceScopes.Global | ActivitySourceScopes.Local) => throw null!; + } + public class TracingOptions + { + public List Rules { get; } = null!; + } +} diff --git a/src/libraries/Microsoft.Extensions.Diagnostics.Abstractions/ref/Microsoft.Extensions.Diagnostics.Abstractions.csproj b/src/libraries/Microsoft.Extensions.Diagnostics.Abstractions/ref/Microsoft.Extensions.Diagnostics.Abstractions.csproj index 2028fc09150426..abe93eaa84c096 100644 --- a/src/libraries/Microsoft.Extensions.Diagnostics.Abstractions/ref/Microsoft.Extensions.Diagnostics.Abstractions.csproj +++ b/src/libraries/Microsoft.Extensions.Diagnostics.Abstractions/ref/Microsoft.Extensions.Diagnostics.Abstractions.csproj @@ -23,6 +23,8 @@ + + diff --git a/src/libraries/Microsoft.Extensions.Diagnostics.Abstractions/src/Tracing/ActivityListenerBuilder.cs b/src/libraries/Microsoft.Extensions.Diagnostics.Abstractions/src/Tracing/ActivityListenerBuilder.cs new file mode 100644 index 00000000000000..855c2619dd70a5 --- /dev/null +++ b/src/libraries/Microsoft.Extensions.Diagnostics.Abstractions/src/Tracing/ActivityListenerBuilder.cs @@ -0,0 +1,61 @@ +// 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; + +namespace Microsoft.Extensions.Diagnostics.Tracing +{ + /// + /// Describes the user-configurable surface of an registered with an + /// . The tracing infrastructure consumes the values set on the builder and + /// constructs the underlying ; subscription to + /// instances is controlled entirely by the configuration-driven set. + /// + /// + /// The builder intentionally does not expose ShouldListenTo, + /// or : subscription filtering is owned by the rule set, and the lifetime of the + /// underlying listener is owned by the tracing builder. The delegate properties are snapshotted by the + /// infrastructure at registration time; mutating the builder afterwards has no effect on the registered listener. + /// Instances are constructed by the tracing infrastructure when an AddListener overload runs; user code + /// configures an instance through the configure callback passed to AddListener. + /// + public sealed class ActivityListenerBuilder + { + internal ActivityListenerBuilder(string name) + { + Debug.Assert(name is not null); + Name = name; + } + + /// + /// Gets the name used by configuration-based filtering to target rules at this listener. + /// + public string Name { get; } + + /// + /// Gets or sets the callback invoked when an is sampled from an . + /// + public SampleActivity? Sample { get; set; } + + /// + /// Gets or sets the callback invoked when an is sampled from a parent identifier string. + /// + public SampleActivity? SampleUsingParentId { get; set; } + + /// + /// Gets or sets the callback invoked when a sampled starts. + /// + public Action? ActivityStarted { get; set; } + + /// + /// Gets or sets the callback invoked when a sampled stops. + /// + public Action? ActivityStopped { get; set; } + + /// + /// Gets or sets the callback invoked when an exception is recorded on a sampled . + /// + public ExceptionRecorder? ExceptionRecorder { get; set; } + } +} diff --git a/src/libraries/Microsoft.Extensions.Diagnostics.Abstractions/src/Tracing/ActivitySourceScopes.cs b/src/libraries/Microsoft.Extensions.Diagnostics.Abstractions/src/Tracing/ActivitySourceScopes.cs new file mode 100644 index 00000000000000..e2bef8be7f1ecc --- /dev/null +++ b/src/libraries/Microsoft.Extensions.Diagnostics.Abstractions/src/Tracing/ActivitySourceScopes.cs @@ -0,0 +1,31 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; + +namespace Microsoft.Extensions.Diagnostics.Tracing +{ + /// + /// Represents scopes used by to distinguish between activity sources created directly + /// via constructors () and those created via + /// dependency injection with (). + /// + [Flags] + public enum ActivitySourceScopes + { + /// + /// No scope is specified. This field should not be used. + /// + None = 0, + + /// + /// Indicates instances created via constructors. + /// + Global = 1, + + /// + /// Indicates instances created via . + /// + Local = 2 + } +} diff --git a/src/libraries/Microsoft.Extensions.Diagnostics.Abstractions/src/Tracing/ITracingBuilder.cs b/src/libraries/Microsoft.Extensions.Diagnostics.Abstractions/src/Tracing/ITracingBuilder.cs new file mode 100644 index 00000000000000..7085eb0828f5af --- /dev/null +++ b/src/libraries/Microsoft.Extensions.Diagnostics.Abstractions/src/Tracing/ITracingBuilder.cs @@ -0,0 +1,20 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.Extensions.DependencyInjection; + +namespace Microsoft.Extensions.Diagnostics.Tracing +{ + /// + /// Configures the tracing system by registering instances and using + /// rules to determine which and + /// instances are enabled. + /// + public interface ITracingBuilder + { + /// + /// Gets the application service collection that's used by extension methods to register services. + /// + IServiceCollection Services { get; } + } +} diff --git a/src/libraries/Microsoft.Extensions.Diagnostics.Abstractions/src/Tracing/TracingBuilderExtensions.Listeners.cs b/src/libraries/Microsoft.Extensions.Diagnostics.Abstractions/src/Tracing/TracingBuilderExtensions.Listeners.cs new file mode 100644 index 00000000000000..085105f4ee55a6 --- /dev/null +++ b/src/libraries/Microsoft.Extensions.Diagnostics.Abstractions/src/Tracing/TracingBuilderExtensions.Listeners.cs @@ -0,0 +1,90 @@ +// 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; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; + +namespace Microsoft.Extensions.Diagnostics.Tracing +{ + /// + /// Extension methods for to add or clear registrations. + /// + public static partial class TracingBuilderExtensions + { + /// + /// Registers a new identified by and described by + /// the supplied callback. + /// + /// The . + /// A name used by configuration-based filtering to identify this listener for rule matching. + /// A callback that configures the delegate properties of the supplied . + /// Returns the original for chaining. + /// + /// The tracing infrastructure invokes once when the underlying + /// is first resolved, snapshots the delegate properties from the supplied + /// , and constructs the registered itself. + /// Subscription to instances is driven entirely by the configuration-based + /// set; the builder re-evaluates listener subscriptions automatically when the bound + /// change. + /// + public static ITracingBuilder AddListener(this ITracingBuilder builder, string name, Action configure) + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentException.ThrowIfNullOrEmpty(name); + ArgumentNullException.ThrowIfNull(configure); + + builder.Services.AddSingleton(_ => + { + ActivityListenerBuilder listenerBuilder = new ActivityListenerBuilder(name); + configure(listenerBuilder); + return listenerBuilder; + }); + return builder; + } + + /// + /// Registers a new identified by and described by + /// the supplied callback, which also receives the resolved . + /// + /// The . + /// A name used by configuration-based filtering to identify this listener for rule matching. + /// A callback that configures the supplied , with access to the resolved . + /// Returns the original for chaining. + /// + /// The tracing infrastructure invokes once when the underlying + /// is first resolved, snapshots the delegate properties from the supplied + /// , and constructs the registered itself. + /// Subscription to instances is driven entirely by the configuration-based + /// set; the builder re-evaluates listener subscriptions automatically when the bound + /// change. + /// + public static ITracingBuilder AddListener(this ITracingBuilder builder, string name, Action configure) + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentException.ThrowIfNullOrEmpty(name); + ArgumentNullException.ThrowIfNull(configure); + + builder.Services.AddSingleton(serviceProvider => + { + ActivityListenerBuilder listenerBuilder = new ActivityListenerBuilder(name); + configure(serviceProvider, listenerBuilder); + return listenerBuilder; + }); + return builder; + } + + /// + /// Removes all registrations from the dependency injection container. + /// + /// The . + /// Returns the original for chaining. + public static ITracingBuilder ClearListeners(this ITracingBuilder builder) + { + ArgumentNullException.ThrowIfNull(builder); + builder.Services.RemoveAll(); + return builder; + } + } +} diff --git a/src/libraries/Microsoft.Extensions.Diagnostics.Abstractions/src/Tracing/TracingBuilderExtensions.Rules.cs b/src/libraries/Microsoft.Extensions.Diagnostics.Abstractions/src/Tracing/TracingBuilderExtensions.Rules.cs new file mode 100644 index 00000000000000..cef66fa0f9315b --- /dev/null +++ b/src/libraries/Microsoft.Extensions.Diagnostics.Abstractions/src/Tracing/TracingBuilderExtensions.Rules.cs @@ -0,0 +1,77 @@ +// 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; +using Microsoft.Extensions.DependencyInjection; + +namespace Microsoft.Extensions.Diagnostics.Tracing +{ + /// + /// Extension methods for to configure tracing rules. + /// + public static partial class TracingBuilderExtensions + { + /// + /// Enables all activities for the given source, operation, listener, and scopes. + /// + /// The . + /// The , prefix, or pattern with a single * wildcard. A or empty value matches all activity sources. + /// The , exact match. A null or empty value matches all activities within the matching sources. + /// The . A null or empty value matches all listeners. + /// A bitwise combination of the enumeration values that specifies the scopes to consider. Defaults to all scopes. + /// The original for chaining. + public static ITracingBuilder EnableTracing(this ITracingBuilder builder, string? sourceName = null, string? operationName = null, string? listenerName = null, ActivitySourceScopes scopes = ActivitySourceScopes.Global | ActivitySourceScopes.Local) + => builder.ConfigureRule(options => options.EnableTracing(sourceName, operationName, listenerName, scopes)); + + /// + /// Enables all activities for the given source, operation, listener, and scopes. + /// + /// The . + /// The , prefix, or pattern with a single * wildcard. A or empty value matches all activity sources. + /// The , exact match. A null or empty value matches all activities within the matching sources. + /// The . A null or empty value matches all listeners. + /// A bitwise combination of the enumeration values that specifies the scopes to consider. Defaults to all scopes. + /// The original for chaining. + public static TracingOptions EnableTracing(this TracingOptions options, string? sourceName = null, string? operationName = null, string? listenerName = null, ActivitySourceScopes scopes = ActivitySourceScopes.Global | ActivitySourceScopes.Local) + => options.AddRule(sourceName, operationName, listenerName, scopes, enable: true); + + /// + /// Disables all activities for the given source, operation, listener, and scopes. + /// + /// The . + /// The , prefix, or pattern with a single * wildcard. A or empty value matches all activity sources. + /// The , exact match. A null or empty value matches all activities within the matching sources. + /// The . A null or empty value matches all listeners. + /// A bitwise combination of the enumeration values that specifies the scopes to consider. Defaults to all scopes. + /// The original for chaining. + public static ITracingBuilder DisableTracing(this ITracingBuilder builder, string? sourceName = null, string? operationName = null, string? listenerName = null, ActivitySourceScopes scopes = ActivitySourceScopes.Global | ActivitySourceScopes.Local) + => builder.ConfigureRule(options => options.DisableTracing(sourceName, operationName, listenerName, scopes)); + + /// + /// Disables all activities for the given source, operation, listener, and scopes. + /// + /// The . + /// The , prefix, or pattern with a single * wildcard. A or empty value matches all activity sources. + /// The , exact match. A null or empty value matches all activities within the matching sources. + /// The . A null or empty value matches all listeners. + /// A bitwise combination of the enumeration values that specifies the scopes to consider. Defaults to all scopes. + /// The original for chaining. + public static TracingOptions DisableTracing(this TracingOptions options, string? sourceName = null, string? operationName = null, string? listenerName = null, ActivitySourceScopes scopes = ActivitySourceScopes.Global | ActivitySourceScopes.Local) + => options.AddRule(sourceName, operationName, listenerName, scopes, enable: false); + + private static ITracingBuilder ConfigureRule(this ITracingBuilder builder, Action configureOptions) + { + ArgumentNullException.ThrowIfNull(builder); + builder.Services.Configure(configureOptions); + return builder; + } + + private static TracingOptions AddRule(this TracingOptions options, string? sourceName, string? operationName, string? listenerName, ActivitySourceScopes scopes, bool enable) + { + ArgumentNullException.ThrowIfNull(options); + options.Rules.Add(new TracingRule(sourceName, operationName, listenerName, scopes, enable)); + return options; + } + } +} diff --git a/src/libraries/Microsoft.Extensions.Diagnostics.Abstractions/src/Tracing/TracingOptions.cs b/src/libraries/Microsoft.Extensions.Diagnostics.Abstractions/src/Tracing/TracingOptions.cs new file mode 100644 index 00000000000000..9abea25a67b4dd --- /dev/null +++ b/src/libraries/Microsoft.Extensions.Diagnostics.Abstractions/src/Tracing/TracingOptions.cs @@ -0,0 +1,18 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Collections.Generic; + +namespace Microsoft.Extensions.Diagnostics.Tracing +{ + /// + /// Represents options for configuring the tracing system. + /// + public class TracingOptions + { + /// + /// Gets a list of activity rules that identifies which activity sources, activities, and listeners are enabled. + /// + public List Rules { get; } = new List(); + } +} diff --git a/src/libraries/Microsoft.Extensions.Diagnostics.Abstractions/src/Tracing/TracingRule.cs b/src/libraries/Microsoft.Extensions.Diagnostics.Abstractions/src/Tracing/TracingRule.cs new file mode 100644 index 00000000000000..d83cb31f7812fa --- /dev/null +++ b/src/libraries/Microsoft.Extensions.Diagnostics.Abstractions/src/Tracing/TracingRule.cs @@ -0,0 +1,95 @@ +// 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; + +namespace Microsoft.Extensions.Diagnostics.Tracing +{ + /// + /// Contains a set of parameters used to determine which activities are enabled for which listeners. + /// An unspecified matches all activity sources, an unspecified + /// matches all activities within the matching sources, and an unspecified + /// matches all listeners. + /// + /// + /// The most specific rule that matches a given activity will be used. The priority of parameters is as follows: + /// - ListenerName, an exact match. See . + /// - SourceName, either an exact match, the longest prefix match, or a wildcard pattern using a single *. See . + /// - OperationName, an exact match. See . + /// - Scopes, where a more constrained scope is preferred over Global | Local. + /// When multiple rules are equally specific, the rule added last takes precedence. + /// + public class TracingRule + { + /// + /// Initializes a new instance of the class. + /// + /// The , prefix, or pattern with a single * wildcard. A or empty value matches all activity sources. + /// The , exact match. A or empty value matches all activities within the matching sources. + /// The . A or empty value matches all listeners. + /// A bitwise combination of the enumeration values that specifies the scopes to consider. + /// to enable matched activities for this listener; otherwise, . + /// contains more than one * wildcard. + /// is . + public TracingRule(string? sourceName, string? operationName, string? listenerName, ActivitySourceScopes scopes, bool enable) + { + // Validate the wildcard pattern eagerly. The equivalent rule type in Microsoft.Extensions.Diagnostics + // for metrics defers this validation to the per-event matching path, so a malformed rule introduced + // via IOptionsMonitor reload throws out of arbitrary instrument operations later. We diverge from + // that here so configuration mistakes surface at bind time (or programmatic-construction call site) + // and never reach the StartActivity hot path. The metrics behaviour is shipped public surface and + // can't change without a breaking-change process; tracing is new, so we get the cleaner shape now. + if (!string.IsNullOrEmpty(sourceName)) + { + int firstWildcard = sourceName.IndexOf('*'); + if (firstWildcard >= 0 && sourceName.IndexOf('*', firstWildcard + 1) >= 0) + { + throw new ArgumentException("Only one '*' wildcard is allowed in an activity source name pattern.", nameof(sourceName)); + } + } + + SourceName = sourceName; + OperationName = operationName; + ListenerName = listenerName; + Scopes = scopes == ActivitySourceScopes.None + ? throw new ArgumentOutOfRangeException(nameof(scopes), scopes, "The ActivitySourceScopes must be Global, Local, or both.") + : scopes; + Enable = enable; + } + + /// + /// Gets the , either an exact match, the longest prefix match, or a wildcard pattern using a single *. + /// + /// + /// The activity source name. If or empty, all activity sources are matched. + /// + public string? SourceName { get; } + + /// + /// Gets the , an exact match. + /// + /// + /// The operation name. If or empty, all activities within the matching sources are matched. + /// + public string? OperationName { get; } + + /// + /// Gets the , an exact match. + /// + /// + /// The listener name. If or empty, all listeners are matched. + /// + public string? ListenerName { get; } + + /// + /// Gets the . + /// + public ActivitySourceScopes Scopes { get; } + + /// + /// Gets a value that indicates whether matched activities are enabled for this listener. + /// + public bool Enable { get; } + } +} diff --git a/src/libraries/Microsoft.Extensions.Diagnostics.Abstractions/tests/TracingBuilderExtensionsRulesTests.cs b/src/libraries/Microsoft.Extensions.Diagnostics.Abstractions/tests/TracingBuilderExtensionsRulesTests.cs new file mode 100644 index 00000000000000..88f722707601c4 --- /dev/null +++ b/src/libraries/Microsoft.Extensions.Diagnostics.Abstractions/tests/TracingBuilderExtensionsRulesTests.cs @@ -0,0 +1,133 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Diagnostics; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Diagnostics.Tracing; +using Microsoft.Extensions.Options; +using Xunit; + +namespace Microsoft.Extensions.Diagnostics.Tests +{ + public class TracingBuilderExtensionsRulesTests + { + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData("*")] + [InlineData("foo")] + public void BuilderEnableAddsRule(string? sourceName) + { + var services = new ServiceCollection(); + services.AddOptions(); + var builder = new FakeBuilder(services); + + builder.EnableTracing(sourceName: sourceName); + + var container = services.BuildServiceProvider(); + var options = container.GetRequiredService>(); + var instance = options.Value; + var rule = Assert.Single(instance.Rules); + Assert.Equal(sourceName, rule.SourceName); + Assert.Null(rule.ListenerName); + Assert.Equal(ActivitySourceScopes.Global | ActivitySourceScopes.Local, rule.Scopes); + Assert.True(rule.Enable); + } + + [Fact] + public void BuilderDisableWithAllParamsAddsRule() + { + var services = new ServiceCollection(); + services.AddOptions(); + var builder = new FakeBuilder(services); + + builder.DisableTracing(sourceName: "source", listenerName: "listener", scopes: ActivitySourceScopes.Local); + + var container = services.BuildServiceProvider(); + var options = container.GetRequiredService>(); + var instance = options.Value; + var rule = Assert.Single(instance.Rules); + Assert.Equal("source", rule.SourceName); + Assert.Equal("listener", rule.ListenerName); + Assert.Equal(ActivitySourceScopes.Local, rule.Scopes); + Assert.False(rule.Enable); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData("*")] + [InlineData("foo")] + public void OptionsEnableAddsRule(string? sourceName) + { + var services = new ServiceCollection(); + services.AddOptions(); + services.Configure(options => + options.EnableTracing(sourceName: sourceName)); + + var container = services.BuildServiceProvider(); + var options = container.GetRequiredService>(); + var instance = options.Value; + var rule = Assert.Single(instance.Rules); + Assert.Equal(sourceName, rule.SourceName); + Assert.Null(rule.ListenerName); + Assert.Equal(ActivitySourceScopes.Global | ActivitySourceScopes.Local, rule.Scopes); + Assert.True(rule.Enable); + } + + [Fact] + public void OptionsDisableAllParamsAddsRule() + { + var services = new ServiceCollection(); + services.AddOptions(); + services.Configure(options => + options.DisableTracing(sourceName: "source", listenerName: "listener", scopes: ActivitySourceScopes.Global)); + + var container = services.BuildServiceProvider(); + var options = container.GetRequiredService>(); + var instance = options.Value; + var rule = Assert.Single(instance.Rules); + Assert.Equal("source", rule.SourceName); + Assert.Equal("listener", rule.ListenerName); + Assert.Equal(ActivitySourceScopes.Global, rule.Scopes); + Assert.False(rule.Enable); + } + + [Fact] + public void EnableTracingUsesTrue() + { + var services = new ServiceCollection(); + services.AddOptions(); + var builder = new FakeBuilder(services); + + builder.EnableTracing("source", operationName: null, "listener", ActivitySourceScopes.Local); + + var container = services.BuildServiceProvider(); + var options = container.GetRequiredService>(); + var instance = options.Value; + var rule = Assert.Single(instance.Rules); + Assert.True(rule.Enable); + } + + [Fact] + public void DisableTracingUsesFalse() + { + var services = new ServiceCollection(); + services.AddOptions(); + var builder = new FakeBuilder(services); + + builder.DisableTracing("source", operationName: null, "listener", ActivitySourceScopes.Local); + + var container = services.BuildServiceProvider(); + var options = container.GetRequiredService>(); + var instance = options.Value; + var rule = Assert.Single(instance.Rules); + Assert.False(rule.Enable); + } + + private class FakeBuilder(IServiceCollection services) : ITracingBuilder + { + public IServiceCollection Services { get; } = services; + } + } +} diff --git a/src/libraries/Microsoft.Extensions.Diagnostics/README.md b/src/libraries/Microsoft.Extensions.Diagnostics/README.md index b8e0a36534193c..dfc80545261aa5 100644 --- a/src/libraries/Microsoft.Extensions.Diagnostics/README.md +++ b/src/libraries/Microsoft.Extensions.Diagnostics/README.md @@ -6,6 +6,8 @@ Commonly Used APIS: - MetricsServiceExtensions.AddMetrics(this IServiceCollection services) - MeterFactoryExtensions.Create(this IMeterFactory, string name, string? version = null, IEnumerable> tags = null, object? scope = null) - MetricsBuilderConfigurationExtensions.AddConfiguration(this IMetricsBuilder builder, IConfiguration configuration) +- TracingServiceExtensions.AddTracing(this IServiceCollection services) +- TracingBuilderConfigurationExtensions.AddConfiguration(this ITracingBuilder builder, IConfiguration configuration) ## Contribution Bar - [x] [We consider new features, new APIs, bug fixes, and performance changes](https://github.com/dotnet/runtime/tree/main/src/libraries#contribution-bar) diff --git a/src/libraries/Microsoft.Extensions.Diagnostics/ref/Microsoft.Extensions.Diagnostics.cs b/src/libraries/Microsoft.Extensions.Diagnostics/ref/Microsoft.Extensions.Diagnostics.cs index 47fe8a3cf524fa..43d23ae2fb8493 100644 --- a/src/libraries/Microsoft.Extensions.Diagnostics/ref/Microsoft.Extensions.Diagnostics.cs +++ b/src/libraries/Microsoft.Extensions.Diagnostics/ref/Microsoft.Extensions.Diagnostics.cs @@ -8,6 +8,11 @@ public static class MetricsServiceExtensions public static Microsoft.Extensions.DependencyInjection.IServiceCollection AddMetrics(this Microsoft.Extensions.DependencyInjection.IServiceCollection services) { throw null; } public static Microsoft.Extensions.DependencyInjection.IServiceCollection AddMetrics(this Microsoft.Extensions.DependencyInjection.IServiceCollection services, System.Action configure) { throw null; } } + public static class TracingServiceExtensions + { + public static Microsoft.Extensions.DependencyInjection.IServiceCollection AddTracing(this Microsoft.Extensions.DependencyInjection.IServiceCollection services) { throw null; } + public static Microsoft.Extensions.DependencyInjection.IServiceCollection AddTracing(this Microsoft.Extensions.DependencyInjection.IServiceCollection services, System.Action configure) { throw null; } + } } namespace Microsoft.Extensions.Diagnostics.Metrics { @@ -24,6 +29,18 @@ public static class MetricsBuilderConfigurationExtensions public static IMetricsBuilder AddConfiguration(this IMetricsBuilder builder, Microsoft.Extensions.Configuration.IConfiguration configuration) => throw null!; } } +namespace Microsoft.Extensions.Diagnostics.Tracing +{ + public abstract class ActivityListenerConfigurationFactory + { + protected ActivityListenerConfigurationFactory() { } + public abstract Microsoft.Extensions.Configuration.IConfiguration GetConfiguration(string listenerName); + } + public static class TracingBuilderConfigurationExtensions + { + public static ITracingBuilder AddConfiguration(this ITracingBuilder builder, Microsoft.Extensions.Configuration.IConfiguration configuration) => throw null!; + } +} namespace Microsoft.Extensions.Diagnostics.Metrics.Configuration { public interface IMetricListenerConfigurationFactory diff --git a/src/libraries/Microsoft.Extensions.Diagnostics/src/Tracing/Configuration/ActivityListenerConfigurationFactory.cs b/src/libraries/Microsoft.Extensions.Diagnostics/src/Tracing/Configuration/ActivityListenerConfigurationFactory.cs new file mode 100644 index 00000000000000..22bc10d73f95f3 --- /dev/null +++ b/src/libraries/Microsoft.Extensions.Diagnostics/src/Tracing/Configuration/ActivityListenerConfigurationFactory.cs @@ -0,0 +1,26 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Diagnostics; +using Microsoft.Extensions.Configuration; + +namespace Microsoft.Extensions.Diagnostics.Tracing +{ + /// + /// Resolves an view for a named . + /// + /// + /// Implementations merge every section registered through + /// that targets the supplied listener name, + /// returning a single merged instance per call. + /// + public abstract class ActivityListenerConfigurationFactory + { + /// + /// Gets the merged for the listener identified by . + /// + /// The name of the listener whose configuration is requested. + /// An that aggregates every section registered for . + public abstract IConfiguration GetConfiguration(string listenerName); + } +} diff --git a/src/libraries/Microsoft.Extensions.Diagnostics/src/Tracing/Configuration/DefaultActivityListenerConfigurationFactory.cs b/src/libraries/Microsoft.Extensions.Diagnostics/src/Tracing/Configuration/DefaultActivityListenerConfigurationFactory.cs new file mode 100644 index 00000000000000..bf2d9f7c27fb27 --- /dev/null +++ b/src/libraries/Microsoft.Extensions.Diagnostics/src/Tracing/Configuration/DefaultActivityListenerConfigurationFactory.cs @@ -0,0 +1,33 @@ +// 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 Microsoft.Extensions.Configuration; + +namespace Microsoft.Extensions.Diagnostics.Tracing +{ + internal sealed class DefaultActivityListenerConfigurationFactory : ActivityListenerConfigurationFactory + { + private readonly IEnumerable _configurations; + + public DefaultActivityListenerConfigurationFactory(IEnumerable configurations) + { + _configurations = configurations ?? throw new ArgumentNullException(nameof(configurations)); + } + + public override IConfiguration GetConfiguration(string listenerName) + { + ArgumentNullException.ThrowIfNull(listenerName); + + var configurationBuilder = new ConfigurationBuilder(); + foreach (TracingConfiguration configuration in _configurations) + { + IConfigurationSection section = configuration.Configuration.GetSection(listenerName); + configurationBuilder.AddConfiguration(section); + } + + return configurationBuilder.Build(); + } + } +} diff --git a/src/libraries/Microsoft.Extensions.Diagnostics/src/Tracing/Configuration/TracingBuilderConfigurationExtensions.cs b/src/libraries/Microsoft.Extensions.Diagnostics/src/Tracing/Configuration/TracingBuilderConfigurationExtensions.cs new file mode 100644 index 00000000000000..0fc3b01d170574 --- /dev/null +++ b/src/libraries/Microsoft.Extensions.Diagnostics/src/Tracing/Configuration/TracingBuilderConfigurationExtensions.cs @@ -0,0 +1,43 @@ +// 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; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; + +namespace Microsoft.Extensions.Diagnostics.Tracing +{ + /// + /// Extensions for for enabling tracing based on . + /// + public static class TracingBuilderConfigurationExtensions + { + /// + /// Reads tracing configuration from the provided section and configures + /// which and instances are enabled. + /// + /// + /// Tracing has two key levels: and . + /// - Section names: EnabledTracing (both global and local), EnabledGlobalTracing, and EnabledLocalTracing, plus the listener-specific forms {ListenerName}:.... + /// - Within each section, supported entries are Default, {SourceName}, {SourceName}:Default, and {SourceName}:{OperationName}. Default at either level is a synonym for the level above (a source-level rule when nested under a source, a global rule at the top). + /// - Listener-specific rules are evaluated together with root-level rules. When both match, the most specific rule is chosen; listener-specific rules are more specific than root-level defaults. + /// - Values are Boolean only: true enables and false disables. + /// Example keys: EnabledTracing:Default=true, EnabledGlobalTracing:MyCompany.Service=false, EnabledTracing:MyCompany.Service:Checkout=true, and MyListener:EnabledLocalTracing:MyCompany.Service=true. + /// + /// The . + /// The section to load. + /// The original for chaining. + public static ITracingBuilder AddConfiguration(this ITracingBuilder builder, IConfiguration configuration) + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentNullException.ThrowIfNull(configuration); + + builder.Services.AddSingleton>(new TracingConfigureOptions(configuration)); + builder.Services.AddSingleton>(new ConfigurationChangeTokenSource(configuration)); + builder.Services.AddSingleton(new TracingConfiguration(configuration)); + return builder; + } + } +} diff --git a/src/libraries/Microsoft.Extensions.Diagnostics/src/Tracing/Configuration/TracingConfiguration.cs b/src/libraries/Microsoft.Extensions.Diagnostics/src/Tracing/Configuration/TracingConfiguration.cs new file mode 100644 index 00000000000000..279388b225cb26 --- /dev/null +++ b/src/libraries/Microsoft.Extensions.Diagnostics/src/Tracing/Configuration/TracingConfiguration.cs @@ -0,0 +1,18 @@ +// 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 Microsoft.Extensions.Configuration; + +namespace Microsoft.Extensions.Diagnostics.Tracing +{ + internal sealed class TracingConfiguration + { + public TracingConfiguration(IConfiguration configuration) + { + Configuration = configuration ?? throw new ArgumentNullException(nameof(configuration)); + } + + public IConfiguration Configuration { get; } + } +} diff --git a/src/libraries/Microsoft.Extensions.Diagnostics/src/Tracing/Configuration/TracingConfigureOptions.cs b/src/libraries/Microsoft.Extensions.Diagnostics/src/Tracing/Configuration/TracingConfigureOptions.cs new file mode 100644 index 00000000000000..f2f2fb8ede67fb --- /dev/null +++ b/src/libraries/Microsoft.Extensions.Diagnostics/src/Tracing/Configuration/TracingConfigureOptions.cs @@ -0,0 +1,116 @@ +// 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; +using System.Linq; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Options; + +namespace Microsoft.Extensions.Diagnostics.Tracing +{ + internal sealed class TracingConfigureOptions : IConfigureOptions + { + private const string EnabledTracingKey = "EnabledTracing"; + private const string EnabledGlobalTracingKey = "EnabledGlobalTracing"; + private const string EnabledLocalTracingKey = "EnabledLocalTracing"; + private const string DefaultKey = "Default"; + private readonly IConfiguration _configuration; + + public TracingConfigureOptions(IConfiguration configuration) + { + _configuration = configuration ?? throw new ArgumentNullException(nameof(configuration)); + } + + public void Configure(TracingOptions options) => LoadConfig(options); + + private void LoadConfig(TracingOptions options) + { + foreach (var configurationSection in _configuration.GetChildren()) + { + if (configurationSection.Key.Equals(EnabledTracingKey, StringComparison.OrdinalIgnoreCase)) + { + LoadActivitySourceRules(options, configurationSection, ActivitySourceScopes.Global | ActivitySourceScopes.Local, listenerName: null); + } + else if (configurationSection.Key.Equals(EnabledGlobalTracingKey, StringComparison.OrdinalIgnoreCase)) + { + LoadActivitySourceRules(options, configurationSection, ActivitySourceScopes.Global, listenerName: null); + } + else if (configurationSection.Key.Equals(EnabledLocalTracingKey, StringComparison.OrdinalIgnoreCase)) + { + LoadActivitySourceRules(options, configurationSection, ActivitySourceScopes.Local, listenerName: null); + } + else + { + var listenerName = configurationSection.Key; + var enabledTracingSection = configurationSection.GetSection(EnabledTracingKey); + if (enabledTracingSection.Exists()) + { + LoadActivitySourceRules(options, enabledTracingSection, ActivitySourceScopes.Global | ActivitySourceScopes.Local, listenerName); + } + + var enabledGlobalTracingSection = configurationSection.GetSection(EnabledGlobalTracingKey); + if (enabledGlobalTracingSection.Exists()) + { + LoadActivitySourceRules(options, enabledGlobalTracingSection, ActivitySourceScopes.Global, listenerName); + } + + var enabledLocalTracingSection = configurationSection.GetSection(EnabledLocalTracingKey); + if (enabledLocalTracingSection.Exists()) + { + LoadActivitySourceRules(options, enabledLocalTracingSection, ActivitySourceScopes.Local, listenerName); + } + } + } + } + + internal static void LoadActivitySourceRules(TracingOptions options, IConfigurationSection configurationSection, ActivitySourceScopes scopes, string? listenerName) + { + foreach (var activitySourceSection in configurationSection.GetChildren()) + { + if (activitySourceSection.GetChildren().Any()) + { + LoadActivityRules(options, activitySourceSection, scopes, listenerName); + } + else if (TryGetEnabledValue(activitySourceSection, out var enabled)) + { + var sourceName = activitySourceSection.Key; + if (string.Equals(DefaultKey, sourceName, StringComparison.OrdinalIgnoreCase)) + { + sourceName = null; + } + + options.Rules.Add(new TracingRule(sourceName, operationName: null, listenerName, scopes, enabled)); + } + } + } + + internal static void LoadActivityRules(TracingOptions options, IConfigurationSection activitySourceSection, ActivitySourceScopes scopes, string? listenerName) + { + foreach (var activityPair in activitySourceSection.AsEnumerable(makePathsRelative: true)) + { + if (bool.TryParse(activityPair.Value, out var enabled)) + { + var operationName = activityPair.Key; + if (string.Equals(DefaultKey, operationName, StringComparison.OrdinalIgnoreCase)) + { + operationName = null; + } + + options.Rules.Add(new TracingRule(activitySourceSection.Key, operationName, listenerName, scopes, enabled)); + } + } + } + + private static bool TryGetEnabledValue(IConfigurationSection activitySourceSection, out bool enabled) + { + if (bool.TryParse(activitySourceSection.Value, out enabled)) + { + return true; + } + + enabled = default; + return false; + } + } +} diff --git a/src/libraries/Microsoft.Extensions.Diagnostics/src/Tracing/DefaultActivitySourceFactory.cs b/src/libraries/Microsoft.Extensions.Diagnostics/src/Tracing/DefaultActivitySourceFactory.cs new file mode 100644 index 00000000000000..d8177279f706e3 --- /dev/null +++ b/src/libraries/Microsoft.Extensions.Diagnostics/src/Tracing/DefaultActivitySourceFactory.cs @@ -0,0 +1,609 @@ +// 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; +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using System.Threading; +using Microsoft.Extensions.Options; + +namespace Microsoft.Extensions.Diagnostics.Tracing +{ + internal sealed class DefaultActivitySourceFactory : ActivitySourceFactory + { + private readonly Dictionary _cachedSources = []; + private readonly ActivityListenerRegistration[] _listenerRegistrations; + private readonly IDisposable? _changeTokenRegistration; + private bool _disposed; + + public DefaultActivitySourceFactory(IEnumerable listenerBuilders, IOptionsMonitor options) + { + ArgumentNullException.ThrowIfNull(listenerBuilders); + ArgumentNullException.ThrowIfNull(options); + + _listenerRegistrations = listenerBuilders + .Select(listenerBuilder => new ActivityListenerRegistration(listenerBuilder, this)) + .ToArray(); + try + { + _changeTokenRegistration = options.OnChange((opts, name) => + { + if (string.IsNullOrEmpty(name)) + { + UpdateRules(opts); + } + }); + + UpdateRules(options.CurrentValue, false); + } + catch + { + Dispose(); + throw; + } + } + + protected override ActivitySource CreateCore(ActivitySourceOptions options) + { + Debug.Assert(options is not null); + Debug.Assert(options.Name is not null); + Debug.Assert(ReferenceEquals(options.Scope, this)); + + lock (_cachedSources) + { + if (_disposed) + { + throw new ObjectDisposedException(nameof(DefaultActivitySourceFactory)); + } + + if (TryGetCachedMatch(options, out ActivitySource? cached)) + { + return cached; + } + } + + // Construct outside the cache lock since the base ActivitySource constructor + // walks ActivitySource.s_allListeners and synchronously invokes each listener's + // ShouldListenTo predicate. + FactoryActivitySource newSource = new FactoryActivitySource(options); + + lock (_cachedSources) + { + if (_disposed) + { + newSource.Release(); + throw new ObjectDisposedException(nameof(DefaultActivitySourceFactory)); + } + + if (TryGetCachedMatch(options, out ActivitySource? winner)) + { + // Lost the race to another concurrent Create call. + newSource.Release(); + return winner; + } + + if (_cachedSources.TryGetValue(options.Name, out FactoryActivitySource[]? sources)) + { + FactoryActivitySource[] grown = new FactoryActivitySource[sources.Length + 1]; + sources.CopyTo(grown, 0); + grown[sources.Length] = newSource; + _cachedSources[options.Name] = grown; + } + else + { + _cachedSources.Add(options.Name, [newSource]); + } + + return newSource; + } + } + + private bool TryGetCachedMatch(ActivitySourceOptions options, [NotNullWhen(true)] out ActivitySource? match) + { + Debug.Assert(Monitor.IsEntered(_cachedSources)); + + if (_cachedSources.TryGetValue(options.Name, out FactoryActivitySource[]? sources)) + { + foreach (FactoryActivitySource source in sources) + { + if (source.Version == options.Version + && source.TelemetrySchemaUrl == options.TelemetrySchemaUrl + && DiagnosticsHelper.CompareTags(source.Tags as IList>, options.Tags)) + { + match = source; + return true; + } + } + } + + match = null; + return false; + } + + private void UpdateRules(TracingOptions options, bool overwrite = true) + { + if (Volatile.Read(ref _disposed)) + { + return; + } + + List rules = options.Rules; + foreach (ActivityListenerRegistration registration in _listenerRegistrations) + { + registration.UpdateRules(rules, overwrite); + } + } + + protected override void Dispose(bool disposing) + { + if (!disposing) + { + return; + } + + lock (_cachedSources) + { + if (_disposed) + { + return; + } + + Volatile.Write(ref _disposed, true); + _changeTokenRegistration?.Dispose(); + } + + foreach (ActivityListenerRegistration registration in _listenerRegistrations) + { + registration.Dispose(); + } + + foreach (KeyValuePair entry in _cachedSources) + { + foreach (FactoryActivitySource source in entry.Value) + { + source.Release(); + } + } + + _cachedSources.Clear(); + } + + internal sealed class FactoryActivitySource : ActivitySource + { + public FactoryActivitySource(ActivitySourceOptions options) : base(options) + { + } + + public void Release() => base.Dispose(true); // call the protected Dispose(bool) + + protected override void Dispose(bool disposing) + { + // no-op, disallow users from disposing of the activity sources created by the factory. + } + } + + private sealed class ActivityListenerRegistration : IDisposable + { + private readonly string _listenerName; + private readonly DefaultActivitySourceFactory _activitySourceFactory; + private readonly object _lock = new(); + private readonly SampleActivity? _sample; + private readonly SampleActivity? _sampleUsingParentId; + private readonly Action? _activityStarted; + private readonly Action? _activityStopped; + private readonly ExceptionRecorder? _exceptionRecorder; + private readonly ActivityListener _activityListener; + private ListenerState _state; + private bool _disposed; + + public ActivityListenerRegistration(ActivityListenerBuilder listenerBuilder, DefaultActivitySourceFactory activitySourceFactory) + { + _activitySourceFactory = activitySourceFactory; + _listenerName = listenerBuilder.Name; + _sample = listenerBuilder.Sample; + _sampleUsingParentId = listenerBuilder.SampleUsingParentId; + _activityStarted = listenerBuilder.ActivityStarted; + _activityStopped = listenerBuilder.ActivityStopped; + _exceptionRecorder = listenerBuilder.ExceptionRecorder; + _state = ListenerState.Empty; + _activityListener = new ActivityListener { ShouldListenTo = ShouldListenTo }; + ApplyListenerDelegates(false); + } + + public void Dispose() + { + lock (_lock) + { + if (_disposed) + { + return; + } + + _disposed = true; + _activityListener.Dispose(); + _state = ListenerState.Empty; + } + } + + public void UpdateRules(List rules, bool overwrite = true) + { + ArgumentNullException.ThrowIfNull(rules); + + lock (_lock) + { + if (_disposed) + { + return; + } + + if (!overwrite && !ReferenceEquals(_state, ListenerState.Empty)) + { + return; + } + + ListenerState newState = ListenerState.Create(rules); + bool delegatesNeedSwap = newState.HasOperationNameRules != _state.HasOperationNameRules; + Volatile.Write(ref _state, newState); + if (delegatesNeedSwap) + { + ApplyListenerDelegates(newState.HasOperationNameRules); + } + } + + _activityListener.RefreshSources(); + } + + private void ApplyListenerDelegates(bool hasOperationNameRules) + { + _activityListener.Sample = _sample is null ? null : (hasOperationNameRules ? WrappedSample : _sample); + _activityListener.SampleUsingParentId = _sampleUsingParentId is null ? null : (hasOperationNameRules ? WrappedSampleUsingParentId : _sampleUsingParentId); + _activityListener.ActivityStarted = _activityStarted is null ? null : (hasOperationNameRules ? WrappedActivityStarted : _activityStarted); + _activityListener.ActivityStopped = _activityStopped is null ? null : (hasOperationNameRules ? WrappedActivityStopped : _activityStopped); + _activityListener.ExceptionRecorder = _exceptionRecorder is null ? null : (hasOperationNameRules ? WrappedExceptionRecorder : _exceptionRecorder); + } + + + private ActivitySamplingResult WrappedSample(ref ActivityCreationOptions options) + { + ListenerState state = Volatile.Read(ref _state); + if (state.HasOperationNameRules && !IsEnabledFast(state, options.Source, options.Name)) + { + return ActivitySamplingResult.None; + } + + Debug.Assert(_sample is not null); + return _sample!.Invoke(ref options); + } + + private ActivitySamplingResult WrappedSampleUsingParentId(ref ActivityCreationOptions options) + { + ListenerState state = Volatile.Read(ref _state); + if (state.HasOperationNameRules && !IsEnabledFast(state, options.Source, options.Name)) + { + return ActivitySamplingResult.None; + } + + Debug.Assert(_sampleUsingParentId is not null); + return _sampleUsingParentId!.Invoke(ref options); + } + + private void WrappedActivityStarted(Activity activity) + { + ListenerState state = Volatile.Read(ref _state); + if (!state.HasOperationNameRules || IsEnabledFast(state, activity.Source, activity.OperationName)) + { + Debug.Assert(_activityStarted is not null); + _activityStarted!.Invoke(activity); + } + } + + private void WrappedActivityStopped(Activity activity) + { + ListenerState state = Volatile.Read(ref _state); + if (!state.HasOperationNameRules || IsEnabledFast(state, activity.Source, activity.OperationName)) + { + Debug.Assert(_activityStopped is not null); + _activityStopped!.Invoke(activity); + } + } + + private void WrappedExceptionRecorder(Activity activity, Exception exception, ref TagList tags) + { + ListenerState state = Volatile.Read(ref _state); + if (!state.HasOperationNameRules || IsEnabledFast(state, activity.Source, activity.OperationName)) + { + Debug.Assert(_exceptionRecorder is not null); + _exceptionRecorder!.Invoke(activity, exception, ref tags); + } + } + + private bool IsEnabledFast(ListenerState state, ActivitySource source, string operationName) + { + (string Name, bool IsLocalScope) key = (source.Name, ReferenceEquals(_activitySourceFactory, source.Scope)); + if (!state.SourceFilterStates.TryGetValue(key, out SourceFilterState filter)) + { + // Miss only fires in the brief window between UpdateRules swapping in a fresh + // (empty) cache and RefreshSources repopulating it for this source. We recompute + // without writing back: the extra work per call is preferable to the dictionary + // copy a CAS write-back would cost, and the next ShouldListenTo populates the + // entry anyway. + filter = ComputeFilterState(state.Rules, key.Name, key.IsLocalScope); + } + bool divergent = filter.Divergent is { } d && d.Contains(operationName); + return divergent ? !filter.DefaultEnabled : filter.DefaultEnabled; + } + + private SourceFilterState ComputeFilterState(IList rules, string sourceName, bool isLocalScope) + { + TracingRule? defaultRule = GetMostSpecificRule(rules, sourceName, operationName: null, _listenerName, isLocalScope, considerOperationName: true); + bool defaultEnabled = defaultRule?.Enable ?? false; + + HashSet? divergent = null; + HashSet? seen = null; + foreach (TracingRule rule in rules) + { + if (string.IsNullOrEmpty(rule.OperationName)) + { + continue; + } + + seen ??= new HashSet(StringComparer.OrdinalIgnoreCase); + if (!seen.Add(rule.OperationName)) + { + continue; + } + + bool enabled = IsOperationEnabled(rules, sourceName, isLocalScope, rule.OperationName); + if (enabled != defaultEnabled) + { + divergent ??= new HashSet(StringComparer.OrdinalIgnoreCase); + divergent.Add(rule.OperationName); + } + } + + return new SourceFilterState(defaultEnabled, divergent); + } + + private bool IsOperationEnabled(IList rules, string sourceName, bool isLocalScope, string operationName) + { + TracingRule? rule = GetMostSpecificRule(rules, sourceName, operationName, _listenerName, isLocalScope, considerOperationName: true); + return rule?.Enable ?? false; + } + + private bool ShouldListenTo(ActivitySource activitySource) + { + if (activitySource.Scope is { } s && !ReferenceEquals(s, _activitySourceFactory)) + { + return false; + } + + (string Name, bool IsLocalScope) key = (activitySource.Name, ReferenceEquals(_activitySourceFactory, activitySource.Scope)); + + SourceFilterState filter; + while (true) + { + ListenerState state = Volatile.Read(ref _state); + if (state.SourceFilterStates.TryGetValue(key, out filter)) + { + break; + } + + filter = ComputeFilterState(state.Rules, key.Name, key.IsLocalScope); + // Copy-on-write via CAS so IsEnabledFast readers stay lock-free and + // UpdateRules can swap the whole state without blocking concurrent ShouldListenTo calls. + var newDict = new Dictionary<(string Name, bool IsLocalScope), SourceFilterState>(state.SourceFilterStates) + { + [key] = filter, + }; + ListenerState newState = state.WithSourceFilterStates(newDict); + if (Interlocked.CompareExchange(ref _state, newState, state) == state) + { + break; + } + } + + return filter.DefaultEnabled || filter.Divergent is { Count: > 0 }; + } + + private static TracingRule? GetMostSpecificRule(IList rules, string sourceName, string? operationName, string? listenerName, bool isLocalScope, bool considerOperationName) + { + TracingRule? best = null; + foreach (TracingRule rule in rules) + { + if (RuleMatches(rule, sourceName, listenerName, isLocalScope, considerOperationName, operationName) + && IsMoreSpecific(rule, best, isLocalScope, considerOperationName)) + { + best = rule; + } + } + + return best; + } + + private static bool RuleMatches(TracingRule rule, string sourceName, string? listenerName, bool isLocalScope, bool considerOperationName, string? operationName = null) + { + if (!string.IsNullOrEmpty(rule.ListenerName) + && !string.Equals(rule.ListenerName, listenerName, StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + if (!rule.Scopes.HasFlag(isLocalScope ? ActivitySourceScopes.Local : ActivitySourceScopes.Global)) + { + return false; + } + + if (!Matches(rule.SourceName, sourceName)) + { + return false; + } + + if (considerOperationName && !string.IsNullOrEmpty(rule.OperationName)) + { + if (operationName is null + || !string.Equals(rule.OperationName, operationName, StringComparison.OrdinalIgnoreCase)) + { + return false; + } + } + + return true; + } + + private static bool IsMoreSpecific(TracingRule rule, TracingRule? best, bool isLocalScope, bool considerOperationName) + { + if (best is null) + { + return true; + } + + if (!string.IsNullOrEmpty(rule.ListenerName) && string.IsNullOrEmpty(best.ListenerName)) + { + return true; + } + else if (string.IsNullOrEmpty(rule.ListenerName) && !string.IsNullOrEmpty(best.ListenerName)) + { + return false; + } + + if (!string.IsNullOrEmpty(rule.SourceName)) + { + if (string.IsNullOrEmpty(best.SourceName)) + { + return true; + } + + if (rule.SourceName.Length != best.SourceName.Length) + { + return rule.SourceName.Length > best.SourceName.Length; + } + } + else if (!string.IsNullOrEmpty(best.SourceName)) + { + return false; + } + + if (considerOperationName) + { + if (!string.IsNullOrEmpty(rule.OperationName) && string.IsNullOrEmpty(best.OperationName)) + { + return true; + } + else if (string.IsNullOrEmpty(rule.OperationName) && !string.IsNullOrEmpty(best.OperationName)) + { + return false; + } + } + + if (isLocalScope) + { + if (!rule.Scopes.HasFlag(ActivitySourceScopes.Global) && best.Scopes.HasFlag(ActivitySourceScopes.Global)) + { + return true; + } + else if (rule.Scopes.HasFlag(ActivitySourceScopes.Global) && !best.Scopes.HasFlag(ActivitySourceScopes.Global)) + { + return false; + } + } + else + { + if (!rule.Scopes.HasFlag(ActivitySourceScopes.Local) && best.Scopes.HasFlag(ActivitySourceScopes.Local)) + { + return true; + } + else if (rule.Scopes.HasFlag(ActivitySourceScopes.Local) && !best.Scopes.HasFlag(ActivitySourceScopes.Local)) + { + return false; + } + } + + return true; + } + + private static bool Matches(string? pattern, string name) + { + if (string.IsNullOrEmpty(pattern)) + { + return true; + } + + const char WildcardChar = '*'; + int wildcardIndex = pattern.IndexOf(WildcardChar); + // TracingRule's constructor validates that at most one '*' is present, so we don't + // re-check here. If a pattern with multiple wildcards somehow reaches this code, + // the second wildcard is silently treated as a literal '*' inside the suffix. + + ReadOnlySpan prefix; + ReadOnlySpan suffix; + if (wildcardIndex < 0) + { + prefix = pattern.AsSpan(); + suffix = default; + } + else + { + prefix = pattern.AsSpan(0, wildcardIndex); + suffix = pattern.AsSpan(wildcardIndex + 1); + } + + return name.AsSpan().StartsWith(prefix, StringComparison.OrdinalIgnoreCase) + && name.AsSpan().EndsWith(suffix, StringComparison.OrdinalIgnoreCase); + } + + private readonly struct SourceFilterState + { + public SourceFilterState(bool defaultEnabled, HashSet? divergent) + { + DefaultEnabled = defaultEnabled; + Divergent = divergent; + } + + public bool DefaultEnabled { get; } + public HashSet? Divergent { get; } + } + + private sealed class ListenerState + { + public static readonly ListenerState Empty = new([], hasOperationNameRules: false, new Dictionary<(string, bool), SourceFilterState>()); + + public ListenerState(IList rules, bool hasOperationNameRules, Dictionary<(string Name, bool IsLocalScope), SourceFilterState> sourceFilterStates) + { + Rules = rules; + HasOperationNameRules = hasOperationNameRules; + SourceFilterStates = sourceFilterStates; + } + + public IList Rules { get; } + public bool HasOperationNameRules { get; } + + // Keyed by (Name, IsLocalScope) rather than by ActivitySource instance so that + // disposed sources do not stay pinned in the cache, and so that two sources + // sharing the same name and scope (e.g. a source recreated after a previous + // instance was disposed) share the cached filter state. + public Dictionary<(string Name, bool IsLocalScope), SourceFilterState> SourceFilterStates { get; } + + public static ListenerState Create(IList rules) + => new(rules, ComputeHasOperationNameRules(rules), new Dictionary<(string, bool), SourceFilterState>()); + + public ListenerState WithSourceFilterStates(Dictionary<(string Name, bool IsLocalScope), SourceFilterState> sourceFilterStates) + => new(Rules, HasOperationNameRules, sourceFilterStates); + + private static bool ComputeHasOperationNameRules(IList rules) + { + foreach (TracingRule rule in rules) + { + if (!string.IsNullOrEmpty(rule.OperationName)) + { + return true; + } + } + + return false; + } + } + } + } +} diff --git a/src/libraries/Microsoft.Extensions.Diagnostics/src/Tracing/TracingServiceExtensions.cs b/src/libraries/Microsoft.Extensions.Diagnostics/src/Tracing/TracingServiceExtensions.cs new file mode 100644 index 00000000000000..9521a79b6ca090 --- /dev/null +++ b/src/libraries/Microsoft.Extensions.Diagnostics/src/Tracing/TracingServiceExtensions.cs @@ -0,0 +1,69 @@ +// 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; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Diagnostics.Tracing; +using Microsoft.Extensions.Options; + +namespace Microsoft.Extensions.DependencyInjection +{ + /// + /// Extension methods for setting up tracing services in an . + /// + public static class TracingServiceExtensions + { + /// + /// Adds tracing services to the specified . + /// + /// The to add services to. + /// The so that additional calls can be chained. + public static IServiceCollection AddTracing(this IServiceCollection services) + { + ArgumentNullException.ThrowIfNull(services); + + services.AddOptions(); + services.TryAddSingleton(); + services.AddOptions().ValidateOnStart(); + services.TryAddSingleton, SubscriptionActivator>(); + services.TryAddSingleton(); + + return services; + } + + /// + /// Adds tracing services to the specified . + /// + /// The to add services to. + /// A callback to configure the . + /// The so that additional calls can be chained. + public static IServiceCollection AddTracing(this IServiceCollection services, Action configure) + { + ArgumentNullException.ThrowIfNull(services); + ArgumentNullException.ThrowIfNull(configure); + + _ = AddTracing(services); + var builder = new TracingBuilder(services); + configure(builder); + return services; + } + + private sealed class TracingBuilder(IServiceCollection services) : ITracingBuilder + { + public IServiceCollection Services { get; } = services; + } + + private sealed class NoOpOptions + { + } + + private sealed class SubscriptionActivator(ActivitySourceFactory factory) : IConfigureOptions + { + public void Configure(NoOpOptions options) + { + GC.KeepAlive(factory); // Eagerly instantiate the factory so any constructor-based listener registration happens during startup. + } + } + } +} diff --git a/src/libraries/Microsoft.Extensions.Diagnostics/tests/DefaultActivitySourceFactoryTests.cs b/src/libraries/Microsoft.Extensions.Diagnostics/tests/DefaultActivitySourceFactoryTests.cs new file mode 100644 index 00000000000000..c556ba8f78fd0c --- /dev/null +++ b/src/libraries/Microsoft.Extensions.Diagnostics/tests/DefaultActivitySourceFactoryTests.cs @@ -0,0 +1,457 @@ +// 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; +using System.Linq; +using Microsoft.DotNet.RemoteExecutor; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Configuration.Memory; +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +namespace Microsoft.Extensions.Diagnostics.Tracing.Tests +{ + public class DefaultActivitySourceFactoryTests + { + [Fact] + public void FactoryReturnsCachedSourceForSameNameVersionAndTags() + { + using var sp = new ServiceCollection().AddTracing().BuildServiceProvider(); + using ActivitySourceFactory factory = sp.GetRequiredService(); + + var tags = new[] { new KeyValuePair("k", "v") }; + ActivitySource a = factory.Create("MySource", "1.0", tags); + ActivitySource b = factory.Create("MySource", "1.0", tags); + + Assert.Same(a, b); + Assert.Same(factory, a.Scope); + } + + [Fact] + public void FactoryReturnsDifferentSourcesForDifferentVersionOrTags() + { + using var sp = new ServiceCollection().AddTracing().BuildServiceProvider(); + using ActivitySourceFactory factory = sp.GetRequiredService(); + + ActivitySource v1 = factory.Create("MySource", "1.0"); + ActivitySource v2 = factory.Create("MySource", "2.0"); + ActivitySource t1 = factory.Create("MySource", "1.0", new[] { new KeyValuePair("k", "v") }); + + Assert.NotSame(v1, v2); + Assert.NotSame(v1, t1); + } + + [Fact] + public void FactoryTagOrderDoesNotAffectCacheLookup() + { + using var sp = new ServiceCollection().AddTracing().BuildServiceProvider(); + using ActivitySourceFactory factory = sp.GetRequiredService(); + + ActivitySource a = factory.Create("MySource", "1.0", new[] + { + new KeyValuePair("a", "1"), + new KeyValuePair("b", "2"), + }); + ActivitySource b = factory.Create("MySource", "1.0", new[] + { + new KeyValuePair("b", "2"), + new KeyValuePair("a", "1"), + }); + + Assert.Same(a, b); + } + + [Fact] + public void CreateAfterDisposeThrowsObjectDisposedException() + { + using var sp = new ServiceCollection().AddTracing().BuildServiceProvider(); + ActivitySourceFactory factory = sp.GetRequiredService(); + factory.Dispose(); + + Assert.Throws(() => factory.Create("MySource")); + } + + [Fact] + public void CreateWithExplicitScopeMatchingFactoryIsAllowed() + { + using var sp = new ServiceCollection().AddTracing().BuildServiceProvider(); + using ActivitySourceFactory factory = sp.GetRequiredService(); + + var options = new ActivitySourceOptions("MySource") { Scope = factory }; + ActivitySource source = factory.Create(options); + + Assert.Same(factory, source.Scope); + } + + [Fact] + public void CreateWithDifferentScopeThrows() + { + using var sp = new ServiceCollection().AddTracing().BuildServiceProvider(); + using ActivitySourceFactory factory = sp.GetRequiredService(); + + var foreignScope = new object(); + var options = new ActivitySourceOptions("MySource") { Scope = foreignScope }; + + Assert.Throws(() => factory.Create(options)); + } + + [ConditionalFact(typeof(RemoteExecutor), nameof(RemoteExecutor.IsSupported))] + public void EnabledTracingRuleAllowsActivityStart() + { + RemoteExecutor.Invoke(() => + { + using var sp = BuildServices( + configure: builder => + { + builder.EnableTracing("MySource"); + AddSamplingListener(builder, "L1", out _); + }); + + using ActivitySourceFactory factory = sp.GetRequiredService(); + using ActivitySource source = factory.Create("MySource"); + using Activity? activity = source.StartActivity("Op1"); + + Assert.NotNull(activity); + }).Dispose(); + } + + [ConditionalFact(typeof(RemoteExecutor), nameof(RemoteExecutor.IsSupported))] + public void DisabledByDefaultProducesNoActivity() + { + RemoteExecutor.Invoke(() => + { + using var sp = BuildServices( + configure: builder => + { + builder.EnableTracing("Other"); + AddSamplingListener(builder, "L1", out _); + }); + + using ActivitySourceFactory factory = sp.GetRequiredService(); + using ActivitySource source = factory.Create("MySource"); + using Activity? activity = source.StartActivity("Op1"); + + Assert.Null(activity); + }).Dispose(); + } + + [ConditionalFact(typeof(RemoteExecutor), nameof(RemoteExecutor.IsSupported))] + public void MostSpecificRuleWinsAcrossSourcePrefix() + { + RemoteExecutor.Invoke(() => + { + using var sp = BuildServices( + configure: builder => + { + builder.EnableTracing("MyCompany"); + builder.DisableTracing("MyCompany.Service"); + AddSamplingListener(builder, "L1", out _); + }); + + using ActivitySourceFactory factory = sp.GetRequiredService(); + using ActivitySource broad = factory.Create("MyCompany.Other"); + using ActivitySource narrow = factory.Create("MyCompany.Service"); + + using Activity? broadActivity = broad.StartActivity("Op"); + using Activity? narrowActivity = narrow.StartActivity("Op"); + + Assert.NotNull(broadActivity); + Assert.Null(narrowActivity); + }).Dispose(); + } + + [ConditionalFact(typeof(RemoteExecutor), nameof(RemoteExecutor.IsSupported))] + public void WildcardSourcePatternMatchesPrefixAndSuffix() + { + RemoteExecutor.Invoke(() => + { + using var sp = BuildServices( + configure: builder => + { + builder.EnableTracing("MyCompany.*.Public"); + AddSamplingListener(builder, "L1", out _); + }); + + using ActivitySourceFactory factory = sp.GetRequiredService(); + using ActivitySource matching = factory.Create("MyCompany.Service.Public"); + using ActivitySource nonMatching = factory.Create("MyCompany.Service.Internal"); + + using Activity? matched = matching.StartActivity("Op"); + using Activity? unmatched = nonMatching.StartActivity("Op"); + + Assert.NotNull(matched); + Assert.Null(unmatched); + }).Dispose(); + } + + [ConditionalFact(typeof(RemoteExecutor), nameof(RemoteExecutor.IsSupported))] + public void OperationNameRuleDisablesOneOperationOfEnabledSource() + { + RemoteExecutor.Invoke(() => + { + using var sp = BuildServices( + configure: builder => + { + builder.EnableTracing("MySource"); + builder.DisableTracing("MySource", "Quiet"); + AddSamplingListener(builder, "L1", out _); + }); + + using ActivitySourceFactory factory = sp.GetRequiredService(); + using ActivitySource source = factory.Create("MySource"); + + using Activity? loud = source.StartActivity("Loud"); + using Activity? quiet = source.StartActivity("Quiet"); + + Assert.NotNull(loud); + Assert.Null(quiet); + }).Dispose(); + } + + [ConditionalFact(typeof(RemoteExecutor), nameof(RemoteExecutor.IsSupported))] + public void OperationNameRuleEnablesOneOperationOfDisabledSource() + { + RemoteExecutor.Invoke(() => + { + using var sp = BuildServices( + configure: builder => + { + builder.EnableTracing("MySource", "Loud"); + AddSamplingListener(builder, "L1", out _); + }); + + using ActivitySourceFactory factory = sp.GetRequiredService(); + using ActivitySource source = factory.Create("MySource"); + + using Activity? loud = source.StartActivity("Loud"); + using Activity? quiet = source.StartActivity("Quiet"); + + Assert.NotNull(loud); + Assert.Null(quiet); + }).Dispose(); + } + + [ConditionalFact(typeof(RemoteExecutor), nameof(RemoteExecutor.IsSupported))] + public void LocalScopeRuleDoesNotMatchStandaloneSource() + { + RemoteExecutor.Invoke(() => + { + using var sp = BuildServices( + configure: builder => + { + builder.EnableTracing("MySource", scopes: ActivitySourceScopes.Local); + AddSamplingListener(builder, "L1", out _); + }); + + using ActivitySourceFactory factory = sp.GetRequiredService(); + using ActivitySource factorySource = factory.Create("MySource"); + using ActivitySource standalone = new ActivitySource("MySource"); + + using Activity? fromFactory = factorySource.StartActivity("Op"); + using Activity? fromStandalone = standalone.StartActivity("Op"); + + Assert.NotNull(fromFactory); + Assert.Null(fromStandalone); + }).Dispose(); + } + + [ConditionalFact(typeof(RemoteExecutor), nameof(RemoteExecutor.IsSupported))] + public void GlobalScopeRuleMatchesStandaloneAndFactorySources() + { + RemoteExecutor.Invoke(() => + { + using var sp = BuildServices( + configure: builder => + { + builder.EnableTracing("MySource", scopes: ActivitySourceScopes.Global | ActivitySourceScopes.Local); + AddSamplingListener(builder, "L1", out _); + }); + + using ActivitySourceFactory factory = sp.GetRequiredService(); + using ActivitySource factorySource = factory.Create("MySource"); + using ActivitySource standalone = new ActivitySource("MySource"); + + using Activity? fromFactory = factorySource.StartActivity("Op"); + using Activity? fromStandalone = standalone.StartActivity("Op"); + + Assert.NotNull(fromFactory); + Assert.NotNull(fromStandalone); + }).Dispose(); + } + + [ConditionalFact(typeof(RemoteExecutor), nameof(RemoteExecutor.IsSupported))] + public void GlobalRuleOnOneFactoryDoesNotMatchAnotherFactorysLocalSource() + { + RemoteExecutor.Invoke(() => + { + List? aStarted = null; + using ServiceProvider spA = BuildServices( + configure: builder => + { + builder.EnableTracing("MySource", scopes: ActivitySourceScopes.Global); + AddSamplingListener(builder, "LA", out aStarted); + }); + using ServiceProvider spB = BuildServices( + configure: builder => + { + // factory B has no rules: any activity that fires is the result of A's + // listener attaching to B's source, which would be the cross-factory leak. + AddSamplingListener(builder, "LB", out _); + }); + + using ActivitySourceFactory factoryA = spA.GetRequiredService(); + using ActivitySourceFactory factoryB = spB.GetRequiredService(); + + using ActivitySource sourceFromB = factoryB.Create("MySource"); + using Activity? activity = sourceFromB.StartActivity("Op"); + + Assert.Null(activity); + Assert.Empty(aStarted!); + }).Dispose(); + } + + [ConditionalFact(typeof(RemoteExecutor), nameof(RemoteExecutor.IsSupported))] + public void LocalRuleOnOneFactoryDoesNotMatchAnotherFactorysLocalSource() + { + RemoteExecutor.Invoke(() => + { + List? aStarted = null; + using ServiceProvider spA = BuildServices( + configure: builder => + { + builder.EnableTracing("MySource", scopes: ActivitySourceScopes.Local); + AddSamplingListener(builder, "LA", out aStarted); + }); + using ServiceProvider spB = BuildServices( + configure: builder => + { + AddSamplingListener(builder, "LB", out _); + }); + + using ActivitySourceFactory factoryA = spA.GetRequiredService(); + using ActivitySourceFactory factoryB = spB.GetRequiredService(); + + using ActivitySource sourceFromB = factoryB.Create("MySource"); + using Activity? activity = sourceFromB.StartActivity("Op"); + + Assert.Null(activity); + Assert.Empty(aStarted!); + }).Dispose(); + } + + [ConditionalFact(typeof(RemoteExecutor), nameof(RemoteExecutor.IsSupported))] + public void ListenerNameTargetsSpecificListenerOnly() + { + RemoteExecutor.Invoke(() => + { + List? l1Started = null; + List? l2Started = null; + using var sp = BuildServices( + configure: builder => + { + builder.EnableTracing("MySource", listenerName: "L1"); + AddSamplingListener(builder, "L1", out l1Started); + AddSamplingListener(builder, "L2", out l2Started); + }); + + using ActivitySourceFactory factory = sp.GetRequiredService(); + using ActivitySource source = factory.Create("MySource"); + using Activity? activity = source.StartActivity("Op"); + + Assert.NotNull(activity); + Assert.NotEmpty(l1Started!); + Assert.Empty(l2Started!); + }).Dispose(); + } + + [ConditionalFact(typeof(RemoteExecutor), nameof(RemoteExecutor.IsSupported))] + public void DisposingFactoryUnsubscribesListeners() + { + RemoteExecutor.Invoke(() => + { + var sp = BuildServices( + configure: builder => + { + builder.EnableTracing("MySource"); + AddSamplingListener(builder, "L1", out var started); + }); + + ActivitySourceFactory factory = sp.GetRequiredService(); + using (ActivitySource warmup = factory.Create("MySource")) + { + using Activity? a = warmup.StartActivity("Op"); + Assert.NotNull(a); + } + + factory.Dispose(); + + using ActivitySource standalone = new ActivitySource("MySource"); + using Activity? afterDispose = standalone.StartActivity("Op"); + Assert.Null(afterDispose); + + sp.Dispose(); + }).Dispose(); + } + + [ConditionalFact(typeof(RemoteExecutor), nameof(RemoteExecutor.IsSupported))] + public void ConfigurationReloadAppliesNewRules() + { + RemoteExecutor.Invoke(() => + { + var initial = new Dictionary + { + ["EnabledTracing:MySource"] = "false", + }; + var memorySource = new MemoryConfigurationSource { InitialData = initial }; + IConfigurationRoot config = new ConfigurationBuilder().Add(memorySource).Build(); + + var services = new ServiceCollection(); + services.AddTracing(builder => + { + builder.AddConfiguration(config); + AddSamplingListener(builder, "L1", out _); + }); + using ServiceProvider sp = services.BuildServiceProvider(); + using ActivitySourceFactory factory = sp.GetRequiredService(); + using ActivitySource source = factory.Create("MySource"); + + using (Activity? before = source.StartActivity("Op")) + { + Assert.Null(before); + } + + config["EnabledTracing:MySource"] = "true"; + config.Reload(); + + using (Activity? after = source.StartActivity("Op")) + { + Assert.NotNull(after); + } + }).Dispose(); + } + + private static ServiceProvider BuildServices(Action configure) + { + var services = new ServiceCollection(); + services.AddTracing(configure); + return services.BuildServiceProvider(); + } + + private static void AddSamplingListener(ITracingBuilder builder, string name, out List started) + { + var local = new List(); + started = local; + builder.AddListener(name, b => + { + b.Sample = (ref ActivityCreationOptions _) => ActivitySamplingResult.AllData; + b.SampleUsingParentId = (ref ActivityCreationOptions _) => ActivitySamplingResult.AllData; + b.ActivityStarted = a => + { + lock (local) { local.Add(a); } + }; + }); + } + } +} diff --git a/src/libraries/Microsoft.Extensions.Diagnostics/tests/Microsoft.Extensions.Diagnostics.Tests.csproj b/src/libraries/Microsoft.Extensions.Diagnostics/tests/Microsoft.Extensions.Diagnostics.Tests.csproj index 0647d565f14da5..c0368b28c4b6fb 100644 --- a/src/libraries/Microsoft.Extensions.Diagnostics/tests/Microsoft.Extensions.Diagnostics.Tests.csproj +++ b/src/libraries/Microsoft.Extensions.Diagnostics/tests/Microsoft.Extensions.Diagnostics.Tests.csproj @@ -16,6 +16,7 @@ + diff --git a/src/libraries/Microsoft.Extensions.Diagnostics/tests/TracingConfigureOptionsTests.cs b/src/libraries/Microsoft.Extensions.Diagnostics/tests/TracingConfigureOptionsTests.cs new file mode 100644 index 00000000000000..1d0224bbaf6358 --- /dev/null +++ b/src/libraries/Microsoft.Extensions.Diagnostics/tests/TracingConfigureOptionsTests.cs @@ -0,0 +1,192 @@ +// 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.Linq; +using Microsoft.Extensions.Configuration; +using Xunit; + +namespace Microsoft.Extensions.Diagnostics.Tracing.Tests +{ + public class TracingConfigureOptionsTests + { + [Fact] + public void LoadActivityRulesAddsOneRulePerOperationAndCollapsesDefault() + { + var options = new TracingOptions(); + var configuration = new ConfigurationBuilder().AddInMemoryCollection().Build(); + configuration["SourceName:Op1"] = "true"; + configuration["SourceName:Op2"] = "false"; + configuration["SourceName:Default"] = "true"; + + TracingConfigureOptions.LoadActivityRules(options, configuration.GetSection("SourceName"), ActivitySourceScopes.Local, "Listener"); + + Assert.Equal(3, options.Rules.Count); + AssertRule(options.Rules.Single(r => r.OperationName == "Op1"), "SourceName", "Op1", "Listener", ActivitySourceScopes.Local, true); + AssertRule(options.Rules.Single(r => r.OperationName == "Op2"), "SourceName", "Op2", "Listener", ActivitySourceScopes.Local, false); + AssertRule(options.Rules.Single(r => r.OperationName is null), "SourceName", null, "Listener", ActivitySourceScopes.Local, true); + } + + [Fact] + public void LoadActivityRulesIgnoresNonBooleanEntries() + { + var options = new TracingOptions(); + var configuration = new ConfigurationBuilder().AddInMemoryCollection().Build(); + configuration["SourceName:Op1"] = "true"; + configuration["SourceName:Op2"] = "not-a-bool"; + + TracingConfigureOptions.LoadActivityRules(options, configuration.GetSection("SourceName"), ActivitySourceScopes.Local, listenerName: null); + + var rule = Assert.Single(options.Rules); + AssertRule(rule, "SourceName", "Op1", null, ActivitySourceScopes.Local, true); + } + + [Fact] + public void LoadActivitySourceRulesAddsLeafBoolEntries() + { + var options = new TracingOptions(); + var configuration = new ConfigurationBuilder().AddInMemoryCollection().Build(); + configuration["Section:SourceA"] = "true"; + configuration["Section:SourceB"] = "false"; + configuration["Section:Default"] = "true"; + + TracingConfigureOptions.LoadActivitySourceRules(options, configuration.GetSection("Section"), ActivitySourceScopes.Local, "Listener"); + + Assert.Equal(3, options.Rules.Count); + AssertRule(options.Rules.Single(r => r.SourceName == "SourceA"), "SourceA", null, "Listener", ActivitySourceScopes.Local, true); + AssertRule(options.Rules.Single(r => r.SourceName == "SourceB"), "SourceB", null, "Listener", ActivitySourceScopes.Local, false); + AssertRule(options.Rules.Single(r => r.SourceName is null), null, null, "Listener", ActivitySourceScopes.Local, true); + } + + [Fact] + public void LoadActivitySourceRulesDescendsIntoSectionsWithChildren() + { + var options = new TracingOptions(); + var configuration = new ConfigurationBuilder().AddInMemoryCollection().Build(); + configuration["Section:SourceA:Op1"] = "true"; + configuration["Section:SourceA:Op2"] = "false"; + configuration["Section:SourceA:Default"] = "true"; + configuration["Section:SourceB:Op1"] = "true"; + configuration["Section:SourceB:Default"] = "false"; + configuration["Section:Default"] = "true"; + + TracingConfigureOptions.LoadActivitySourceRules(options, configuration.GetSection("Section"), ActivitySourceScopes.Local, "Listener"); + + Assert.Equal(6, options.Rules.Count); + AssertRule(options.Rules.Single(r => r.SourceName == "SourceA" && r.OperationName == "Op1"), "SourceA", "Op1", "Listener", ActivitySourceScopes.Local, true); + AssertRule(options.Rules.Single(r => r.SourceName == "SourceA" && r.OperationName == "Op2"), "SourceA", "Op2", "Listener", ActivitySourceScopes.Local, false); + AssertRule(options.Rules.Single(r => r.SourceName == "SourceA" && r.OperationName is null), "SourceA", null, "Listener", ActivitySourceScopes.Local, true); + AssertRule(options.Rules.Single(r => r.SourceName == "SourceB" && r.OperationName == "Op1"), "SourceB", "Op1", "Listener", ActivitySourceScopes.Local, true); + AssertRule(options.Rules.Single(r => r.SourceName == "SourceB" && r.OperationName is null), "SourceB", null, "Listener", ActivitySourceScopes.Local, false); + AssertRule(options.Rules.Single(r => r.SourceName is null && r.OperationName is null), null, null, "Listener", ActivitySourceScopes.Local, true); + } + + [Fact] + public void LoadActivitySourceRulesKeepsLiteralDefaultSourceWhenItHasChildren() + { + // A source literally named "Default" with nested operations is treated as the source "Default", + // not as the all-sources catch-all. The Default -> null collapse only fires for the leaf-bool form. + var options = new TracingOptions(); + var configuration = new ConfigurationBuilder().AddInMemoryCollection().Build(); + configuration["Section:Default:Op"] = "true"; + + TracingConfigureOptions.LoadActivitySourceRules(options, configuration.GetSection("Section"), ActivitySourceScopes.Local, "Listener"); + + var rule = Assert.Single(options.Rules); + AssertRule(rule, "Default", "Op", "Listener", ActivitySourceScopes.Local, true); + } + + [Theory] + [InlineData("EnabledTracing:Default", "true", null, null, null, ActivitySourceScopes.Global | ActivitySourceScopes.Local, true)] + [InlineData("EnabledTracing:Source", "false", "Source", null, null, ActivitySourceScopes.Global | ActivitySourceScopes.Local, false)] + [InlineData("EnabledTracing:Source:Default", "true", "Source", null, null, ActivitySourceScopes.Global | ActivitySourceScopes.Local, true)] + [InlineData("EnabledTracing:Source:Op", "false", "Source", "Op", null, ActivitySourceScopes.Global | ActivitySourceScopes.Local, false)] + [InlineData("EnabledGlobalTracing:Default", "true", null, null, null, ActivitySourceScopes.Global, true)] + [InlineData("EnabledGlobalTracing:Source", "false", "Source", null, null, ActivitySourceScopes.Global, false)] + [InlineData("EnabledGlobalTracing:Source:Default", "true", "Source", null, null, ActivitySourceScopes.Global, true)] + [InlineData("EnabledGlobalTracing:Source:Op", "false", "Source", "Op", null, ActivitySourceScopes.Global, false)] + [InlineData("EnabledLocalTracing:Default", "true", null, null, null, ActivitySourceScopes.Local, true)] + [InlineData("EnabledLocalTracing:Source", "false", "Source", null, null, ActivitySourceScopes.Local, false)] + [InlineData("EnabledLocalTracing:Source:Default", "true", "Source", null, null, ActivitySourceScopes.Local, true)] + [InlineData("EnabledLocalTracing:Source:Op", "false", "Source", "Op", null, ActivitySourceScopes.Local, false)] + [InlineData("Listener:EnabledTracing:Default", "true", null, null, "Listener", ActivitySourceScopes.Global | ActivitySourceScopes.Local, true)] + [InlineData("Listener:EnabledTracing:Source", "false", "Source", null, "Listener", ActivitySourceScopes.Global | ActivitySourceScopes.Local, false)] + [InlineData("Listener:EnabledTracing:Source:Default", "true", "Source", null, "Listener", ActivitySourceScopes.Global | ActivitySourceScopes.Local, true)] + [InlineData("Listener:EnabledTracing:Source:Op", "false", "Source", "Op", "Listener", ActivitySourceScopes.Global | ActivitySourceScopes.Local, false)] + [InlineData("Listener:EnabledGlobalTracing:Default", "true", null, null, "Listener", ActivitySourceScopes.Global, true)] + [InlineData("Listener:EnabledGlobalTracing:Source", "false", "Source", null, "Listener", ActivitySourceScopes.Global, false)] + [InlineData("Listener:EnabledGlobalTracing:Source:Default", "true", "Source", null, "Listener", ActivitySourceScopes.Global, true)] + [InlineData("Listener:EnabledGlobalTracing:Source:Op", "false", "Source", "Op", "Listener", ActivitySourceScopes.Global, false)] + [InlineData("Listener:EnabledLocalTracing:Default", "true", null, null, "Listener", ActivitySourceScopes.Local, true)] + [InlineData("Listener:EnabledLocalTracing:Source", "false", "Source", null, "Listener", ActivitySourceScopes.Local, false)] + [InlineData("Listener:EnabledLocalTracing:Source:Default", "true", "Source", null, "Listener", ActivitySourceScopes.Local, true)] + [InlineData("Listener:EnabledLocalTracing:Source:Op", "false", "Source", "Op", "Listener", ActivitySourceScopes.Local, false)] + public void TopLevelKeyMapsToExpectedRule(string key, string value, string? sourceName, string? operationName, string? listenerName, ActivitySourceScopes scopes, bool enabled) + { + var options = new TracingOptions(); + var configuration = new ConfigurationBuilder().AddInMemoryCollection().Build(); + configuration[key] = value; + + new TracingConfigureOptions(configuration).Configure(options); + + var rule = Assert.Single(options.Rules); + AssertRule(rule, sourceName, operationName, listenerName, scopes, enabled); + } + + [Fact] + public void TopLevelSectionKeysAreCaseInsensitive() + { + var options = new TracingOptions(); + var configuration = new ConfigurationBuilder().AddInMemoryCollection().Build(); + configuration["enabledtracing:source"] = "true"; + configuration["ENABLEDGLOBALTRACING:source"] = "false"; + + new TracingConfigureOptions(configuration).Configure(options); + + Assert.Equal(2, options.Rules.Count); + AssertRule(options.Rules.Single(r => r.Scopes == (ActivitySourceScopes.Global | ActivitySourceScopes.Local)), + "source", null, null, ActivitySourceScopes.Global | ActivitySourceScopes.Local, true); + AssertRule(options.Rules.Single(r => r.Scopes == ActivitySourceScopes.Global), + "source", null, null, ActivitySourceScopes.Global, false); + } + + [Fact] + public void NonBooleanLeafValueIsIgnored() + { + var options = new TracingOptions(); + var configuration = new ConfigurationBuilder().AddInMemoryCollection().Build(); + configuration["EnabledTracing:Source"] = "not-a-bool"; + + new TracingConfigureOptions(configuration).Configure(options); + + Assert.Empty(options.Rules); + } + + [Fact] + public void ListenerSectionWithoutKnownSubSectionAddsNoRules() + { + var options = new TracingOptions(); + var configuration = new ConfigurationBuilder().AddInMemoryCollection().Build(); + configuration["Listener:Unrelated:Source"] = "true"; + + new TracingConfigureOptions(configuration).Configure(options); + + Assert.Empty(options.Rules); + } + + [Fact] + public void ConstructorThrowsOnNullConfiguration() + { + Assert.Throws(() => new TracingConfigureOptions(null!)); + } + + private static void AssertRule(TracingRule rule, string? sourceName, string? operationName, string? listenerName, ActivitySourceScopes scopes, bool enable) + { + Assert.Equal(sourceName, rule.SourceName); + Assert.Equal(operationName, rule.OperationName); + Assert.Equal(listenerName, rule.ListenerName); + Assert.Equal(scopes, rule.Scopes); + Assert.Equal(enable, rule.Enable); + } + } +} diff --git a/src/libraries/System.Diagnostics.DiagnosticSource/ref/System.Diagnostics.DiagnosticSourceActivity.cs b/src/libraries/System.Diagnostics.DiagnosticSource/ref/System.Diagnostics.DiagnosticSourceActivity.cs index 10a4af3bda126e..6428e74468c560 100644 --- a/src/libraries/System.Diagnostics.DiagnosticSource/ref/System.Diagnostics.DiagnosticSourceActivity.cs +++ b/src/libraries/System.Diagnostics.DiagnosticSource/ref/System.Diagnostics.DiagnosticSourceActivity.cs @@ -150,7 +150,7 @@ public void CopyTo(System.Span destination) { } public string ToHexString() { throw null; } public override string ToString() { throw null; } } - public sealed class ActivitySource : IDisposable + public class ActivitySource : IDisposable { public ActivitySource(string name) { throw null; } [System.ComponentModel.EditorBrowsable(System.ComponentModel.EditorBrowsableState.Never)] @@ -159,6 +159,7 @@ public sealed class ActivitySource : IDisposable public ActivitySource(ActivitySourceOptions options) { throw null; } public string Name { get { throw null; } } public string? Version { get { throw null; } } + public object? Scope { get { throw null; } } public string? TelemetrySchemaUrl { get; } public System.Collections.Generic.IEnumerable>? Tags { get { throw null; } } public bool HasListeners() { throw null; } @@ -171,6 +172,7 @@ public sealed class ActivitySource : IDisposable public System.Diagnostics.Activity? StartActivity(System.Diagnostics.ActivityKind kind, System.Diagnostics.ActivityContext parentContext = default, System.Collections.Generic.IEnumerable>? tags = null, System.Collections.Generic.IEnumerable? links = null, DateTimeOffset startTime = default, [System.Runtime.CompilerServices.CallerMemberName] string name = "") { throw null; } public static void AddActivityListener(System.Diagnostics.ActivityListener listener) { throw null; } public void Dispose() { throw null; } + protected virtual void Dispose(bool disposing) { throw null; } } public class ActivitySourceOptions { @@ -178,8 +180,18 @@ public class ActivitySourceOptions public string Name { get { throw null; } set { } } public string? Version { get { throw null; } set { } } public System.Collections.Generic.IEnumerable>? Tags { get { throw null; } set { } } + public object? Scope { get { throw null; } set { } } public string? TelemetrySchemaUrl { get { throw null; } set { } } } + public abstract partial class ActivitySourceFactory : System.IDisposable + { + protected ActivitySourceFactory() { } + public System.Diagnostics.ActivitySource Create(System.Diagnostics.ActivitySourceOptions options) { throw null; } + public System.Diagnostics.ActivitySource Create(string name, string? version = "", System.Collections.Generic.IEnumerable>? tags = null) { throw null; } + protected abstract System.Diagnostics.ActivitySource CreateCore(System.Diagnostics.ActivitySourceOptions options); + public void Dispose() { } + protected virtual void Dispose(bool disposing) { } + } [System.FlagsAttribute] public enum ActivityTraceFlags { @@ -299,6 +311,7 @@ public sealed class ActivityListener : IDisposable public System.Func? ShouldListenTo { get { throw null; } set { } } public System.Diagnostics.SampleActivity? SampleUsingParentId { get { throw null; } set { } } public System.Diagnostics.SampleActivity? Sample { get { throw null; } set { } } + public void RefreshSources() { throw null; } public void Dispose() { throw null; } } public abstract class DistributedContextPropagator diff --git a/src/libraries/System.Diagnostics.DiagnosticSource/src/Resources/Strings.resx b/src/libraries/System.Diagnostics.DiagnosticSource/src/Resources/Strings.resx index 207040c39e8730..6f97d51610b393 100644 --- a/src/libraries/System.Diagnostics.DiagnosticSource/src/Resources/Strings.resx +++ b/src/libraries/System.Diagnostics.DiagnosticSource/src/Resources/Strings.resx @@ -120,6 +120,9 @@ "Value must be a valid ActivityIdFormat value" + + The activity source factory does not allow a custom scope value when creating an activity source. + Trying to set an Activity that is not running @@ -183,4 +186,7 @@ Invalid Max buckets value {0}. Max buckets must be greater than or equal to {1}. + + One or more 'ShouldListenTo' callbacks threw while refreshing the listener's source filters. See InnerExceptions for details. + diff --git a/src/libraries/System.Diagnostics.DiagnosticSource/src/System.Diagnostics.DiagnosticSource.csproj b/src/libraries/System.Diagnostics.DiagnosticSource/src/System.Diagnostics.DiagnosticSource.csproj index ecdb05dc3eb4ff..8060e1d09ffa34 100644 --- a/src/libraries/System.Diagnostics.DiagnosticSource/src/System.Diagnostics.DiagnosticSource.csproj +++ b/src/libraries/System.Diagnostics.DiagnosticSource/src/System.Diagnostics.DiagnosticSource.csproj @@ -41,6 +41,7 @@ System.Diagnostics.DiagnosticSource + diff --git a/src/libraries/System.Diagnostics.DiagnosticSource/src/System/Diagnostics/ActivityListener.cs b/src/libraries/System.Diagnostics.DiagnosticSource/src/System/Diagnostics/ActivityListener.cs index 1ebf2699d18fa7..ae281ce19d4ca0 100644 --- a/src/libraries/System.Diagnostics.DiagnosticSource/src/System/Diagnostics/ActivityListener.cs +++ b/src/libraries/System.Diagnostics.DiagnosticSource/src/System/Diagnostics/ActivityListener.cs @@ -2,6 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.Collections.Generic; +using System.Threading; namespace System.Diagnostics { @@ -20,6 +21,8 @@ namespace System.Diagnostics /// public sealed class ActivityListener : IDisposable { + private bool _disposed; + /// /// Construct a new object to start listening to the events. /// @@ -57,9 +60,45 @@ public ActivityListener() /// public SampleActivity? Sample { get; set; } + internal bool IsDisposed => Volatile.Read(ref _disposed); + + /// + /// Re-evaluates against every registered , attaching this + /// listener to sources that now match and detaching from sources that no longer match. Call this after mutating + /// or any state captured by its callback (for example, when configuration changes + /// alter the rules used by the predicate). If the listener has not yet been registered, it is registered as part + /// of the refresh; calling this on a disposed listener has no effect, including when the disposal races with + /// the refresh. + /// + /// If throws while evaluating exactly one source, that + /// exception is rethrown unchanged after the refresh completes for every other source. If it throws for more + /// than one source, the throws are wrapped in an . Sources whose evaluation + /// threw are left in their previous attachment state; sources whose evaluation succeeded are updated. + public void RefreshSources() + { + if (Volatile.Read(ref _disposed)) + { + return; + } + + ActivitySource.ResetSourceFilters(this); + } + /// /// Dispose will unregister this object from listening to events. /// - public void Dispose() => ActivitySource.DetachListener(this); + public void Dispose() + { + if (Volatile.Read(ref _disposed)) + { + return; + } + + // The flag must be published before the cleanup walks so that a concurrent + // RefreshSources, AddActivityListener, or ActivitySource ctor observes IsDisposed + // via its post-commit recheck and undoes any attachments it raced into place. + Volatile.Write(ref _disposed, true); + ActivitySource.DetachListener(this); + } } } diff --git a/src/libraries/System.Diagnostics.DiagnosticSource/src/System/Diagnostics/ActivitySource.cs b/src/libraries/System.Diagnostics.DiagnosticSource/src/System/Diagnostics/ActivitySource.cs index d229ad2bd24348..f5ca608d2ae976 100644 --- a/src/libraries/System.Diagnostics.DiagnosticSource/src/System/Diagnostics/ActivitySource.cs +++ b/src/libraries/System.Diagnostics.DiagnosticSource/src/System/Diagnostics/ActivitySource.cs @@ -4,22 +4,24 @@ using System.Collections.Generic; using System.ComponentModel; using System.Runtime.CompilerServices; +using System.Runtime.ExceptionServices; using System.Threading; namespace System.Diagnostics { [DebuggerDisplay("Name = {Name}")] - public sealed class ActivitySource : IDisposable + public class ActivitySource : IDisposable { private static readonly SynchronizedList s_activeSources = new SynchronizedList(); private static readonly SynchronizedList s_allListeners = new SynchronizedList(); + private static readonly SynchronizedList s_disposedListeners = new SynchronizedList(); private SynchronizedList? _listeners; /// /// Construct an ActivitySource object with the input name /// /// The name of the ActivitySource object - public ActivitySource(string name) : this(name, version: "", tags: null, telemetrySchemaUrl: null) {} + public ActivitySource(string name) : this(name, version: "", tags: null, scope: null, telemetrySchemaUrl: null) {} /// /// Construct an ActivitySource object with the input name @@ -27,7 +29,7 @@ public ActivitySource(string name) : this(name, version: "", tags: null, telemet /// The name of the ActivitySource object /// The version of the component publishing the tracing info. [EditorBrowsable(EditorBrowsableState.Never)] - public ActivitySource(string name, string? version = "") : this(name, version, tags: null, telemetrySchemaUrl: null) {} + public ActivitySource(string name, string? version = "") : this(name, version, tags: null, scope: null, telemetrySchemaUrl: null) {} /// /// Construct an ActivitySource object with the input name @@ -35,18 +37,19 @@ public ActivitySource(string name, string? version = "") : this(name, version, t /// The name of the ActivitySource object /// The version of the component publishing the tracing info. /// The optional ActivitySource tags. - public ActivitySource(string name, string? version = "", IEnumerable>? tags = default) : this(name, version, tags, telemetrySchemaUrl: null) {} + public ActivitySource(string name, string? version = "", IEnumerable>? tags = default) : this(name, version, tags, scope: null, telemetrySchemaUrl: null) {} /// /// Initialize a new instance of the ActivitySource object using the . /// /// The object to use for initializing the ActivitySource object. - public ActivitySource(ActivitySourceOptions options) : this((options ?? throw new ArgumentNullException(nameof(options))).Name, options.Version, options.Tags, options.TelemetrySchemaUrl) {} + public ActivitySource(ActivitySourceOptions options) : this((options ?? throw new ArgumentNullException(nameof(options))).Name, options.Version, options.Tags, options.Scope, options.TelemetrySchemaUrl) {} - private ActivitySource(string name, string? version, IEnumerable>? tags, string? telemetrySchemaUrl) + private ActivitySource(string name, string? version, IEnumerable>? tags, object? scope, string? telemetrySchemaUrl) { Name = name ?? throw new ArgumentNullException(nameof(name)); Version = version; + Scope = scope; TelemetrySchemaUrl = telemetrySchemaUrl; // Sorting the tags to make sure the tags are always in the same order. @@ -73,6 +76,22 @@ private ActivitySource(string name, string? version, IEnumerable? listeners = Volatile.Read(ref _listeners); + if (listeners is not null && !ReferenceEquals(listeners, s_disposedListeners)) + { + listeners.EnumWithAction((listener, source) => + { + if (listener.IsDisposed) + { + ((ActivitySource)source).RemoveListener(listener); + } + }, this); + } + GC.KeepAlive(DiagnosticSourceEventSource.Log); } @@ -91,6 +110,11 @@ private ActivitySource(string name, string? version, IEnumerable public IEnumerable>? Tags { get; } + /// + /// Returns the ActivitySource scope object. + /// + public object? Scope { get; } + /// /// Returns the telemetry schema URL associated with the ActivitySource. /// @@ -105,7 +129,9 @@ private ActivitySource(string name, string? version, IEnumerable? listeners = _listeners; - return listeners != null && listeners.Count > 0; + return listeners != null + && !ReferenceEquals(listeners, s_disposedListeners) + && listeners.Count > 0; } /// @@ -204,13 +230,15 @@ public bool HasListeners() private Activity? CreateActivity(string name, ActivityKind kind, ActivityContext context, string? parentId, IEnumerable>? tags, IEnumerable? links, DateTimeOffset startTime, bool startIt = true, ActivityIdFormat idFormat = ActivityIdFormat.Unknown) { - // _listeners can get assigned to null in Dispose. + // _listeners can get assigned to the disposed sentinel in Dispose. SynchronizedList? listeners = _listeners; if (listeners == null || listeners.Count == 0) { return null; } + Debug.Assert(!ReferenceEquals(listeners, s_disposedListeners)); + Activity? activity = null; ActivityTagsCollection? samplerTags; string? traceState; @@ -337,8 +365,16 @@ public bool HasListeners() /// public void Dispose() { - _listeners = null; - s_activeSources.Remove(this); + Dispose(true); + GC.SuppressFinalize(this); + } + + protected virtual void Dispose(bool disposing) + { + if (Interlocked.Exchange(ref _listeners, s_disposedListeners) != s_disposedListeners) + { + s_activeSources.Remove(this); + } } /// @@ -358,6 +394,78 @@ public static void AddActivityListener(ActivityListener listener) source.AddListener((ActivityListener)obj); } }, listener); + + // If Dispose ran concurrently it may have walked past some sources before we + // attached to them, leaving the listener resurrected. Re-run the same cleanup + // Dispose performs; every per-list op is idempotent, so racing Dispose's walk + // per source is safe regardless of order. + if (listener.IsDisposed) + { + DetachListener(listener); + } + } + } + + /// + /// Resets source filters for the object to start or stop listening to the events based on the listener configuration. + /// + /// The instance whose configuration, in particular its callback, determines which instances it should listen to. + internal static void ResetSourceFilters(ActivityListener listener) + { + ArgumentNullException.ThrowIfNull(listener); + + // Register first so any ActivitySource constructed after this point sees us + // in s_allListeners and self-attaches via its own walk (see the ActivitySource + // ctor walking s_allListeners). Without this, a source created during the + // iteration below could be missed by both us and itself. + s_allListeners.AddIfNotExist(listener); + + List? predicateFailures = null; + s_activeSources.EnumWithAction((source, obj) => + { + var ls = (ActivityListener)obj; + bool listen; + try + { + listen = ls.ShouldListenTo?.Invoke(source) ?? false; + } + catch (Exception ex) + { + // Predicate threw for this source: leave its attachment state untouched + // (the result was inconclusive, so neither attach nor detach is correct) + // and continue with the remaining sources. We surface the throw(s) once + // the walk completes so the caller sees what went wrong. + (predicateFailures ??= new List()).Add(ex); + return; + } + + if (listen) + { + source.AddListener(ls); + } + else + { + source.RemoveListener(ls); + } + }, listener); + + // If Dispose ran concurrently it may have walked some sources before we + // attached to them, leaving the listener resurrected. Re-run the same + // cleanup Dispose performs; every per-list op is idempotent, so racing + // Dispose's walk per source is safe regardless of order. + if (listener.IsDisposed) + { + DetachListener(listener); + } + + if (predicateFailures is not null) + { + if (predicateFailures.Count == 1) + { + ExceptionDispatchInfo.Capture(predicateFailures[0]).Throw(); + } + + throw new AggregateException(SR.ActivityListener_RefreshSourceFilters_PredicateThrew, predicateFailures); } } @@ -365,28 +473,57 @@ public static void AddActivityListener(ActivityListener listener) internal void AddListener(ActivityListener listener) { - if (_listeners == null) + SynchronizedList? listeners = Volatile.Read(ref _listeners); + if (ReferenceEquals(listeners, s_disposedListeners)) + { + return; + } + + if (listeners is null) + { + SynchronizedList newListeners = new SynchronizedList(); + listeners = Interlocked.CompareExchange(ref _listeners, newListeners, null); + if (listeners is null) + { + newListeners.AddIfNotExist(listener); + return; + } + + if (ReferenceEquals(listeners, s_disposedListeners)) + { + return; + } + } + + listeners.AddIfNotExist(listener); + } + + internal void RemoveListener(ActivityListener listener) + { + SynchronizedList? listeners = Volatile.Read(ref _listeners); + if (listeners is null || ReferenceEquals(listeners, s_disposedListeners)) { - Interlocked.CompareExchange(ref _listeners, new SynchronizedList(), null); + return; } - _listeners.AddIfNotExist(listener); + listeners.Remove(listener); } internal static void DetachListener(ActivityListener listener) { s_allListeners.Remove(listener); - s_activeSources.EnumWithAction((source, obj) => source._listeners?.Remove((ActivityListener) obj), listener); + s_activeSources.EnumWithAction((source, obj) => source.RemoveListener((ActivityListener)obj), listener); } internal void NotifyActivityStart(Activity activity) { Debug.Assert(activity != null); - // _listeners can get assigned to null in Dispose. + // _listeners can get assigned to the disposed sentinel in Dispose. SynchronizedList? listeners = _listeners; if (listeners != null && listeners.Count > 0) { + Debug.Assert(!ReferenceEquals(listeners, s_disposedListeners)); listeners.EnumWithAction((listener, obj) => listener.ActivityStarted?.Invoke((Activity)obj), activity); } } @@ -395,10 +532,11 @@ internal void NotifyActivityStop(Activity activity) { Debug.Assert(activity != null); - // _listeners can get assigned to null in Dispose. + // _listeners can get assigned to the disposed sentinel in Dispose. SynchronizedList? listeners = _listeners; if (listeners != null && listeners.Count > 0) { + Debug.Assert(!ReferenceEquals(listeners, s_disposedListeners)); listeners.EnumWithAction((listener, obj) => listener.ActivityStopped?.Invoke((Activity)obj), activity); } } @@ -407,10 +545,11 @@ internal void NotifyActivityAddException(Activity activity, Exception exception, { Debug.Assert(activity != null); - // _listeners can get assigned to null in Dispose. + // _listeners can get assigned to the disposed sentinel in Dispose. SynchronizedList? listeners = _listeners; if (listeners != null && listeners.Count > 0) { + Debug.Assert(!ReferenceEquals(listeners, s_disposedListeners)); listeners.EnumWithExceptionNotification(activity, exception, ref tags); } } diff --git a/src/libraries/System.Diagnostics.DiagnosticSource/src/System/Diagnostics/ActivitySourceFactory.cs b/src/libraries/System.Diagnostics.DiagnosticSource/src/System/Diagnostics/ActivitySourceFactory.cs new file mode 100644 index 00000000000000..5defd7104b7c2d --- /dev/null +++ b/src/libraries/System.Diagnostics.DiagnosticSource/src/System/Diagnostics/ActivitySourceFactory.cs @@ -0,0 +1,101 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Collections.Generic; + +namespace System.Diagnostics +{ + /// + /// A factory for creating instances. + /// + /// + /// Activity source factories are responsible for creating and caching activity sources. Derived classes implement + /// to provide the actual creation logic; the framework invariants + /// (null and scope validation, scope assignment) are enforced by the base class. + /// + public abstract class ActivitySourceFactory : IDisposable + { + /// + /// Creates an using the supplied . + /// + /// The describing the activity source to create. + /// An configured with the supplied . + /// is . + /// is set to a value other than this factory. + /// + /// The base implementation validates , then constructs a fresh + /// copy with bound to this factory + /// and delegates construction to . The caller-supplied + /// instance is never mutated, so concurrent calls that share an options instance are + /// safe. + /// + public ActivitySource Create(ActivitySourceOptions options) + { + ArgumentNullException.ThrowIfNull(options); + + if (options.Scope is not null && !ReferenceEquals(options.Scope, this)) + { + throw new InvalidOperationException(SR.InvalidActivitySourceScope); + } + + ActivitySourceOptions scoped = new(options.Name) + { + Version = options.Version, + Tags = options.Tags, + TelemetrySchemaUrl = options.TelemetrySchemaUrl, + Scope = this, + }; + + return CreateCore(scoped); + } + + /// + /// Creates an with the specified , , and . + /// + /// The name of the . + /// The version of the . + /// The tags to associate with the . + /// An with the specified , , and . + public ActivitySource Create(string name, string? version = "", IEnumerable>? tags = null) + { + ActivitySourceOptions options = new(name) + { + Version = version, + Tags = tags, + }; + + return Create(options); + } + + /// + /// When overridden in a derived class, creates the for the supplied . + /// + /// The describing the activity source to create. + /// is guaranteed to be set to this factory. + /// An configured with the supplied . + /// + /// Derived classes implement this method to perform the actual creation (and optional caching) of the + /// . The supplied have already been validated and the + /// property has been set to this factory instance; derived classes + /// should forward the options to the constructor unchanged. + /// + protected abstract ActivitySource CreateCore(ActivitySourceOptions options); + + /// + /// Releases all resources used by the . + /// + public void Dispose() + { + Dispose(disposing: true); + GC.SuppressFinalize(this); + } + + /// + /// Releases the unmanaged resources used by the and optionally releases the managed resources. + /// + /// to release both managed and unmanaged resources; to release only unmanaged resources. + protected virtual void Dispose(bool disposing) + { + } + } +} diff --git a/src/libraries/System.Diagnostics.DiagnosticSource/src/System/Diagnostics/ActivitySourceOptions.cs b/src/libraries/System.Diagnostics.DiagnosticSource/src/System/Diagnostics/ActivitySourceOptions.cs index 28b49259ecf346..d03b393b5dcc9c 100644 --- a/src/libraries/System.Diagnostics.DiagnosticSource/src/System/Diagnostics/ActivitySourceOptions.cs +++ b/src/libraries/System.Diagnostics.DiagnosticSource/src/System/Diagnostics/ActivitySourceOptions.cs @@ -40,6 +40,12 @@ public string Name /// public IEnumerable>? Tags { get; set; } + /// + /// The optional opaque object to attach to the . The scope object can be attached + /// to multiple activity sources for scoping purposes. + /// + public object? Scope { get; set; } + /// /// The optional schema URL specifies a location of a Schema File that /// can be retrieved using HTTP or HTTPS protocol. diff --git a/src/libraries/System.Diagnostics.DiagnosticSource/tests/ActivitySourceTests.cs b/src/libraries/System.Diagnostics.DiagnosticSource/tests/ActivitySourceTests.cs index 5cba29522e1ea3..a09a0c44c09f88 100644 --- a/src/libraries/System.Diagnostics.DiagnosticSource/tests/ActivitySourceTests.cs +++ b/src/libraries/System.Diagnostics.DiagnosticSource/tests/ActivitySourceTests.cs @@ -84,6 +84,345 @@ public void TestConstruction() }).Dispose(); } + [ConditionalFact(typeof(RemoteExecutor), nameof(RemoteExecutor.IsSupported))] + public void TestRefreshSourcesUpdatesListenerState() + { + RemoteExecutor.Invoke(() => { + using ActivitySource source = new ActivitySource("ListenerUpdateSource"); + Assert.False(source.HasListeners()); + + int shouldListen = 1; + int startedCount = 0; + int stoppedCount = 0; + + using ActivityListener listener = new ActivityListener + { + ShouldListenTo = activitySource => Volatile.Read(ref shouldListen) != 0 && object.ReferenceEquals(source, activitySource), + ActivityStarted = _ => startedCount++, + ActivityStopped = _ => stoppedCount++, + Sample = (ref ActivityCreationOptions options) => ActivitySamplingResult.AllDataAndRecorded, + SampleUsingParentId = (ref ActivityCreationOptions options) => ActivitySamplingResult.AllDataAndRecorded, + }; + + Parallel.For(0, 16, _ => listener.RefreshSources()); + Assert.True(source.HasListeners()); + using (Activity? activity = source.StartActivity("enabled")) + { + Assert.NotNull(activity); + Assert.Equal(1, startedCount); + Assert.Equal(0, stoppedCount); + } + + Assert.Equal(1, startedCount); + Assert.Equal(1, stoppedCount); + + Volatile.Write(ref shouldListen, 0); + Parallel.For(0, 16, _ => listener.RefreshSources()); + Assert.False(source.HasListeners()); + Assert.Null(source.StartActivity("disabled")); + Assert.Equal(1, startedCount); + Assert.Equal(1, stoppedCount); + }).Dispose(); + } + + [ConditionalFact(typeof(RemoteExecutor), nameof(RemoteExecutor.IsSupported))] + public void TestRefreshSourcesOnDisposedListenerIsNoOp() + { + RemoteExecutor.Invoke(() => { + using ActivitySource source = new ActivitySource("RefreshAfterDisposeSource"); + + ActivityListener listener = new ActivityListener + { + ShouldListenTo = activitySource => object.ReferenceEquals(source, activitySource), + Sample = (ref ActivityCreationOptions options) => ActivitySamplingResult.AllDataAndRecorded, + SampleUsingParentId = (ref ActivityCreationOptions options) => ActivitySamplingResult.AllDataAndRecorded, + }; + + listener.RefreshSources(); + Assert.True(source.HasListeners()); + + listener.Dispose(); + Assert.False(source.HasListeners()); + + listener.RefreshSources(); + Assert.False(source.HasListeners()); + Assert.Null(source.StartActivity("after-dispose")); + }).Dispose(); + } + + [ConditionalFact(typeof(RemoteExecutor), nameof(RemoteExecutor.IsSupported))] + public void TestDisposedSourceCannotBeResubscribed() + { + RemoteExecutor.Invoke(() => { + using (ActivitySource source = new ActivitySource("DisposeRaceSource_AddActivityListener")) + using (ActivityListener listener = new ActivityListener()) + { + listener.ShouldListenTo = activitySource => + { + if (object.ReferenceEquals(source, activitySource)) + { + source.Dispose(); + return true; + } + + return false; + }; + listener.SampleUsingParentId = (ref ActivityCreationOptions options) => ActivitySamplingResult.AllDataAndRecorded; + listener.Sample = (ref ActivityCreationOptions options) => ActivitySamplingResult.AllDataAndRecorded; + + ActivitySource.AddActivityListener(listener); + + Assert.False(source.HasListeners()); + Assert.Null(source.StartActivity("disposed")); + } + + using (ActivitySource source = new ActivitySource("DisposeRaceSource_RefreshSources")) + using (ActivityListener listener = new ActivityListener()) + { + listener.ShouldListenTo = activitySource => + { + if (object.ReferenceEquals(source, activitySource)) + { + source.Dispose(); + return true; + } + + return false; + }; + listener.SampleUsingParentId = (ref ActivityCreationOptions options) => ActivitySamplingResult.AllDataAndRecorded; + listener.Sample = (ref ActivityCreationOptions options) => ActivitySamplingResult.AllDataAndRecorded; + + listener.RefreshSources(); + + Assert.False(source.HasListeners()); + Assert.Null(source.StartActivity("disposed")); + } + }).Dispose(); + } + + [ConditionalFact(typeof(RemoteExecutor), nameof(RemoteExecutor.IsSupported))] + public void TestRefreshSourcesLosesRaceWithConcurrentDispose() + { + RemoteExecutor.Invoke(() => + { + using ActivitySource source = new ActivitySource("RefreshDisposeRaceSource"); + + using ManualResetEventSlim insideShouldListenTo = new ManualResetEventSlim(); + using ManualResetEventSlim disposeFinished = new ManualResetEventSlim(); + + ActivityListener listener = new ActivityListener + { + ShouldListenTo = activitySource => + { + if (ReferenceEquals(source, activitySource)) + { + insideShouldListenTo.Set(); + // Block phase 1 until the main thread has fully disposed the listener. + disposeFinished.Wait(); + return true; + } + return false; + }, + Sample = (ref ActivityCreationOptions options) => ActivitySamplingResult.AllDataAndRecorded, + SampleUsingParentId = (ref ActivityCreationOptions options) => ActivitySamplingResult.AllDataAndRecorded, + }; + + Task refresher = Task.Run(() => listener.RefreshSources()); + + insideShouldListenTo.Wait(); + listener.Dispose(); + disposeFinished.Set(); + + refresher.Wait(); + + Assert.False(source.HasListeners()); + Assert.Null(source.StartActivity("after-dispose-during-refresh")); + }).Dispose(); + } + + [ConditionalFact(typeof(RemoteExecutor), nameof(RemoteExecutor.IsSupported))] + public void TestAddActivityListenerLosesRaceWithConcurrentDispose() + { + RemoteExecutor.Invoke(() => + { + using ActivitySource source = new ActivitySource("AddListenerDisposeRaceSource"); + + using ManualResetEventSlim insideShouldListenTo = new ManualResetEventSlim(); + using ManualResetEventSlim disposeFinished = new ManualResetEventSlim(); + + ActivityListener listener = new ActivityListener + { + ShouldListenTo = activitySource => + { + if (ReferenceEquals(source, activitySource)) + { + insideShouldListenTo.Set(); + disposeFinished.Wait(); + return true; + } + return false; + }, + Sample = (ref ActivityCreationOptions options) => ActivitySamplingResult.AllDataAndRecorded, + SampleUsingParentId = (ref ActivityCreationOptions options) => ActivitySamplingResult.AllDataAndRecorded, + }; + + Task adder = Task.Run(() => ActivitySource.AddActivityListener(listener)); + + insideShouldListenTo.Wait(); + listener.Dispose(); + disposeFinished.Set(); + + adder.Wait(); + + Assert.False(source.HasListeners()); + Assert.Null(source.StartActivity("after-dispose-during-add")); + }).Dispose(); + } + + [ConditionalFact(typeof(RemoteExecutor), nameof(RemoteExecutor.IsSupported))] + public void TestActivitySourceCtorLosesRaceWithConcurrentListenerDispose() + { + RemoteExecutor.Invoke(() => + { + using ManualResetEventSlim insideShouldListenTo = new ManualResetEventSlim(); + using ManualResetEventSlim disposeFinished = new ManualResetEventSlim(); + + ActivityListener listener = new ActivityListener + { + ShouldListenTo = activitySource => + { + if (activitySource.Name == "CtorDisposeRaceSource") + { + insideShouldListenTo.Set(); + disposeFinished.Wait(); + return true; + } + return false; + }, + Sample = (ref ActivityCreationOptions options) => ActivitySamplingResult.AllDataAndRecorded, + SampleUsingParentId = (ref ActivityCreationOptions options) => ActivitySamplingResult.AllDataAndRecorded, + }; + + ActivitySource.AddActivityListener(listener); + + ActivitySource? source = null; + Task ctor = Task.Run(() => source = new ActivitySource("CtorDisposeRaceSource")); + + insideShouldListenTo.Wait(); + listener.Dispose(); + disposeFinished.Set(); + + ctor.Wait(); + + Assert.NotNull(source); + Assert.False(source!.HasListeners()); + Assert.Null(source.StartActivity("after-dispose-during-ctor")); + + source.Dispose(); + }).Dispose(); + } + + [ConditionalFact(typeof(RemoteExecutor), nameof(RemoteExecutor.IsSupported))] + public void TestRefreshSourcesRethrowsSinglePredicateThrowAfterCompletingWalk() + { + RemoteExecutor.Invoke(() => + { + using ActivitySource throwingSource = new ActivitySource("RefreshThrowing.Single.Throwing"); + using ActivitySource matchedSource = new ActivitySource("RefreshThrowing.Single.Matched"); + + using ActivityListener listener = new ActivityListener + { + ShouldListenTo = src => + { + if (src.Name == "RefreshThrowing.Single.Throwing") + { + throw new InvalidOperationException("boom"); + } + return src.Name == "RefreshThrowing.Single.Matched"; + }, + Sample = (ref ActivityCreationOptions options) => ActivitySamplingResult.AllDataAndRecorded, + SampleUsingParentId = (ref ActivityCreationOptions options) => ActivitySamplingResult.AllDataAndRecorded, + }; + + InvalidOperationException ex = Assert.Throws(() => listener.RefreshSources()); + Assert.Equal("boom", ex.Message); + + // The throw must not abort the iteration: the non-throwing source still got attached. + Assert.True(matchedSource.HasListeners()); + Assert.False(throwingSource.HasListeners()); + }).Dispose(); + } + + [ConditionalFact(typeof(RemoteExecutor), nameof(RemoteExecutor.IsSupported))] + public void TestRefreshSourcesAggregatesMultiplePredicateThrows() + { + RemoteExecutor.Invoke(() => + { + using ActivitySource throwingA = new ActivitySource("RefreshThrowing.Aggregate.A"); + using ActivitySource throwingB = new ActivitySource("RefreshThrowing.Aggregate.B"); + using ActivitySource matchedSource = new ActivitySource("RefreshThrowing.Aggregate.Matched"); + + using ActivityListener listener = new ActivityListener + { + ShouldListenTo = src => src.Name switch + { + "RefreshThrowing.Aggregate.A" => throw new InvalidOperationException("boom-A"), + "RefreshThrowing.Aggregate.B" => throw new ArgumentException("boom-B"), + "RefreshThrowing.Aggregate.Matched" => true, + _ => false, + }, + Sample = (ref ActivityCreationOptions options) => ActivitySamplingResult.AllDataAndRecorded, + SampleUsingParentId = (ref ActivityCreationOptions options) => ActivitySamplingResult.AllDataAndRecorded, + }; + + AggregateException ex = Assert.Throws(() => listener.RefreshSources()); + Assert.Equal(2, ex.InnerExceptions.Count); + Assert.Contains(ex.InnerExceptions, e => e is InvalidOperationException { Message: "boom-A" }); + Assert.Contains(ex.InnerExceptions, e => e is ArgumentException { Message: "boom-B" }); + + Assert.True(matchedSource.HasListeners()); + Assert.False(throwingA.HasListeners()); + Assert.False(throwingB.HasListeners()); + }).Dispose(); + } + + [ConditionalFact(typeof(RemoteExecutor), nameof(RemoteExecutor.IsSupported))] + public void TestRefreshSourcesPredicateThrowDoesNotDetachPriorAttachment() + { + RemoteExecutor.Invoke(() => + { + using ActivitySource source = new ActivitySource("RefreshThrowing.NoDetach.Source"); + + bool throwOnNextEvaluation = false; + using ActivityListener listener = new ActivityListener + { + ShouldListenTo = src => + { + if (src.Name != "RefreshThrowing.NoDetach.Source") + { + return false; + } + if (Volatile.Read(ref throwOnNextEvaluation)) + { + throw new InvalidOperationException("boom-after-attach"); + } + return true; + }, + Sample = (ref ActivityCreationOptions options) => ActivitySamplingResult.AllDataAndRecorded, + SampleUsingParentId = (ref ActivityCreationOptions options) => ActivitySamplingResult.AllDataAndRecorded, + }; + + listener.RefreshSources(); + Assert.True(source.HasListeners()); + + Volatile.Write(ref throwOnNextEvaluation, true); + Assert.Throws(() => listener.RefreshSources()); + + // The throw left the source's attachment state alone, so the listener is still attached. + Assert.True(source.HasListeners()); + }).Dispose(); + } + [ConditionalFact(typeof(RemoteExecutor), nameof(RemoteExecutor.IsSupported))] public void TestStartActivityWithNoListener() { @@ -1593,6 +1932,72 @@ public void TestIdFormats(string data) }, data).Dispose(); } + [ConditionalFact(typeof(RemoteExecutor), nameof(RemoteExecutor.IsSupported))] + public void TestActivitySourceFactoryCreate_VersionParameterDefaultsAndExplicitNullArePreserved() + { + RemoteExecutor.Invoke(() => + { + using TestActivitySourceFactory factory = new TestActivitySourceFactory(); + + using ActivitySource defaultVersion = factory.Create("Versioning.Source"); + using ActivitySource explicitEmpty = factory.Create("Versioning.Source", version: ""); + using ActivitySource explicitNull = factory.Create("Versioning.Source", version: null); + using ActivitySource explicitValue = factory.Create("Versioning.Source", version: "1.0"); + + // The default for the version parameter matches the direct ActivitySource(string) ctor convention: "". + Assert.Equal(string.Empty, defaultVersion.Version); + Assert.Equal(string.Empty, explicitEmpty.Version); + + // Explicit null must be preserved (not collapsed to ""), so callers can dedup against ActivitySourceOptions { Version = null }. + Assert.Null(explicitNull.Version); + + Assert.Equal("1.0", explicitValue.Version); + + using ActivitySource baselineDirect = new ActivitySource("Versioning.Source"); + Assert.Equal(baselineDirect.Version, defaultVersion.Version); + }).Dispose(); + } + + [ConditionalFact(typeof(RemoteExecutor), nameof(RemoteExecutor.IsSupported))] + public void TestActivitySourceFactoryCreate_DoesNotMutateSharedOptions_UnderConcurrentCalls() + { + RemoteExecutor.Invoke(() => + { + using TestActivitySourceFactory factory = new TestActivitySourceFactory(); + IEnumerable> originalTags = new[] { new KeyValuePair("k", "v") }; + ActivitySourceOptions sharedOptions = new ActivitySourceOptions("Shared.ConcurrentSource") + { + Version = "1.0", + Tags = originalTags, + TelemetrySchemaUrl = "https://schema.test/concurrent", + }; + + Assert.Null(sharedOptions.Scope); + + Parallel.For(0, 2000, _ => + { + using ActivitySource source = factory.Create(sharedOptions); + Assert.Same(factory, source.Scope); + }); + + Assert.Equal("Shared.ConcurrentSource", sharedOptions.Name); + Assert.Equal("1.0", sharedOptions.Version); + Assert.Same(originalTags, sharedOptions.Tags); + Assert.Equal("https://schema.test/concurrent", sharedOptions.TelemetrySchemaUrl); + Assert.Null(sharedOptions.Scope); + }).Dispose(); + } + + private sealed class TestActivitySourceFactory : ActivitySourceFactory + { + protected override ActivitySource CreateCore(ActivitySourceOptions options) + { + Assert.NotNull(options); + Assert.Same(this, options.Scope); + return new ActivitySource(options); + } + } + public void Dispose() => Activity.Current = null; } } From 05d776948d7114f17b329d624018b6ff836f0480 Mon Sep 17 00:00:00 2001 From: rosebyte Date: Sun, 14 Jun 2026 16:46:30 +0200 Subject: [PATCH 2/2] add timeouts --- .../tests/ActivitySourceTests.cs | 21 +++++++++++-------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/src/libraries/System.Diagnostics.DiagnosticSource/tests/ActivitySourceTests.cs b/src/libraries/System.Diagnostics.DiagnosticSource/tests/ActivitySourceTests.cs index a09a0c44c09f88..ce501189bb3d53 100644 --- a/src/libraries/System.Diagnostics.DiagnosticSource/tests/ActivitySourceTests.cs +++ b/src/libraries/System.Diagnostics.DiagnosticSource/tests/ActivitySourceTests.cs @@ -205,6 +205,7 @@ public void TestRefreshSourcesLosesRaceWithConcurrentDispose() { RemoteExecutor.Invoke(() => { + TimeSpan timeout = TimeSpan.FromSeconds(30); using ActivitySource source = new ActivitySource("RefreshDisposeRaceSource"); using ManualResetEventSlim insideShouldListenTo = new ManualResetEventSlim(); @@ -218,7 +219,7 @@ public void TestRefreshSourcesLosesRaceWithConcurrentDispose() { insideShouldListenTo.Set(); // Block phase 1 until the main thread has fully disposed the listener. - disposeFinished.Wait(); + Assert.True(disposeFinished.Wait(timeout), "Predicate timed out waiting for main thread to dispose the listener."); return true; } return false; @@ -229,11 +230,11 @@ public void TestRefreshSourcesLosesRaceWithConcurrentDispose() Task refresher = Task.Run(() => listener.RefreshSources()); - insideShouldListenTo.Wait(); + Assert.True(insideShouldListenTo.Wait(timeout), "Timed out waiting for ShouldListenTo to be entered."); listener.Dispose(); disposeFinished.Set(); - refresher.Wait(); + Assert.True(refresher.Wait(timeout), "RefreshSources task did not complete within timeout."); Assert.False(source.HasListeners()); Assert.Null(source.StartActivity("after-dispose-during-refresh")); @@ -245,6 +246,7 @@ public void TestAddActivityListenerLosesRaceWithConcurrentDispose() { RemoteExecutor.Invoke(() => { + TimeSpan timeout = TimeSpan.FromSeconds(30); using ActivitySource source = new ActivitySource("AddListenerDisposeRaceSource"); using ManualResetEventSlim insideShouldListenTo = new ManualResetEventSlim(); @@ -257,7 +259,7 @@ public void TestAddActivityListenerLosesRaceWithConcurrentDispose() if (ReferenceEquals(source, activitySource)) { insideShouldListenTo.Set(); - disposeFinished.Wait(); + Assert.True(disposeFinished.Wait(timeout), "Predicate timed out waiting for main thread to dispose the listener."); return true; } return false; @@ -268,11 +270,11 @@ public void TestAddActivityListenerLosesRaceWithConcurrentDispose() Task adder = Task.Run(() => ActivitySource.AddActivityListener(listener)); - insideShouldListenTo.Wait(); + Assert.True(insideShouldListenTo.Wait(timeout), "Timed out waiting for ShouldListenTo to be entered."); listener.Dispose(); disposeFinished.Set(); - adder.Wait(); + Assert.True(adder.Wait(timeout), "AddActivityListener task did not complete within timeout."); Assert.False(source.HasListeners()); Assert.Null(source.StartActivity("after-dispose-during-add")); @@ -284,6 +286,7 @@ public void TestActivitySourceCtorLosesRaceWithConcurrentListenerDispose() { RemoteExecutor.Invoke(() => { + TimeSpan timeout = TimeSpan.FromSeconds(30); using ManualResetEventSlim insideShouldListenTo = new ManualResetEventSlim(); using ManualResetEventSlim disposeFinished = new ManualResetEventSlim(); @@ -294,7 +297,7 @@ public void TestActivitySourceCtorLosesRaceWithConcurrentListenerDispose() if (activitySource.Name == "CtorDisposeRaceSource") { insideShouldListenTo.Set(); - disposeFinished.Wait(); + Assert.True(disposeFinished.Wait(timeout), "Predicate timed out waiting for main thread to dispose the listener."); return true; } return false; @@ -308,11 +311,11 @@ public void TestActivitySourceCtorLosesRaceWithConcurrentListenerDispose() ActivitySource? source = null; Task ctor = Task.Run(() => source = new ActivitySource("CtorDisposeRaceSource")); - insideShouldListenTo.Wait(); + Assert.True(insideShouldListenTo.Wait(timeout), "Timed out waiting for ShouldListenTo to be entered."); listener.Dispose(); disposeFinished.Set(); - ctor.Wait(); + Assert.True(ctor.Wait(timeout), "ActivitySource constructor task did not complete within timeout."); Assert.NotNull(source); Assert.False(source!.HasListeners());