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..2d2b9099d091d7 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,41 @@ 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 static partial class TracingBuilderExtensions + { + public static ITracingBuilder AddListener(this ITracingBuilder builder, Func factory) { 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/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..6b817ef0a441a9 --- /dev/null +++ b/src/libraries/Microsoft.Extensions.Diagnostics.Abstractions/src/Tracing/TracingBuilderExtensions.Listeners.cs @@ -0,0 +1,51 @@ +// 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 using the supplied factory to materialise the instance from the service provider. + /// + /// The . + /// A factory function that produces the listener instance. + /// Returns the original for chaining. + /// + /// The tracing builder takes ownership of the listener returned by + /// and wraps it to apply the rules described by the bound . Callers + /// should not retain a reference to the returned instance, dispose it directly, or call + /// on it: only the wrapper is registered with + /// , so calling on + /// the user-supplied instance has no effect. The builder re-evaluates the listener + /// automatically when change. + /// + public static ITracingBuilder AddListener(this ITracingBuilder builder, Func factory) + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentNullException.ThrowIfNull(factory); + builder.Services.AddSingleton(factory); + 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..a17bfcd1c08b83 --- /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 or prefix. A null 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 or prefix. A null 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 or prefix. A null 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 or prefix. A null 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..c7746e041ddd53 --- /dev/null +++ b/src/libraries/Microsoft.Extensions.Diagnostics.Abstractions/src/Tracing/TracingRule.cs @@ -0,0 +1,94 @@ +// 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. + /// + 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..d06f79ed26c7ce 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.Diagnostics.Tracing.ITracingBuilder AddTracing(this Microsoft.Extensions.DependencyInjection.IServiceCollection services) { throw null; } + public static Microsoft.Extensions.Diagnostics.Tracing.ITracingBuilder AddTracing(this Microsoft.Extensions.DependencyInjection.IServiceCollection services, System.Action configure) { throw null; } + } } namespace Microsoft.Extensions.Diagnostics.Metrics { @@ -24,6 +29,13 @@ public static class MetricsBuilderConfigurationExtensions public static IMetricsBuilder AddConfiguration(this IMetricsBuilder builder, Microsoft.Extensions.Configuration.IConfiguration configuration) => throw null!; } } +namespace Microsoft.Extensions.Diagnostics.Tracing +{ + 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/Metrics/DefaultMeterFactory.cs b/src/libraries/Microsoft.Extensions.Diagnostics/src/Metrics/DefaultMeterFactory.cs index 0cde0d9b9277aa..9cc08a7b12e043 100644 --- a/src/libraries/Microsoft.Extensions.Diagnostics/src/Metrics/DefaultMeterFactory.cs +++ b/src/libraries/Microsoft.Extensions.Diagnostics/src/Metrics/DefaultMeterFactory.cs @@ -52,8 +52,15 @@ public Meter Create(MeterOptions options) object? scope = options.Scope; options.Scope = this; - FactoryMeter m = new FactoryMeter(options); - options.Scope = scope; + FactoryMeter m; + try + { + m = new FactoryMeter(options); + } + finally + { + options.Scope = scope; + } meterList.Add(m); return m; diff --git a/src/libraries/Microsoft.Extensions.Diagnostics/src/Resources/Strings.resx b/src/libraries/Microsoft.Extensions.Diagnostics/src/Resources/Strings.resx index 68a0e242ba0881..bb139a0a714e5f 100644 --- a/src/libraries/Microsoft.Extensions.Diagnostics/src/Resources/Strings.resx +++ b/src/libraries/Microsoft.Extensions.Diagnostics/src/Resources/Strings.resx @@ -123,4 +123,7 @@ Only one wildcard character is allowed in category name. - \ No newline at end of file + + One or more activity listener registrations threw while applying tracing configuration changes. + + 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..c43d4279b6d20f --- /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. + /// + /// + /// The configuration key shapes follow the metrics model, except tracing stops at the level and has no instrument-level child keys. + /// - Section names: EnabledTracing (both global and local), EnabledGlobalTracing, and EnabledLocalTracing, plus the listener-specific forms {ListenerName}:.... + /// - Within each section, supported entries are Default and . Unlike metrics, tracing does not support a nested {ActivitySourceName}:Default form because there is no level below the activity source. + /// - 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, 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)); + return builder; + } + } +} 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..91c9489f34ff5d --- /dev/null +++ b/src/libraries/Microsoft.Extensions.Diagnostics/src/Tracing/DefaultActivitySourceFactory.cs @@ -0,0 +1,665 @@ +// 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.Runtime.ExceptionServices; +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 listeners, IOptionsMonitor options) + { + ArgumentNullException.ThrowIfNull(listeners); + ArgumentNullException.ThrowIfNull(options); + + // Each ActivityListenerRegistration ctor attaches a wrapper ActivityListener globally + // (ActivitySource.AddActivityListener). If a later registration throws, the wrappers + // already attached would leak in s_allListeners/s_activeSources for the process lifetime + // because the partially-constructed factory never sees Dispose(). Materialise into a + // local list and dispose what we built before rethrowing. + TracingOptions initial = options.CurrentValue; + List registrations = new(); + try + { + foreach (ActivityListener listener in listeners) + { + registrations.Add(new ActivityListenerRegistration(listener, this, initial)); + } + + _listenerRegistrations = registrations.ToArray(); + // TracingOptions is a public type so other code may register named buckets via + // services.Configure("foo", ...). We only own the default bucket, + // so filter notifications to it. The standard OptionsMonitor pipeline normalises + // null -> Options.DefaultName ("") before invoking the listener, but the delegate + // signature permits null, so IsNullOrEmpty is the defensive form that covers both. + _changeTokenRegistration = options.OnChange((opts, name) => + { + if (string.IsNullOrEmpty(name)) + { + UpdateRules(opts); + } + }); + + // Reconcile: a configuration reload could have fired between the CurrentValue read + // used to bootstrap the registrations above and the OnChange subscription. Such a + // reload would not be delivered via the callback (we had no subscription yet) and + // would be permanently lost without this re-read. Re-applying the latest snapshot + // covers that window; if no reload happened, OptionsMonitor returns the same + // instance and the ReferenceEquals guard skips the redundant work. The standard + // sibling MetricsSubscriptionManager achieves the same property by subscribing + // first and then calling UpdateRules(CurrentValue) unconditionally. + TracingOptions current = options.CurrentValue; + if (!ReferenceEquals(current, initial)) + { + UpdateRules(current); + } + } + catch + { + _changeTokenRegistration?.Dispose(); + + foreach (ActivityListenerRegistration registration in registrations) + { + try + { + registration.Dispose(); + } + catch + { + // Suppress secondary failures during cleanup so the original construction + // exception is the one observed by the caller. + } + } + + 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)); + + // Phase 1: lookup under the cache lock. No user code runs here. + lock (_cachedSources) + { + if (_disposed) + { + throw new ObjectDisposedException(nameof(DefaultActivitySourceFactory)); + } + + if (TryGetCachedMatch(options, out ActivitySource? cached)) + { + return cached; + } + } + + // Phase 2: construct outside the cache lock. The base ActivitySource constructor + // walks ActivitySource.s_allListeners and synchronously invokes each listener's + // ShouldListenTo predicate, which forwards to user-supplied delegates. Holding + // _cachedSources across user code would create a lock-order inversion against any + // user lock taken before calling Create. + FactoryActivitySource newSource = new FactoryActivitySource(options); + + // Phase 3: re-acquire the cache lock and commit, handling the rare race where a + // concurrent Create with the same identity won, and a concurrent factory dispose. + 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. Discard our redundant + // instance; it was never published to any caller and Release tears down its + // BCL bookkeeping (s_activeSources entry, attached listeners). + newSource.Release(); + return winner; + } + + if (!_cachedSources.TryGetValue(options.Name, out List? sourceList)) + { + sourceList = new List(); + _cachedSources.Add(options.Name, sourceList); + } + + sourceList.Add(newSource); + return newSource; + } + } + + private bool TryGetCachedMatch(ActivitySourceOptions options, [NotNullWhen(true)] out ActivitySource? match) + { + Debug.Assert(Monitor.IsEntered(_cachedSources)); + + if (_cachedSources.TryGetValue(options.Name, out List? sourceList)) + { + foreach (FactoryActivitySource source in sourceList) + { + 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) + { + if (Volatile.Read(ref _disposed)) + { + return; + } + + List rules = options.Rules; + List? errors = null; + + foreach (ActivityListenerRegistration registration in _listenerRegistrations) + { + try + { + registration.UpdateRules(rules); + } + catch (Exception ex) + { + (errors ??= new List()).Add(ex); + } + } + + if (errors is null) + { + return; + } + + if (errors.Count == 1) + { + ExceptionDispatchInfo.Capture(errors[0]).Throw(); + } + + throw new AggregateException(SR.DefaultActivitySourceFactory_UpdateRules_RegistrationThrew, errors); + } + + 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 (var entry in _cachedSources) + { + foreach (var 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 ActivityListener _userListener; + private readonly ActivityListener _activityListener; + private ListenerState _state; + private bool _disposed; + + public ActivityListenerRegistration(ActivityListener listener, DefaultActivitySourceFactory activitySourceFactory, TracingOptions options) + { + ArgumentNullException.ThrowIfNull(listener); + _userListener = listener; + _activitySourceFactory = activitySourceFactory ?? throw new ArgumentNullException(nameof(activitySourceFactory)); + _listenerName = listener.Name; + _state = ListenerState.Create(options.Rules); + _activityListener = new ActivityListener(_listenerName) + { + ShouldListenTo = ShouldListenTo, + Sample = WrappedSample, + SampleUsingParentId = WrappedSampleUsingParentId, + ActivityStarted = WrappedActivityStarted, + ActivityStopped = WrappedActivityStopped, + ExceptionRecorder = WrappedExceptionRecorder, + }; + ActivitySource.AddActivityListener(_activityListener); + + // Dispose the user-supplied listener so that, if the caller kept a reference, any + // RefreshSources() they invoke on it short-circuits on the IsDisposed check and + // does not register their raw listener in s_allListeners in parallel with our + // wrapper. We never attached this listener via AddActivityListener, so Dispose + // has no other effect: the delegate properties (Sample, ActivityStarted, ...) + // remain readable, which is what our wrappers rely on for live lookup. + listener.Dispose(); + } + + public void Dispose() + { + lock (_lock) + { + if (_disposed) + { + return; + } + + _disposed = true; + _activityListener.Dispose(); + _state = ListenerState.Empty; + } + } + + public void UpdateRules(List rules) + { + ArgumentNullException.ThrowIfNull(rules); + + lock (_lock) + { + if (_disposed) + { + return; + } + + // Single atomic publication of rules + fresh per-source cache. Readers either + // see the old snapshot in full or the new one in full. + Volatile.Write(ref _state, ListenerState.Create(rules)); + } + + // RefreshSources walks ActivitySource.s_activeSources and synchronously invokes the + // wrapper's ShouldListenTo (which forwards to the user-supplied predicate) for every + // active source. Calling it under _lock would expose us to the same lock-order + // inversion that CreateCore avoids: a caller whose ShouldListenTo grabs a user lock + // could deadlock against another thread that holds that lock and triggers a reload. + // The call is safe to make without _lock: RefreshSources short-circuits once the + // listener is disposed, and concurrent UpdateRules calls converge because every + // wrapper reads _state via Volatile.Read on each invocation. + _activityListener.RefreshSources(); + } + + private ActivitySamplingResult WrappedSample(ref ActivityCreationOptions options) + { + ListenerState state = Volatile.Read(ref _state); + if (state.HasOperationNameRules && !IsEnabledFast(state, options.Source, options.Name)) + { + return ActivitySamplingResult.None; + } + + return _userListener.Sample?.Invoke(ref options) ?? ActivitySamplingResult.None; + } + + private ActivitySamplingResult WrappedSampleUsingParentId(ref ActivityCreationOptions options) + { + ListenerState state = Volatile.Read(ref _state); + if (state.HasOperationNameRules && !IsEnabledFast(state, options.Source, options.Name)) + { + return ActivitySamplingResult.None; + } + + return _userListener.SampleUsingParentId?.Invoke(ref options) ?? ActivitySamplingResult.None; + } + + private void WrappedActivityStarted(Activity activity) + { + ListenerState state = Volatile.Read(ref _state); + if (!state.HasOperationNameRules || IsEnabledFast(state, activity.Source, activity.OperationName)) + { + _userListener.ActivityStarted?.Invoke(activity); + } + } + + private void WrappedActivityStopped(Activity activity) + { + ListenerState state = Volatile.Read(ref _state); + if (!state.HasOperationNameRules || IsEnabledFast(state, activity.Source, activity.OperationName)) + { + _userListener.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)) + { + _userListener.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)) + { + // Cache miss is rare (race against UpdateRules clearing the dictionary). + // Compute on the fly without caching; the next ShouldListenTo for this source repopulates. + 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) + { + (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; + } + } + + bool rulesAllow = filter.DefaultEnabled || filter.Divergent is { Count: > 0 }; + if (!rulesAllow) + { + return false; + } + + return _userListener.ShouldListenTo?.Invoke(activitySource) ?? true; + } + + 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. Name and Scope are both + // immutable once an ActivitySource is constructed, so the key is stable. + 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..cb7465d8baccb5 --- /dev/null +++ b/src/libraries/Microsoft.Extensions.Diagnostics/src/Tracing/TracingServiceExtensions.cs @@ -0,0 +1,68 @@ +// 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 ITracingBuilder AddTracing(this IServiceCollection services) + { + ArgumentNullException.ThrowIfNull(services); + + services.AddOptions(); + + services.TryAddSingleton(); + services.AddOptions().ValidateOnStart(); + services.TryAddSingleton, SubscriptionActivator>(); + + return new TracingBuilder(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 ITracingBuilder AddTracing(this IServiceCollection services, Action configure) + { + ArgumentNullException.ThrowIfNull(services); + ArgumentNullException.ThrowIfNull(configure); + + var builder = services.AddTracing(); + configure(builder); + return builder; + } + + 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/DefaultMetricsFactoryTests.cs b/src/libraries/Microsoft.Extensions.Diagnostics/tests/DefaultMetricsFactoryTests.cs index f73b197136bd7e..8338c934b1fbab 100644 --- a/src/libraries/Microsoft.Extensions.Diagnostics/tests/DefaultMetricsFactoryTests.cs +++ b/src/libraries/Microsoft.Extensions.Diagnostics/tests/DefaultMetricsFactoryTests.cs @@ -2,6 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. using System; +using System.Collections; using System.Collections.Generic; using System.Diagnostics; using System.Diagnostics.Metrics; @@ -103,6 +104,24 @@ public void NegativeTest() Assert.Equal(meterFactory, meter.Scope); } + [Fact] + public void Create_RestoresScope_WhenMeterCreationThrows() + { + ServiceCollection services = new ServiceCollection(); + services.AddMetrics(); + var sp = services.BuildServiceProvider(); + using IMeterFactory meterFactory = sp.GetRequiredService(); + + MeterOptions options = new MeterOptions("name") + { + Tags = new ThrowingTagsEnumerable() + }; + + Assert.Null(options.Scope); + Assert.Throws(() => meterFactory.Create(options)); + Assert.Null(options.Scope); + } + [Fact] public void MeterDisposeTest() { @@ -182,5 +201,27 @@ public void Dispose() _meterList.Clear(); } } + + private sealed class ThrowingTagsEnumerable : IEnumerable> + { + public IEnumerator> GetEnumerator() => new ThrowingTagsEnumerator(); + + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); + + private sealed class ThrowingTagsEnumerator : IEnumerator> + { + public KeyValuePair Current => default; + + object IEnumerator.Current => Current; + + public bool MoveNext() => throw new InvalidOperationException("Test exception."); + + public void Dispose() + { + } + + public void Reset() => throw new NotSupportedException(); + } + } } } diff --git a/src/libraries/Microsoft.Extensions.Diagnostics/tests/TracingConfigurationSampleTests.cs b/src/libraries/Microsoft.Extensions.Diagnostics/tests/TracingConfigurationSampleTests.cs new file mode 100644 index 00000000000000..87ad53bc357554 --- /dev/null +++ b/src/libraries/Microsoft.Extensions.Diagnostics/tests/TracingConfigurationSampleTests.cs @@ -0,0 +1,853 @@ +// 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; +using System.Collections.Generic; +using System.Diagnostics; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Diagnostics.Tracing; +using Microsoft.Extensions.Options; +using Xunit; + +namespace Microsoft.Extensions.Diagnostics.Tests +{ + public class TracingConfigurationSampleTests + { + private const string SampleListenerName = nameof(SampleActivityListener); + + [Fact] + public void EnabledRuleAllowsActivityCreation() + { + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["EnabledTracing:Default"] = "true", + }) + .Build(); + + using var serviceProvider = new ServiceCollection() + .AddTracing(builder => builder + .AddListener(_ => SampleActivityListener.Create()) + .AddConfiguration(configuration)) + .Services + .Configure(options => + options.Rules.Add(new TracingRule("Demo.Source", operationName: null, listenerName: null, scopes: ActivitySourceScopes.Global | ActivitySourceScopes.Local, enable: true))) + .BuildServiceProvider(); + + serviceProvider.GetRequiredService().Validate(); + + using var source = new ActivitySource("Demo.Source"); + + AssertActivityCreation(source, "AllowedOperation", expectedCreated: true); + AssertActivityCreation(source, "BlockedOperation", expectedCreated: true); + } + + [Fact] + public void DisabledRuleSkipsActivityCreation() + { + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["EnabledTracing:Default"] = "true", + }) + .Build(); + + using var serviceProvider = new ServiceCollection() + .AddTracing(builder => builder + .AddListener(_ => SampleActivityListener.Create()) + .AddConfiguration(configuration)) + .Services + .Configure(options => + options.Rules.Add(new TracingRule("Demo.Source", operationName: null, listenerName: null, scopes: ActivitySourceScopes.Global | ActivitySourceScopes.Local, enable: false))) + .BuildServiceProvider(); + + serviceProvider.GetRequiredService().Validate(); + + using var source = new ActivitySource("Demo.Source"); + AssertActivityCreation(source, "BlockedOperation", expectedCreated: false); + } + + [Fact] + public void ScopeConfigurationMatchesSampleBehavior() + { + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["EnabledTracing:Default"] = "false", + ["EnabledGlobalTracing:Demo.ScopeSource"] = "true", + ["EnabledLocalTracing:Demo.ScopeSource"] = "false", + ["EnabledLocalTracing:Demo.LocalOnlySource"] = "true", + }) + .Build(); + + using var serviceProvider = new ServiceCollection() + .AddTracing(builder => builder + .AddListener(_ => SampleActivityListener.Create()) + .AddConfiguration(configuration)) + .Services + .BuildServiceProvider(); + + serviceProvider.GetRequiredService().Validate(); + ActivitySourceFactory activitySourceFactory = serviceProvider.GetRequiredService(); + + using var globalSource = new ActivitySource("Demo.ScopeSource"); + using var localScopeSource = activitySourceFactory.Create(new ActivitySourceOptions("Demo.ScopeSource")); + using var localOnlySource = activitySourceFactory.Create("Demo.LocalOnlySource"); + using var blockedSource = new ActivitySource("Demo.BlockedSource"); + + AssertActivityCreation(globalSource, "AllowedOperation", expectedCreated: true); + AssertActivityCreation(localScopeSource, "AllowedOperation", expectedCreated: false); + AssertActivityCreation(localOnlySource, "LocalOnlyOperation", expectedCreated: true); + AssertActivityCreation(blockedSource, "AllowedOperation", expectedCreated: false); + } + + [Fact] + public void ExistingSourceRespondsToRuleChanges() + { + var optionsMonitor = new TestActivityOptionsMonitor(CreateOptions("Demo.ReloadableSource", enable: false)); + + using var serviceProvider = new ServiceCollection() + .AddTracing(builder => builder.AddListener(_ => SampleActivityListener.Create())) + .Services + .AddSingleton>(optionsMonitor) + .BuildServiceProvider(); + + serviceProvider.GetRequiredService().Validate(); + + using var source = new ActivitySource("Demo.ReloadableSource"); + + AssertActivityCreation(source, "BeforeEnable", expectedCreated: false); + + optionsMonitor.Set(CreateOptions("Demo.ReloadableSource", enable: true)); + AssertActivityCreation(source, "AfterEnable", expectedCreated: true); + + optionsMonitor.Set(CreateOptions("Demo.ReloadableSource", enable: false)); + AssertActivityCreation(source, "AfterDisable", expectedCreated: false); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + public void UnspecifiedListenerNameRuleMatchesNamedListener(string? listenerName) + { + var optionsMonitor = new TestActivityOptionsMonitor(CreateOptions( + new TracingRule("Demo.DefaultListenerBucket", operationName: null, listenerName, scopes: ActivitySourceScopes.Global | ActivitySourceScopes.Local, enable: true))); + + using var serviceProvider = new ServiceCollection() + .AddTracing(builder => builder.AddListener(_ => SampleActivityListener.Create())) + .Services + .AddSingleton>(optionsMonitor) + .BuildServiceProvider(); + + serviceProvider.GetRequiredService().Validate(); + + using var source = new ActivitySource("Demo.DefaultListenerBucket"); + AssertActivityCreation(source, "DefaultListenerBucketOperation", expectedCreated: true); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + public void ExplicitListenerNameDisableWinsOverUnspecifiedEnable(string? unspecifiedListenerName) + { + var optionsMonitor = new TestActivityOptionsMonitor(CreateOptions( + new TracingRule("Demo.ListenerSpecificDisable", operationName: null, listenerName: SampleListenerName, scopes: ActivitySourceScopes.Global | ActivitySourceScopes.Local, enable: false), + new TracingRule("Demo.ListenerSpecificDisable", operationName: null, listenerName: unspecifiedListenerName, scopes: ActivitySourceScopes.Global | ActivitySourceScopes.Local, enable: true))); + + using var serviceProvider = new ServiceCollection() + .AddTracing(builder => builder.AddListener(_ => SampleActivityListener.Create())) + .Services + .AddSingleton>(optionsMonitor) + .BuildServiceProvider(); + + serviceProvider.GetRequiredService().Validate(); + + using var source = new ActivitySource("Demo.ListenerSpecificDisable"); + AssertActivityCreation(source, "ListenerSpecificDisableOperation", expectedCreated: false); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + public void ExplicitListenerNameEnableWinsOverUnspecifiedDisable(string? unspecifiedListenerName) + { + var optionsMonitor = new TestActivityOptionsMonitor(CreateOptions( + new TracingRule("Demo.ListenerSpecificEnable", operationName: null, listenerName: SampleListenerName, scopes: ActivitySourceScopes.Global | ActivitySourceScopes.Local, enable: true), + new TracingRule("Demo.ListenerSpecificEnable", operationName: null, listenerName: unspecifiedListenerName, scopes: ActivitySourceScopes.Global | ActivitySourceScopes.Local, enable: false))); + + using var serviceProvider = new ServiceCollection() + .AddTracing(builder => builder.AddListener(_ => SampleActivityListener.Create())) + .Services + .AddSingleton>(optionsMonitor) + .BuildServiceProvider(); + + serviceProvider.GetRequiredService().Validate(); + + using var source = new ActivitySource("Demo.ListenerSpecificEnable"); + AssertActivityCreation(source, "ListenerSpecificEnableOperation", expectedCreated: true); + } + + [Fact] + public void ActivitySourceFactoryCreate_WithInvalidScope_ThrowsTracingSpecificMessage() + { + using var serviceProvider = new ServiceCollection() + .AddTracing(builder => builder.AddListener(_ => SampleActivityListener.Create())) + .Services + .BuildServiceProvider(); + + ActivitySourceFactory activitySourceFactory = serviceProvider.GetRequiredService(); + + InvalidOperationException ex = Assert.Throws(() => + activitySourceFactory.Create(new ActivitySourceOptions("Demo.InvalidScopeSource") + { + Scope = new object() + })); + + Assert.Contains("activity source", ex.Message, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("meter", ex.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void SourceNamePatternWithMultipleWildcards_ThrowsTracingSpecificMessage() + { + ArgumentException ex = Assert.Throws("sourceName", () => + new TracingRule("Demo*Wildcard*Source", operationName: null, listenerName: null, scopes: ActivitySourceScopes.Global | ActivitySourceScopes.Local, enable: true)); + + Assert.Contains("activity source", ex.Message, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("category", ex.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void FactoryConstruction_DisposesPartialRegistrations_WhenLaterListenerRegistrationThrows() + { + int firstListenerShouldListenCalls = 0; + var firstListener = new ActivityListener("PartialCleanupFirstListener") + { + ShouldListenTo = _ => + { + Interlocked.Increment(ref firstListenerShouldListenCalls); + return false; + }, + }; + + var optionsMonitor = new TestActivityOptionsMonitor(CreateOptions( + new TracingRule(sourceName: null, operationName: null, listenerName: null, scopes: ActivitySourceScopes.Global | ActivitySourceScopes.Local, enable: true))); + + // The second registration returns a null ActivityListener. MS.DI materialises + // IEnumerable eagerly but accepts null factory returns, so the null + // reaches the factory ctor mid-iteration and the registration ctor's + // ArgumentNullException.ThrowIfNull throws there. That is exactly the partial-cleanup + // case we need to verify: the first registration has already been built (and its + // wrapper attached to ActivitySource.s_allListeners) before the throw. + using var serviceProvider = new ServiceCollection() + .AddTracing() + .Services + .AddSingleton(firstListener) + .AddSingleton(_ => null!) + .AddSingleton>(optionsMonitor) + .BuildServiceProvider(); + + Assert.Throws( + () => serviceProvider.GetRequiredService()); + + // If the partial-construction cleanup ran, the wrapper attached for firstListener has + // been detached from s_allListeners/s_activeSources. Creating a fresh ActivitySource + // after the failed construction must not invoke wrapper.ShouldListenTo (and therefore + // must not invoke firstListener.ShouldListenTo). If cleanup had not run, the wrapper + // would still be attached and would forward the call here. + int beforeNewSource = Volatile.Read(ref firstListenerShouldListenCalls); + using (var freshSource = new ActivitySource("Demo.PartialCleanupRegression_" + Guid.NewGuid().ToString("N"))) + { + Assert.Equal(beforeNewSource, Volatile.Read(ref firstListenerShouldListenCalls)); + } + } + + [Fact] + public void ActivitySourceFactoryCreate_ReturnsDistinctSources_WhenTelemetrySchemaUrlDiffers() + { + using var serviceProvider = new ServiceCollection() + .AddTracing(builder => builder.AddListener(_ => SampleActivityListener.Create())) + .Services + .BuildServiceProvider(); + + ActivitySourceFactory activitySourceFactory = serviceProvider.GetRequiredService(); + + ActivitySource schemaV1 = activitySourceFactory.Create(new ActivitySourceOptions("Demo.SchemaCacheKey") + { + Version = "1.0", + TelemetrySchemaUrl = "https://schema.test/v1", + }); + ActivitySource schemaV2 = activitySourceFactory.Create(new ActivitySourceOptions("Demo.SchemaCacheKey") + { + Version = "1.0", + TelemetrySchemaUrl = "https://schema.test/v2", + }); + ActivitySource schemaV1Again = activitySourceFactory.Create(new ActivitySourceOptions("Demo.SchemaCacheKey") + { + Version = "1.0", + TelemetrySchemaUrl = "https://schema.test/v1", + }); + ActivitySource schemaNull = activitySourceFactory.Create(new ActivitySourceOptions("Demo.SchemaCacheKey") + { + Version = "1.0", + }); + ActivitySource schemaNullAgain = activitySourceFactory.Create(new ActivitySourceOptions("Demo.SchemaCacheKey") + { + Version = "1.0", + }); + + Assert.NotSame(schemaV1, schemaV2); + Assert.NotSame(schemaV1, schemaNull); + Assert.NotSame(schemaV2, schemaNull); + Assert.Same(schemaV1, schemaV1Again); + Assert.Same(schemaNull, schemaNullAgain); + + Assert.Equal("https://schema.test/v1", schemaV1.TelemetrySchemaUrl); + Assert.Equal("https://schema.test/v2", schemaV2.TelemetrySchemaUrl); + Assert.Null(schemaNull.TelemetrySchemaUrl); + } + + [Fact] + public void ActivitySourceFactoryCreate_DoesNotMutateOptions_WhenActivitySourceCreationThrows() + { + using var serviceProvider = new ServiceCollection() + .AddTracing(builder => builder.AddListener(_ => SampleActivityListener.Create())) + .Services + .BuildServiceProvider(); + + ActivitySourceFactory activitySourceFactory = serviceProvider.GetRequiredService(); + ThrowingTagsEnumerable originalTags = new ThrowingTagsEnumerable(); + ActivitySourceOptions options = new ActivitySourceOptions("Demo.ThrowingScopeSource") + { + Version = "1.0", + Tags = originalTags, + TelemetrySchemaUrl = "https://schema.test/v1", + }; + + Assert.Null(options.Scope); + Assert.Throws(() => activitySourceFactory.Create(options)); + + Assert.Equal("Demo.ThrowingScopeSource", options.Name); + Assert.Equal("1.0", options.Version); + Assert.Same(originalTags, options.Tags); + Assert.Equal("https://schema.test/v1", options.TelemetrySchemaUrl); + Assert.Null(options.Scope); + } + + [Fact] + public void UpdateRules_PropagatesSingleListenerThrow_AfterUpdatingSiblings() + { + const string SourceName = "Demo.SiblingReloadIsolation.Single"; + + int siblingShouldListenCalls = 0; + ActivityListener throwingListener = new ActivityListener + { + ShouldListenTo = src => src.Name == SourceName + ? throw new InvalidOperationException("boom") + : false, + }; + ActivityListener siblingListener = new ActivityListener + { + ShouldListenTo = src => + { + if (src.Name == SourceName) + { + Interlocked.Increment(ref siblingShouldListenCalls); + } + return true; + }, + }; + + TestActivityOptionsMonitor optionsMonitor = new TestActivityOptionsMonitor(CreateOptions(SourceName, enable: false)); + + using ServiceProvider serviceProvider = new ServiceCollection() + .AddTracing(builder => builder + .AddListener(_ => throwingListener) + .AddListener(_ => siblingListener)) + .Services + .AddSingleton>(optionsMonitor) + .BuildServiceProvider(); + + serviceProvider.GetRequiredService().Validate(); + + using ActivitySource source = new ActivitySource(SourceName); + int baseline = Volatile.Read(ref siblingShouldListenCalls); + + InvalidOperationException ex = Assert.Throws(() => + optionsMonitor.Set(CreateOptions(SourceName, enable: true))); + Assert.Equal("boom", ex.Message); + + Assert.True(Volatile.Read(ref siblingShouldListenCalls) > baseline, + "Sibling listener did not receive the rule update; reload aborted at the throwing registration."); + } + + [Fact] + public void UpdateRules_AggregatesMultipleListenerThrows_AfterUpdatingSiblings() + { + const string SourceName = "Demo.SiblingReloadIsolation.Multi"; + + int siblingShouldListenCalls = 0; + ActivityListener throwingListener1 = new ActivityListener + { + ShouldListenTo = src => src.Name == SourceName + ? throw new InvalidOperationException("boom-1") + : false, + }; + ActivityListener throwingListener2 = new ActivityListener + { + ShouldListenTo = src => src.Name == SourceName + ? throw new InvalidOperationException("boom-2") + : false, + }; + ActivityListener siblingListener = new ActivityListener + { + ShouldListenTo = src => + { + if (src.Name == SourceName) + { + Interlocked.Increment(ref siblingShouldListenCalls); + } + return true; + }, + }; + + TestActivityOptionsMonitor optionsMonitor = new TestActivityOptionsMonitor(CreateOptions(SourceName, enable: false)); + + using ServiceProvider serviceProvider = new ServiceCollection() + .AddTracing(builder => builder + .AddListener(_ => throwingListener1) + .AddListener(_ => throwingListener2) + .AddListener(_ => siblingListener)) + .Services + .AddSingleton>(optionsMonitor) + .BuildServiceProvider(); + + serviceProvider.GetRequiredService().Validate(); + + using ActivitySource source = new ActivitySource(SourceName); + int baseline = Volatile.Read(ref siblingShouldListenCalls); + + AggregateException agg = Assert.Throws(() => + optionsMonitor.Set(CreateOptions(SourceName, enable: true))); + + Assert.Equal(2, agg.InnerExceptions.Count); + Assert.Contains(agg.InnerExceptions, e => e is InvalidOperationException { Message: "boom-1" }); + Assert.Contains(agg.InnerExceptions, e => e is InvalidOperationException { Message: "boom-2" }); + + Assert.True(Volatile.Read(ref siblingShouldListenCalls) > baseline, + "Sibling listener did not receive the rule update; reload aborted at the throwing registrations."); + } + + [Fact] + public void FactoryDispose_ThrowsOnSubsequentCreate_AndDetachesWrapperListener() + { + const string SourcePrefix = "Demo.FactoryDispose."; + + int notifications = 0; + ActivityListener listener = new ActivityListener + { + ShouldListenTo = src => src.Name.StartsWith(SourcePrefix, StringComparison.Ordinal), + Sample = (ref ActivityCreationOptions _) => ActivitySamplingResult.AllDataAndRecorded, + ActivityStarted = _ => Interlocked.Increment(ref notifications), + }; + + ServiceProvider serviceProvider = new ServiceCollection() + .AddTracing(builder => builder + .AddListener(_ => listener) + .EnableTracing(SourcePrefix + "*")) + .Services + .BuildServiceProvider(); + + serviceProvider.GetRequiredService().Validate(); + ActivitySourceFactory factory = serviceProvider.GetRequiredService(); + + using (ActivitySource before = new ActivitySource(SourcePrefix + "Before")) + using (before.StartActivity("op")) + { + } + Assert.Equal(1, Volatile.Read(ref notifications)); + + serviceProvider.Dispose(); + + Assert.Throws(() => factory.Create(SourcePrefix + "AfterFactoryDispose")); + + int countAfterDispose = Volatile.Read(ref notifications); + using (ActivitySource after = new ActivitySource(SourcePrefix + "After")) + using (after.StartActivity("op")) + { + } + Assert.Equal(countAfterDispose, Volatile.Read(ref notifications)); + } + + [Fact] + public async Task FactoryAndOptionsReload_DoNotDeadlock_UnderConcurrentCreateAndReload() + { + const int Iterations = 200; + const string SourcePrefix = "Demo.ConcurrentReload."; + + ActivityListener listener = new ActivityListener + { + ShouldListenTo = src => src.Name.StartsWith(SourcePrefix, StringComparison.Ordinal), + }; + + TracingRule enabled = new TracingRule(SourcePrefix + "*", operationName: null, listenerName: null, scopes: ActivitySourceScopes.Global | ActivitySourceScopes.Local, enable: true); + TracingRule disabled = new TracingRule(SourcePrefix + "*", operationName: null, listenerName: null, scopes: ActivitySourceScopes.Global | ActivitySourceScopes.Local, enable: false); + + TestActivityOptionsMonitor optionsMonitor = new TestActivityOptionsMonitor(CreateOptions(enabled)); + + using ServiceProvider serviceProvider = new ServiceCollection() + .AddTracing(builder => builder.AddListener(_ => listener)) + .Services + .AddSingleton>(optionsMonitor) + .BuildServiceProvider(); + + serviceProvider.GetRequiredService().Validate(); + ActivitySourceFactory factory = serviceProvider.GetRequiredService(); + + Task createTask = Task.Run(() => + { + for (int i = 0; i < Iterations; i++) + { + ActivitySource source = factory.Create(new ActivitySourceOptions(SourcePrefix + (i % 8))); + source.StartActivity("op")?.Dispose(); + } + }); + + Task reloadTask = Task.Run(() => + { + for (int i = 0; i < Iterations; i++) + { + optionsMonitor.Set(CreateOptions((i & 1) == 0 ? enabled : disabled)); + } + }); + + // Bounded wait: if either task deadlocks, Task.WhenAny lets us fail cleanly with a + // diagnostic instead of letting xunit's outer infrastructure timeout swallow the cause. + // Cancellation tokens would not help here because a deadlocked thread would not poll + // them; the timeout has to fire on a separate scheduler. + Task work = Task.WhenAll(createTask, reloadTask); + Task completed = await Task.WhenAny(work, Task.Delay(TimeSpan.FromSeconds(30))); + + Assert.True(completed == work, "Concurrent Create/reload did not complete within 30s; likely deadlock."); + + // Surface any exception thrown by either task. Safe to await because WhenAny only + // returned `work` when both inner tasks were complete. + await work; + } + + [Fact] + public void OptionsChange_AfterFactoryDispose_IsNoOp() + { + const string SourcePrefix = "Demo.ReloadAfterDispose."; + + int siblingShouldListenCalls = 0; + ActivityListener listener = new ActivityListener + { + ShouldListenTo = src => + { + if (src.Name.StartsWith(SourcePrefix, StringComparison.Ordinal)) + { + Interlocked.Increment(ref siblingShouldListenCalls); + } + return false; + }, + }; + + TestActivityOptionsMonitor optionsMonitor = new TestActivityOptionsMonitor( + CreateOptions(new TracingRule(SourcePrefix + "*", operationName: null, listenerName: null, scopes: ActivitySourceScopes.Global | ActivitySourceScopes.Local, enable: false))); + + ServiceProvider serviceProvider = new ServiceCollection() + .AddTracing(builder => builder.AddListener(_ => listener)) + .Services + .AddSingleton>(optionsMonitor) + .BuildServiceProvider(); + + serviceProvider.GetRequiredService().Validate(); + + serviceProvider.Dispose(); + + int baseline = Volatile.Read(ref siblingShouldListenCalls); + + optionsMonitor.Set(CreateOptions(new TracingRule(SourcePrefix + "*", operationName: null, listenerName: null, scopes: ActivitySourceScopes.Global | ActivitySourceScopes.Local, enable: true))); + + Assert.Equal(baseline, Volatile.Read(ref siblingShouldListenCalls)); + } + + [Fact] + public void FactoryCtor_OptionsReloadDuringBootstrap_AppliesLatestSnapshot() + { + const string SourceName = "Demo.ReloadDuringBootstrap"; + + TracingOptions initial = CreateOptions(SourceName, enable: false); + TracingOptions afterSubscribe = CreateOptions(SourceName, enable: true); + + ReloadOnSubscribeMonitor optionsMonitor = new ReloadOnSubscribeMonitor(initial, afterSubscribe); + + using ServiceProvider serviceProvider = new ServiceCollection() + .AddTracing(builder => builder.AddListener(_ => SampleActivityListener.Create())) + .Services + .AddSingleton>(optionsMonitor) + .BuildServiceProvider(); + + serviceProvider.GetRequiredService().Validate(); + + using ActivitySource source = new ActivitySource(SourceName); + // The monitor silently advances CurrentValue from `initial` (source disabled) to + // `afterSubscribe` (source enabled) when OnChange is invoked. Without the ctor's + // post-subscribe reconciliation read, the factory would be stuck on the disabled + // initial snapshot and the activity would not be created. + AssertActivityCreation(source, "Op", expectedCreated: true); + } + + private static TracingOptions CreateOptions(string sourceName, bool enable) + { + return CreateOptions( + new TracingRule(sourceName, operationName: null, listenerName: null, scopes: ActivitySourceScopes.Global | ActivitySourceScopes.Local, enable), + new TracingRule(sourceName, operationName: null, listenerName: SampleListenerName, scopes: ActivitySourceScopes.Global | ActivitySourceScopes.Local, enable)); + } + + private static TracingOptions CreateOptions(params TracingRule[] rules) + { + var options = new TracingOptions(); + foreach (TracingRule rule in rules) + { + options.Rules.Add(rule); + } + + return options; + } + + private static void AssertActivityCreation(ActivitySource source, string operationName, bool expectedCreated) + { + using Activity? activity = source.StartActivity(operationName); + if (expectedCreated) + { + Assert.NotNull(activity); + } + else + { + Assert.Null(activity); + } + } + + [Fact] + public void DisabledOperationName_FiltersNotifications_EvenWhenAnotherListenerCreatesActivity() + { + using var externalListener = new ActivityListener + { + ShouldListenTo = source => source.Name == "Demo.NotificationFilter", + Sample = (ref ActivityCreationOptions _) => ActivitySamplingResult.AllDataAndRecorded, + SampleUsingParentId = (ref ActivityCreationOptions _) => ActivitySamplingResult.AllDataAndRecorded, + }; + ActivitySource.AddActivityListener(externalListener); + + var recording = new RecordingActivityListener(); + using var serviceProvider = new ServiceCollection() + .AddTracing(builder => builder.AddListener(_ => recording.Listener)) + .Services + .Configure(options => + { + options.Rules.Add(new TracingRule("Demo.NotificationFilter", operationName: null, listenerName: null, scopes: ActivitySourceScopes.Global | ActivitySourceScopes.Local, enable: true)); + options.Rules.Add(new TracingRule("Demo.NotificationFilter", operationName: "BlockedOperation", listenerName: null, scopes: ActivitySourceScopes.Global | ActivitySourceScopes.Local, enable: false)); + }) + .BuildServiceProvider(); + + serviceProvider.GetRequiredService().Validate(); + + using var source = new ActivitySource("Demo.NotificationFilter"); + + using (var allowed = source.StartActivity("AllowedOperation")) + { + Assert.NotNull(allowed); + } + using (var blocked = source.StartActivity("BlockedOperation")) + { + Assert.NotNull(blocked); + } + + Assert.Equal(new[] { "AllowedOperation" }, recording.StartedNames); + Assert.Equal(new[] { "AllowedOperation" }, recording.StoppedNames); + } + + [Fact] + public void EnabledOperationName_NotifiesListener_EvenWhenSourceDefaultDisabled() + { + var recording = new RecordingActivityListener(); + using var serviceProvider = new ServiceCollection() + .AddTracing(builder => builder.AddListener(_ => recording.Listener)) + .Services + .Configure(options => + { + options.Rules.Add(new TracingRule("Demo.OptIn", operationName: null, listenerName: null, scopes: ActivitySourceScopes.Global | ActivitySourceScopes.Local, enable: false)); + options.Rules.Add(new TracingRule("Demo.OptIn", operationName: "OptedInOperation", listenerName: null, scopes: ActivitySourceScopes.Global | ActivitySourceScopes.Local, enable: true)); + }) + .BuildServiceProvider(); + + serviceProvider.GetRequiredService().Validate(); + + using var source = new ActivitySource("Demo.OptIn"); + + using (var optedIn = source.StartActivity("OptedInOperation")) + { + Assert.NotNull(optedIn); + } + + Assert.Equal(new[] { "OptedInOperation" }, recording.StartedNames); + Assert.Equal(new[] { "OptedInOperation" }, recording.StoppedNames); + } + + private sealed class RecordingActivityListener + { + private readonly object _lock = new(); + private readonly List _started = new(); + private readonly List _stopped = new(); + + public RecordingActivityListener() + { + Listener = new ActivityListener(nameof(RecordingActivityListener)) + { + Sample = static (ref ActivityCreationOptions options) => ActivitySamplingResult.AllDataAndRecorded, + SampleUsingParentId = static (ref ActivityCreationOptions options) => ActivitySamplingResult.AllDataAndRecorded, + ActivityStarted = activity => + { + lock (_lock) { _started.Add(activity.OperationName); } + }, + ActivityStopped = activity => + { + lock (_lock) { _stopped.Add(activity.OperationName); } + }, + }; + } + + public ActivityListener Listener { get; } + + public IReadOnlyList StartedNames + { + get { lock (_lock) { return _started.ToArray(); } } + } + + public IReadOnlyList StoppedNames + { + get { lock (_lock) { return _stopped.ToArray(); } } + } + } + + private static class SampleActivityListener + { + public static ActivityListener Create() => new ActivityListener(SampleListenerName) + { + Sample = static (ref ActivityCreationOptions options) => ActivitySamplingResult.AllDataAndRecorded, + SampleUsingParentId = static (ref ActivityCreationOptions options) => ActivitySamplingResult.AllDataAndRecorded, + }; + } + + private sealed class ThrowingTagsEnumerable : IEnumerable> + { + public IEnumerator> GetEnumerator() => new ThrowingTagsEnumerator(); + + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); + + private sealed class ThrowingTagsEnumerator : IEnumerator> + { + public KeyValuePair Current => default; + + object IEnumerator.Current => Current; + + public bool MoveNext() => throw new InvalidOperationException("Test exception."); + + public void Dispose() + { + } + + public void Reset() => throw new NotSupportedException(); + } + } + + private sealed class ReloadOnSubscribeMonitor : IOptionsMonitor + { + private readonly TracingOptions _afterSubscribe; + private TracingOptions _current; + private bool _flipped; + + public ReloadOnSubscribeMonitor(TracingOptions initial, TracingOptions afterSubscribe) + { + _current = initial; + _afterSubscribe = afterSubscribe; + } + + public TracingOptions CurrentValue => Volatile.Read(ref _current); + + public TracingOptions Get(string? name) => CurrentValue; + + public IDisposable OnChange(Action listener) + { + // Simulate a configuration reload that committed silently between the consumer's + // initial CurrentValue read and this subscription. The new snapshot is NOT pushed + // through the listener (that's the whole point of the race we are exercising — + // the change was committed before the listener was attached). + if (!_flipped) + { + _flipped = true; + Volatile.Write(ref _current, _afterSubscribe); + } + + return new NoopDisposable(); + } + + private sealed class NoopDisposable : IDisposable + { + public void Dispose() { } + } + } + + private sealed class TestActivityOptionsMonitor : IOptionsMonitor + { + private readonly List> _callbacks = new(); + + public TestActivityOptionsMonitor(TracingOptions currentValue) + { + CurrentValue = currentValue; + } + + public TracingOptions CurrentValue { get; private set; } + + public TracingOptions Get(string? name) => CurrentValue; + + public IDisposable OnChange(Action listener) + { + _callbacks.Add(listener); + return new CallbackRegistration(_callbacks, listener); + } + + public void Set(TracingOptions options) + { + CurrentValue = options; + foreach (Action callback in _callbacks.ToArray()) + { + callback(options, Options.Options.DefaultName); + } + } + + private sealed class CallbackRegistration : IDisposable + { + private readonly List> _callbacks; + private readonly Action _callback; + + public CallbackRegistration(List> callbacks, Action callback) + { + _callbacks = callbacks; + _callback = callback; + } + + public void Dispose() + { + _callbacks.Remove(_callback); + } + } + } + } +} 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..dacec219d880a3 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 { @@ -293,12 +305,15 @@ public readonly struct ActivityCreationOptions public sealed class ActivityListener : IDisposable { public ActivityListener() { throw null; } + public ActivityListener(string? name) { throw null; } + public string? Name { get { throw null; } } public System.Action? ActivityStarted { get { throw null; } set { } } public System.Action? ActivityStopped { get { throw null; } set { } } public System.Diagnostics.ExceptionRecorder? ExceptionRecorder { get { throw null; } set { } } 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..6ad6e8c6becc13 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. /// @@ -27,6 +30,20 @@ public ActivityListener() { } + /// + /// Construct a new object with a name used by configuration-based filtering to identify this listener. + /// + /// A name that identifies this listener for rule matching. Pass to leave the listener unnamed. + public ActivityListener(string? name) + { + Name = name; + } + + /// + /// Gets the optional name used by configuration-based filtering to target rules at this specific listener. + /// + public string? Name { get; } + /// /// Set or get the callback used to listen to the start event. /// @@ -57,9 +74,45 @@ public ActivityListener() /// public SampleActivity? Sample { get; set; } + /// + /// 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 observes IsDisposed via its post-commit recheck and undoes + // any attachments it raced into place. + Volatile.Write(ref _disposed, true); + ActivitySource.DetachListener(this); + } + + internal bool IsDisposed => Volatile.Read(ref _disposed); } } 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..a21142d3b79684 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. @@ -91,6 +94,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 +113,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 +214,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,7 +349,13 @@ public bool HasListeners() /// public void Dispose() { - _listeners = null; + Dispose(true); + GC.SuppressFinalize(this); + } + + protected virtual void Dispose(bool disposing) + { + Interlocked.Exchange(ref _listeners, s_disposedListeners); s_activeSources.Remove(this); } @@ -351,13 +369,85 @@ public static void AddActivityListener(ActivityListener listener) if (s_allListeners.AddIfNotExist(listener)) { - s_activeSources.EnumWithAction((source, obj) => { - var shouldListenTo = ((ActivityListener)obj).ShouldListenTo; - if (shouldListenTo != null && shouldListenTo(source)) - { - source.AddListener((ActivityListener)obj); - } - }, listener); + try + { + s_activeSources.EnumWithAction((source, obj) => { + var shouldListenTo = ((ActivityListener)obj).ShouldListenTo; + if (shouldListenTo != null && shouldListenTo(source)) + { + source.AddListener((ActivityListener)obj); + } + }, listener); + } + catch + { + s_allListeners.Remove(listener); + s_activeSources.EnumWithAction((source, obj) => source.RemoveListener((ActivityListener)obj), listener); + throw; + } + } + } + + /// + /// 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 +455,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)) { - Interlocked.CompareExchange(ref _listeners, new SynchronizedList(), null); + 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)) + { + 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 +514,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 +527,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..14e2018f68fada 100644 --- a/src/libraries/System.Diagnostics.DiagnosticSource/tests/ActivitySourceTests.cs +++ b/src/libraries/System.Diagnostics.DiagnosticSource/tests/ActivitySourceTests.cs @@ -84,6 +84,263 @@ 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 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 +1850,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; } }