diff --git a/src/Components/Components/src/ComponentsActivityState.cs b/src/Components/Components/src/ComponentsActivityState.cs new file mode 100644 index 000000000000..7efc2d9c4bce --- /dev/null +++ b/src/Components/Components/src/ComponentsActivityState.cs @@ -0,0 +1,73 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.AspNetCore.Components.Infrastructure; + +namespace Microsoft.AspNetCore.Components; + +internal sealed class ComponentsActivityState +{ + private ComponentsActivityLinkStore? _activityLinkStore; + private ComponentsActivityPersistentStateUpdate? _pendingState; + private bool _hasPendingState; + + public ComponentsActivityPersistentStateUpdate Capture() + { + ComponentsActivityPersistentState? routeState = null; + _activityLinkStore?.TryCreatePersistentRouteState(out routeState); + return new ComponentsActivityPersistentStateUpdate(routeState); + } + + public void Apply(ComponentsActivityPersistentStateUpdate state) + { + _pendingState = state; + _hasPendingState = true; + ApplyPendingState(); + } + + public void Initialize(ComponentsActivityLinkStore activityLinkStore) + { + _activityLinkStore = activityLinkStore; + ApplyPendingState(); + } + + private void ApplyPendingState() + { + if (_activityLinkStore is null || !_hasPendingState) + { + return; + } + + if (_pendingState?.Route is { } routeState) + { + _activityLinkStore.RestorePersistentRouteState(routeState); + } + else + { + _activityLinkStore.RemoveActivityContext(ComponentsActivityLinkStore.Route); + } + + _pendingState = null; + _hasPendingState = false; + } +} + +internal sealed class ServerComponentsActivityState(ComponentsActivityState activityState) +{ + [PersistentState(AllowUpdates = true)] + public ComponentsActivityPersistentStateUpdate ActivityState + { + get => activityState.Capture(); + set => activityState.Apply(value); + } +} + +internal sealed class WebAssemblyComponentsActivityState(ComponentsActivityState activityState) +{ + [PersistentState(AllowUpdates = true)] + public ComponentsActivityPersistentStateUpdate ActivityState + { + get => activityState.Capture(); + set => activityState.Apply(value); + } +} diff --git a/src/Components/Components/src/RegisterRenderingMetricsServiceCollectionExtensions.cs b/src/Components/Components/src/RegisterRenderingMetricsServiceCollectionExtensions.cs index e1e224e5f71b..eb28b3b90f8d 100644 --- a/src/Components/Components/src/RegisterRenderingMetricsServiceCollectionExtensions.cs +++ b/src/Components/Components/src/RegisterRenderingMetricsServiceCollectionExtensions.cs @@ -1,9 +1,12 @@ // 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.CodeAnalysis; using System.Diagnostics.Metrics; +using System.Linq; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; +using static Microsoft.AspNetCore.Internal.LinkerFlags; namespace Microsoft.AspNetCore.Components.Infrastructure; @@ -39,10 +42,66 @@ public static IServiceCollection AddComponentsTracing( IServiceCollection services) { services.TryAddScoped(); + if (services.Any(descriptor => + descriptor.ServiceType == typeof(IComponentRenderMode) && + Equals(descriptor.ServiceKey, "Microsoft.AspNetCore.Components.ActivityState.WebAssembly"))) + { + services.TryAddSingleton(); + } + else + { + services.TryAddScoped(); + } + services.TryAddScoped(); + services.TryAddScoped(); + services.TryAddKeyedSingleton( + "Microsoft.AspNetCore.Components.ActivityState.Server"); + services.TryAddKeyedSingleton( + "Microsoft.AspNetCore.Components.ActivityState.WebAssembly"); + services.TryAddEnumerable( + ServiceDescriptor.Singleton()); + services.TryAddEnumerable( + ServiceDescriptor.Singleton()); return services; } + private sealed class UnsupportedComponentsActivityStateRenderMode : IComponentRenderMode; + + private sealed class ServerComponentsActivityStatePersistentServiceRegistration(IServiceProvider serviceProvider) + : IPersistentServiceRegistration + { + private readonly PersistentServiceRegistration _registration = new( + serviceProvider.GetRequiredKeyedService( + "Microsoft.AspNetCore.Components.ActivityState.Server")); + + public string Assembly => _registration.Assembly; + + public string FullTypeName => _registration.FullTypeName; + + public IComponentRenderMode? GetRenderModeOrDefault() => _registration.GetRenderModeOrDefault(); + + [DynamicDependency(JsonSerialized, typeof(ServerComponentsActivityState))] + public Type? GetResolvedTypeOrNull() => _registration.GetResolvedTypeOrNull(); + } + + private sealed class WebAssemblyComponentsActivityStatePersistentServiceRegistration(IServiceProvider serviceProvider) + : IPersistentServiceRegistration + { + private readonly PersistentServiceRegistration _registration = new( + serviceProvider.GetRequiredKeyedService( + "Microsoft.AspNetCore.Components.ActivityState.WebAssembly")); + + public string Assembly => _registration.Assembly; + + public string FullTypeName => _registration.FullTypeName; + + public IComponentRenderMode? GetRenderModeOrDefault() => _registration.GetRenderModeOrDefault(); + + [DynamicDependency(JsonSerialized, typeof(WebAssemblyComponentsActivityState))] + public Type? GetResolvedTypeOrNull() => _registration.GetResolvedTypeOrNull(); + } + private static bool IsMeterFactoryRegistered(IServiceCollection services) { foreach (var service in services) diff --git a/src/Components/Components/src/RenderTree/Renderer.cs b/src/Components/Components/src/RenderTree/Renderer.cs index 015879de2a8d..42ce41df8e32 100644 --- a/src/Components/Components/src/RenderTree/Renderer.cs +++ b/src/Components/Components/src/RenderTree/Renderer.cs @@ -112,7 +112,12 @@ public Renderer(IServiceProvider serviceProvider, ILoggerFactory loggerFactory, if (ComponentsActivitySource.IsSupported) { _componentsActivitySource = serviceProvider.GetService(); - _componentsActivitySource?.Init(new ComponentsActivityLinkStore(this)); + if (_componentsActivitySource is not null) + { + var activityLinkStore = new ComponentsActivityLinkStore(this); + _componentsActivitySource.Init(activityLinkStore); + serviceProvider.GetService()?.Initialize(activityLinkStore); + } } ServiceProviderCascadingValueSuppliers = serviceProvider.GetService() is null diff --git a/src/Components/Components/test/ComponentsActivitySourceTest.cs b/src/Components/Components/test/ComponentsActivitySourceTest.cs index cccb3fb79170..93434b832246 100644 --- a/src/Components/Components/test/ComponentsActivitySourceTest.cs +++ b/src/Components/Components/test/ComponentsActivitySourceTest.cs @@ -71,9 +71,8 @@ public void StartRouteActivity_CreatesAndStartsActivity() } [Fact] - public void StartEventActivity_CreatesAndStartsActivity() + public void StartEventActivity_WithSharedLinkStore_LinksToRoute() { - // Arrange var componentsActivitySource = new ComponentsActivitySource(); var linkstore = new ComponentsActivityLinkStore(null); componentsActivitySource.Init(linkstore); @@ -81,15 +80,13 @@ public void StartEventActivity_CreatesAndStartsActivity() var methodName = "OnClick"; var attributeName = "onclick"; - // First set up a circuit and route context linkstore.SetActivityContext(ComponentsActivityLinkStore.Circuit, default, new KeyValuePair("aspnetcore.components.circuit.id", "test-circuit-id")); - componentsActivitySource.StartNavigateActivity("ParentComponent", "/parent"); + var routeActivityHandle = componentsActivitySource.StartNavigateActivity("ParentComponent", "/parent"); + componentsActivitySource.StopNavigateActivity(routeActivityHandle, null); - // Act var activityHandle = ComponentsActivitySource.StartHandleEventActivity(componentType, methodName, attributeName); var activity = activityHandle.Activity; - // Assert Assert.NotNull(activity); Assert.Equal(ComponentsActivitySource.OnEventName, activity.OperationName); Assert.Equal($"Event {attributeName} -> {componentType}.{methodName}", activity.DisplayName); @@ -100,10 +97,33 @@ public void StartEventActivity_CreatesAndStartsActivity() Assert.Equal(attributeName, activity.GetTagItem("aspnetcore.components.attribute.name")); Assert.False(activity.IsStopped); - componentsActivitySource.StopNavigateActivity(activityHandle, null); + componentsActivitySource.StopHandleEventActivity(activityHandle, null); Assert.True(activity.IsStopped); Assert.Equal("test-circuit-id", activity.GetTagItem("aspnetcore.components.circuit.id")); - Assert.Empty(activity.Links); + Assert.Collection(activity.Links, link => Assert.Equal(routeActivityHandle.Activity.Context, link.Context)); + } + + [Fact] + public void StartEventActivity_WithSeparateLinkStores_DoesNotLinkToRoute() + { + var endpointActivitySource = new ComponentsActivitySource(); + var endpointLinkStore = new ComponentsActivityLinkStore(null); + endpointActivitySource.Init(endpointLinkStore); + + var routeActivityHandle = endpointActivitySource.StartNavigateActivity("ParentComponent", "/parent"); + endpointActivitySource.StopNavigateActivity(routeActivityHandle, null); + Assert.NotNull(routeActivityHandle.Activity); + + var circuitActivitySource = new ComponentsActivitySource(); + var circuitLinkStore = new ComponentsActivityLinkStore(null); + circuitActivitySource.Init(circuitLinkStore); + + var eventActivityHandle = ComponentsActivitySource.StartHandleEventActivity("TestComponent", "OnClick", "onclick"); + circuitActivitySource.StopHandleEventActivity(eventActivityHandle, null); + Assert.NotNull(eventActivityHandle.Activity); + + Assert.Empty(eventActivityHandle.Activity.Links); + Assert.DoesNotContain(eventActivityHandle.Activity.Links, link => link.Context == routeActivityHandle.Activity.Context); } [Fact] diff --git a/src/Components/Components/test/ComponentsActivityStateTest.cs b/src/Components/Components/test/ComponentsActivityStateTest.cs new file mode 100644 index 000000000000..a2eaa2ce9921 --- /dev/null +++ b/src/Components/Components/test/ComponentsActivityStateTest.cs @@ -0,0 +1,92 @@ +// 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.AspNetCore.Components.Infrastructure; + +namespace Microsoft.AspNetCore.Components; + +public class ComponentsActivityStateTest +{ + [Fact] + public void CircuitInitialization_AppliesStateRestoredBeforeRendererInitialization() + { + var routeContext = CreateActivityContext(); + var activityState = new ComponentsActivityState(); + activityState.Apply(CreateState(routeContext, "/counter")); + var linkStore = new ComponentsActivityLinkStore(null); + + activityState.Initialize(linkStore); + + AssertRoute(linkStore, routeContext, "/counter"); + } + + [Fact] + public void NavigationUpdate_ReplacesRouteWhenInteractiveTreeHasNoRouter() + { + var initialContext = CreateActivityContext(); + var updatedContext = CreateActivityContext(); + var linkStore = new ComponentsActivityLinkStore(null); + var activityState = new ComponentsActivityState(); + activityState.Initialize(linkStore); + activityState.Apply(CreateState(initialContext, "/initial")); + + activityState.Apply(CreateState(updatedContext, "/updated")); + + AssertRoute(linkStore, updatedContext, "/updated"); + } + + [Fact] + public void NavigationUpdate_ClearsRouteWhenStateHasNoRoute() + { + var linkStore = new ComponentsActivityLinkStore(null); + var activityState = new ComponentsActivityState(); + activityState.Initialize(linkStore); + activityState.Apply(CreateState(CreateActivityContext(), "/counter")); + + activityState.Apply(new ComponentsActivityPersistentStateUpdate(null)); + + Assert.False(linkStore.TryGetActivityContext(ComponentsActivityLinkStore.Route, out _, out _)); + } + + [Fact] + public void CircuitInitialization_IgnoresMalformedActivityContext() + { + var activityState = new ComponentsActivityState(); + activityState.Apply(new ComponentsActivityPersistentStateUpdate( + new ComponentsActivityPersistentState("invalid", null, false, "/counter"))); + var linkStore = new ComponentsActivityLinkStore(null); + + activityState.Initialize(linkStore); + + Assert.False(linkStore.TryGetActivityContext(ComponentsActivityLinkStore.Route, out _, out _)); + } + + private static ComponentsActivityPersistentStateUpdate CreateState(ActivityContext context, string route) + => new(new ComponentsActivityPersistentState( + $"00-{context.TraceId}-{context.SpanId}-{(byte)context.TraceFlags:x2}", + context.TraceState, + context.IsRemote, + route)); + + private static ActivityContext CreateActivityContext() + => new( + ActivityTraceId.CreateRandom(), + ActivitySpanId.CreateRandom(), + ActivityTraceFlags.Recorded, + traceState: "vendor=value", + isRemote: false); + + private static void AssertRoute( + ComponentsActivityLinkStore linkStore, + ActivityContext expectedContext, + string expectedRoute) + { + Assert.True(linkStore.TryGetActivityContext( + ComponentsActivityLinkStore.Route, + out var context, + out var tag)); + Assert.Equal(expectedContext, context); + Assert.Equal(new KeyValuePair("aspnetcore.components.route", expectedRoute), tag); + } +} diff --git a/src/Components/Server/src/DependencyInjection/ServerRazorComponentsBuilderExtensions.cs b/src/Components/Server/src/DependencyInjection/ServerRazorComponentsBuilderExtensions.cs index 3e6e08c45fa1..df3d34540f26 100644 --- a/src/Components/Server/src/DependencyInjection/ServerRazorComponentsBuilderExtensions.cs +++ b/src/Components/Server/src/DependencyInjection/ServerRazorComponentsBuilderExtensions.cs @@ -35,6 +35,9 @@ public static IServerSideBlazorBuilder AddInteractiveServerComponents(this IRazo ArgumentNullException.ThrowIfNull(builder, nameof(builder)); builder.Services.AddServerSideBlazor(configure); + builder.Services.AddKeyedSingleton( + "Microsoft.AspNetCore.Components.ActivityState.Server", + RenderMode.InteractiveServer); builder.Services.TryAddEnumerable(ServiceDescriptor.Singleton()); return new DefaultServerSideBlazorBuilder(builder.Services); diff --git a/src/Components/WebAssembly/Server/src/WebAssemblyRazorComponentsBuilderExtensions.cs b/src/Components/WebAssembly/Server/src/WebAssemblyRazorComponentsBuilderExtensions.cs index cfbf3596b64a..79ef98a04335 100644 --- a/src/Components/WebAssembly/Server/src/WebAssemblyRazorComponentsBuilderExtensions.cs +++ b/src/Components/WebAssembly/Server/src/WebAssemblyRazorComponentsBuilderExtensions.cs @@ -27,6 +27,9 @@ public static IRazorComponentsBuilder AddInteractiveWebAssemblyComponents(this I { ArgumentNullException.ThrowIfNull(builder, nameof(builder)); + builder.Services.AddKeyedSingleton( + "Microsoft.AspNetCore.Components.ActivityState.WebAssembly", + RenderMode.InteractiveWebAssembly); builder.Services.TryAddEnumerable(ServiceDescriptor.Singleton()); builder.Services.TryAddScoped(); diff --git a/src/Components/WebAssembly/WebAssembly/src/Hosting/WebAssemblyHostBuilder.cs b/src/Components/WebAssembly/WebAssembly/src/Hosting/WebAssemblyHostBuilder.cs index 7ad8803dd768..cc9de27a0b81 100644 --- a/src/Components/WebAssembly/WebAssembly/src/Hosting/WebAssemblyHostBuilder.cs +++ b/src/Components/WebAssembly/WebAssembly/src/Hosting/WebAssemblyHostBuilder.cs @@ -358,6 +358,9 @@ internal void InitializeDefaultServices() // Register metrics and tracing when supported and not disabled by the feature switch. if (IsMeterSupported && IsMeterEnabled) { + Services.AddKeyedSingleton( + "Microsoft.AspNetCore.Components.ActivityState.WebAssembly", + RenderMode.InteractiveWebAssembly); ComponentsMetricsServiceCollectionExtensions.AddComponentsMetrics(Services); ComponentsMetricsServiceCollectionExtensions.AddComponentsTracing(Services); } diff --git a/src/Components/test/E2ETest/ServerRenderingTests/InteractivityTest.cs b/src/Components/test/E2ETest/ServerRenderingTests/InteractivityTest.cs index 9afc3ec712d4..d2c317e3af65 100644 --- a/src/Components/test/E2ETest/ServerRenderingTests/InteractivityTest.cs +++ b/src/Components/test/E2ETest/ServerRenderingTests/InteractivityTest.cs @@ -1,6 +1,8 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System.Net.Http; +using System.Net.Http.Json; using Components.TestServer.RazorComponents; using Microsoft.AspNetCore.Components.E2ETest; using Microsoft.AspNetCore.Components.E2ETest.Infrastructure; @@ -9,6 +11,7 @@ using Microsoft.AspNetCore.E2ETesting; using OpenQA.Selenium; using TestServer; +using TestContentPackage; using Xunit.Abstractions; namespace Microsoft.AspNetCore.Components.E2ETests.ServerRenderingTests; @@ -27,6 +30,161 @@ public InteractivityTest( public override Task InitializeAsync() => InitializeAsync(BrowserFixture.StreamingContext); + [Theory] + [InlineData("server")] + [InlineData("webassembly")] + public async Task PerPageActivityLinks_AreRestoredWhenInteractiveRuntimeIsInitialized(string mode) + { + var testId = Guid.NewGuid().ToString("N"); + try + { + Navigate($"{ServerPathBase}/activity-links/{mode}/circuit-initialization?activity-links-test-id={testId}"); + + Browser.Equal("True", () => Browser.FindElement(By.Id("activity-links-interactive")).Text); + Browser.Click(By.Id("activity-links-trigger")); + Browser.Equal("1", () => Browser.FindElement(By.Id("activity-links-trigger-count")).Text); + + var expectedOperations = mode == "server" + ? new[] + { + "Microsoft.AspNetCore.Components.Navigate", + "Microsoft.AspNetCore.Components.Navigate", + "Microsoft.AspNetCore.Components.Server.Circuits.StartCircuit", + "Microsoft.AspNetCore.Components.HandleEvent", + } + : new[] + { + "Microsoft.AspNetCore.Components.Navigate", + "Microsoft.AspNetCore.Components.Navigate", + "Microsoft.AspNetCore.Components.HandleEvent", + }; + var telemetry = await GetActivityLinksTelemetryAsync(testId, expectedOperations.Length); + AssertActivityLinksTelemetry( + telemetry, + expectedOperations, + "/activity-links/{Mode}/circuit-initialization", + expectCircuit: mode == "server"); + } + finally + { + await CompleteActivityLinksTelemetryAsync(testId); + } + } + + [Theory] + [InlineData("server")] + [InlineData("webassembly")] + public async Task PerPageActivityLinks_AreUpdatedAfterEnhancedNavigation(string mode) + { + var testId = Guid.NewGuid().ToString("N"); + try + { + Navigate($"{ServerPathBase}/activity-links/{mode}/navigation/one?activity-links-test-id={testId}"); + + Browser.Equal("True", () => Browser.FindElement(By.Id("activity-links-interactive")).Text); + var instanceId = Browser.FindElement(By.Id("activity-links-instance")).Text; + + Browser.Click(By.Id("activity-links-navigate")); + + Browser.Equal( + "Activity links navigation two", + () => Browser.FindElement(By.TagName("h1")).Text); + Browser.Equal( + instanceId, + () => Browser.FindElement(By.Id("activity-links-instance")).Text); + Browser.Click(By.Id("activity-links-trigger")); + Browser.Equal("1", () => Browser.FindElement(By.Id("activity-links-trigger-count")).Text); + + var expectedOperations = mode == "server" + ? new[] + { + "Microsoft.AspNetCore.Components.Navigate", + "Microsoft.AspNetCore.Components.Navigate", + "Microsoft.AspNetCore.Components.Server.Circuits.StartCircuit", + "Microsoft.AspNetCore.Components.Navigate", + "Microsoft.AspNetCore.Components.Navigate", + "Microsoft.AspNetCore.Components.HandleEvent", + } + : new[] + { + "Microsoft.AspNetCore.Components.Navigate", + "Microsoft.AspNetCore.Components.Navigate", + "Microsoft.AspNetCore.Components.Navigate", + "Microsoft.AspNetCore.Components.Navigate", + "Microsoft.AspNetCore.Components.HandleEvent", + }; + var telemetry = await GetActivityLinksTelemetryAsync(testId, expectedOperations.Length); + AssertActivityLinksTelemetry( + telemetry, + expectedOperations, + $"/activity-links/{mode}/navigation/two", + expectCircuit: mode == "server"); + } + finally + { + await CompleteActivityLinksTelemetryAsync(testId); + } + } + + private async Task GetActivityLinksTelemetryAsync(string testId, int expectedCount) + { + using var client = new HttpClient { BaseAddress = _serverFixture.RootUri }; + for (var i = 0; i < 100; i++) + { + var telemetry = await client.GetFromJsonAsync( + $"{ServerPathBase}/activity-links/telemetry/{testId}"); + if (telemetry is { Length: >= 1 } && telemetry.Length >= expectedCount) + { + return telemetry; + } + await Task.Delay(100); + } + + return []; + } + + private async Task CompleteActivityLinksTelemetryAsync(string testId) + { + using var client = new HttpClient { BaseAddress = _serverFixture.RootUri }; + await client.DeleteAsync($"{ServerPathBase}/activity-links/telemetry/{testId}"); + } + + private static void AssertActivityLinksTelemetry( + ActivityLinksTestSpan[] telemetry, + string[] expectedOperations, + string expectedRoute, + bool expectCircuit) + { + var actualOperations = telemetry.Select(span => span.Name).ToArray(); + Assert.True( + expectedOperations.SequenceEqual(actualOperations), + $"Expected: {string.Join(", ", expectedOperations)}{Environment.NewLine}" + + $"Actual: {string.Join(", ", telemetry.Select(span => $"{span.Name} route={span.Route} trace={span.TraceId} span={span.SpanId}"))}"); + var route = telemetry.Last(span => span.Name == "Microsoft.AspNetCore.Components.Navigate"); + var eventActivity = Assert.Single( + telemetry.Where(span => span.Name == "Microsoft.AspNetCore.Components.HandleEvent")); + Assert.Equal(expectedRoute, eventActivity.Route); + Assert.Contains( + eventActivity.Links, + link => link.TraceId == route.TraceId && link.SpanId == route.SpanId); + + var circuit = telemetry.SingleOrDefault( + span => span.Name == "Microsoft.AspNetCore.Components.Server.Circuits.StartCircuit"); + if (expectCircuit) + { + Assert.NotNull(circuit); + Assert.Equal(circuit.CircuitId, eventActivity.CircuitId); + Assert.Contains( + eventActivity.Links, + link => link.TraceId == circuit.TraceId && link.SpanId == circuit.SpanId); + } + else + { + Assert.Null(circuit); + Assert.Null(eventActivity.CircuitId); + } + } + [Fact] public void CanRenderInteractiveServerComponent() { diff --git a/src/Components/test/testassets/Components.TestServer/RazorComponentEndpointsStartup.cs b/src/Components/test/testassets/Components.TestServer/RazorComponentEndpointsStartup.cs index f7ea724f174d..eedad05e341d 100644 --- a/src/Components/test/testassets/Components.TestServer/RazorComponentEndpointsStartup.cs +++ b/src/Components/test/testassets/Components.TestServer/RazorComponentEndpointsStartup.cs @@ -17,6 +17,8 @@ using Microsoft.AspNetCore.Localization; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Localization; +using OpenTelemetry; +using OpenTelemetry.Trace; using TestContentPackage; using TestContentPackage.Services; @@ -55,6 +57,11 @@ public void ConfigureServices(IServiceCollection services) new TestStringLocalizerFactory(ClientValidationLocalizationData.Translations)); services.AddValidation(options => options.Resolvers.Add(new BasicTestApp.FormsTest.AsyncValidationResolver())); + services.AddOpenTelemetry().WithTracing(tracing => tracing + .AddSource("Microsoft.AspNetCore.Components") + .AddSource("Microsoft.AspNetCore.Components.Server.Circuits") + .SetSampler(new ComponentsActivityTestSampler()) + .AddProcessor(new SimpleActivityExportProcessor(new ComponentsActivityTestExporter()))); // Increase 10 MB hub message limit (default 32 KB) if (Configuration.GetValue("AllowLargeHubMessages")) @@ -228,6 +235,11 @@ private void ConfigureSubdirPipeline(IApplicationBuilder app, IWebHostEnvironmen app.Use((ctx, nxt) => { + if (ctx.Request.Query.TryGetValue("activity-links-test-id", out var testId)) + { + ComponentsActivityTestCollector.Start(testId.ToString()); + } + if (ctx.Request.Query.ContainsKey("add-csp")) { ctx.Response.Headers.Add("Content-Security-Policy", "script-src 'self' 'unsafe-inline'"); @@ -286,6 +298,21 @@ private void ConfigureEndpoints(IApplicationBuilder app, IWebHostEnvironment env MapEnhancedNavigationEndpoints(endpoints); endpoints.MapAutoPauseTestEndpoints(); + endpoints.MapPost("activity-links/telemetry/{testId}", async (string testId, HttpRequest request) => + { + if (await request.ReadFromJsonAsync() is { } spans) + { + ComponentsActivityTestCollector.Add(testId, spans); + } + return Results.Ok(); + }); + endpoints.MapGet("activity-links/telemetry/{testId}", (string testId) => + ComponentsActivityTestCollector.Get(testId)); + endpoints.MapDelete("activity-links/telemetry/{testId}", (string testId) => + { + ComponentsActivityTestCollector.Complete(testId); + return Results.NoContent(); + }); }); } diff --git a/src/Components/test/testassets/Components.TestServer/RazorComponents/ComponentsActivityTestCollector.cs b/src/Components/test/testassets/Components.TestServer/RazorComponents/ComponentsActivityTestCollector.cs new file mode 100644 index 000000000000..92e01e0e0fb6 --- /dev/null +++ b/src/Components/test/testassets/Components.TestServer/RazorComponents/ComponentsActivityTestCollector.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.Diagnostics; +using OpenTelemetry; +using OpenTelemetry.Trace; +using TestContentPackage; + +namespace Components.TestServer.RazorComponents; + +internal static class ComponentsActivityTestCollector +{ + private static readonly object _lock = new(); + private static readonly Dictionary> _spans = []; + private static string? _activeTestId; + + public static bool IsActive => Volatile.Read(ref _activeTestId) is not null; + + public static void Start(string testId) + { + lock (_lock) + { + _spans.TryAdd(testId, []); + Volatile.Write(ref _activeTestId, testId); + } + } + + public static void AddServer(Activity activity) + { + var testId = Volatile.Read(ref _activeTestId); + if (testId is not null) + { + Add(testId, [ActivityLinksTestSpan.FromActivity(activity)]); + } + } + + public static void Add(string testId, IEnumerable spans) + { + lock (_lock) + { + if (_spans.TryGetValue(testId, out var collected)) + { + foreach (var span in spans) + { + if (!collected.Any(existing => existing.TraceId == span.TraceId && existing.SpanId == span.SpanId)) + { + collected.Add(span); + } + } + } + } + } + + public static ActivityLinksTestSpan[] Get(string testId) + { + lock (_lock) + { + return _spans.TryGetValue(testId, out var spans) ? [.. spans] : []; + } + } + + public static void Complete(string testId) + { + lock (_lock) + { + _spans.Remove(testId); + if (_activeTestId == testId) + { + Volatile.Write(ref _activeTestId, null); + } + } + } +} + +internal sealed class ComponentsActivityTestSampler : Sampler +{ + public override SamplingResult ShouldSample(in SamplingParameters samplingParameters) + => ComponentsActivityTestCollector.IsActive + ? new SamplingResult(SamplingDecision.RecordAndSample) + : new SamplingResult(SamplingDecision.Drop); +} + +internal sealed class ComponentsActivityTestExporter : BaseExporter +{ + public override ExportResult Export(in Batch batch) + { + foreach (var activity in batch) + { + ComponentsActivityTestCollector.AddServer(activity); + } + + return ExportResult.Success; + } +} diff --git a/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/Interactivity/ActivityLinksCircuitInitialization.razor b/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/Interactivity/ActivityLinksCircuitInitialization.razor new file mode 100644 index 000000000000..6c3a3e3de4c1 --- /dev/null +++ b/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/Interactivity/ActivityLinksCircuitInitialization.razor @@ -0,0 +1,17 @@ +@page "/activity-links/{Mode}/circuit-initialization" + +

Activity links circuit initialization

+ +@if (Mode == "server") +{ + +} +else if (Mode == "webassembly") +{ + +} + +@code { + [Parameter] + public string Mode { get; set; } = ""; +} diff --git a/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/Interactivity/ActivityLinksLayout.razor b/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/Interactivity/ActivityLinksLayout.razor new file mode 100644 index 000000000000..21912ba49932 --- /dev/null +++ b/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/Interactivity/ActivityLinksLayout.razor @@ -0,0 +1,5 @@ +@inherits LayoutComponentBase + + + +@Body diff --git a/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/Interactivity/ActivityLinksNavigationOne.razor b/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/Interactivity/ActivityLinksNavigationOne.razor new file mode 100644 index 000000000000..221fce490f73 --- /dev/null +++ b/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/Interactivity/ActivityLinksNavigationOne.razor @@ -0,0 +1,11 @@ +@page "/activity-links/server/navigation/one" +@layout ActivityLinksLayout + +

Activity links navigation one

+ +Navigate + +@code { + [SupplyParameterFromQuery(Name = "activity-links-test-id")] + public string? TestId { get; set; } +} diff --git a/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/Interactivity/ActivityLinksNavigationTwo.razor b/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/Interactivity/ActivityLinksNavigationTwo.razor new file mode 100644 index 000000000000..6c783236a3e2 --- /dev/null +++ b/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/Interactivity/ActivityLinksNavigationTwo.razor @@ -0,0 +1,4 @@ +@page "/activity-links/server/navigation/two" +@layout ActivityLinksLayout + +

Activity links navigation two

diff --git a/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/Interactivity/ActivityLinksWebAssemblyLayout.razor b/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/Interactivity/ActivityLinksWebAssemblyLayout.razor new file mode 100644 index 000000000000..a6f22b2fe73d --- /dev/null +++ b/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/Interactivity/ActivityLinksWebAssemblyLayout.razor @@ -0,0 +1,5 @@ +@inherits LayoutComponentBase + + + +@Body diff --git a/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/Interactivity/ActivityLinksWebAssemblyNavigationOne.razor b/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/Interactivity/ActivityLinksWebAssemblyNavigationOne.razor new file mode 100644 index 000000000000..4d767ba894a5 --- /dev/null +++ b/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/Interactivity/ActivityLinksWebAssemblyNavigationOne.razor @@ -0,0 +1,11 @@ +@page "/activity-links/webassembly/navigation/one" +@layout ActivityLinksWebAssemblyLayout + +

Activity links navigation one

+ +Navigate + +@code { + [SupplyParameterFromQuery(Name = "activity-links-test-id")] + public string? TestId { get; set; } +} diff --git a/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/Interactivity/ActivityLinksWebAssemblyNavigationTwo.razor b/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/Interactivity/ActivityLinksWebAssemblyNavigationTwo.razor new file mode 100644 index 000000000000..06cde195eb2c --- /dev/null +++ b/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/Interactivity/ActivityLinksWebAssemblyNavigationTwo.razor @@ -0,0 +1,4 @@ +@page "/activity-links/webassembly/navigation/two" +@layout ActivityLinksWebAssemblyLayout + +

Activity links navigation two

diff --git a/src/Components/test/testassets/Components.WasmMinimal/ActivityLinksWebAssemblyExporter.cs b/src/Components/test/testassets/Components.WasmMinimal/ActivityLinksWebAssemblyExporter.cs new file mode 100644 index 000000000000..fc0d1a3863a6 --- /dev/null +++ b/src/Components/test/testassets/Components.WasmMinimal/ActivityLinksWebAssemblyExporter.cs @@ -0,0 +1,44 @@ +// 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 System.Net.Http; +using System.Net.Http.Json; +using OpenTelemetry; +using TestContentPackage; + +namespace Components.WasmMinimal; + +internal sealed class ActivityLinksWebAssemblyExporter(HttpClient client, string testId) : BaseExporter +{ + private readonly object _lock = new(); + private Task _pendingExport = Task.CompletedTask; + + public override ExportResult Export(in Batch batch) + { + var spans = new List(); + foreach (var activity in batch) + { + spans.Add(ActivityLinksTestSpan.FromActivity(activity)); + } + lock (_lock) + { + _pendingExport = ExportAfterAsync(_pendingExport, [.. spans]); + } + return ExportResult.Success; + } + + private async Task ExportAfterAsync(Task previousExport, ActivityLinksTestSpan[] spans) + { + try + { + await previousExport; + using var response = await client.PostAsJsonAsync($"activity-links/telemetry/{testId}", spans); + response.EnsureSuccessStatusCode(); + } + catch (Exception exception) + { + Console.Error.WriteLine(exception); + } + } +} diff --git a/src/Components/test/testassets/Components.WasmMinimal/Components.WasmMinimal.csproj b/src/Components/test/testassets/Components.WasmMinimal/Components.WasmMinimal.csproj index c52c9993ecb7..cc36c20bc7d7 100644 --- a/src/Components/test/testassets/Components.WasmMinimal/Components.WasmMinimal.csproj +++ b/src/Components/test/testassets/Components.WasmMinimal/Components.WasmMinimal.csproj @@ -8,11 +8,16 @@ true false true + true + true + true + true + diff --git a/src/Components/test/testassets/Components.WasmMinimal/Program.cs b/src/Components/test/testassets/Components.WasmMinimal/Program.cs index a352de4f1d3c..b2c6b44b32b6 100644 --- a/src/Components/test/testassets/Components.WasmMinimal/Program.cs +++ b/src/Components/test/testassets/Components.WasmMinimal/Program.cs @@ -2,12 +2,16 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.Globalization; +using System.Net.Http; using System.Runtime.InteropServices.JavaScript; using System.Security.Claims; using Components.TestServer.Services; +using Components.WasmMinimal; using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.Components.Web; using Microsoft.AspNetCore.Components.WebAssembly.Hosting; +using OpenTelemetry; +using OpenTelemetry.Trace; using TestContentPackage; using TestContentPackage.Services; @@ -26,6 +30,15 @@ builder.Services.AddCascadingAuthenticationState(); +if (JSImports.GetQueryParam("activity-links-test-id") is { Length: > 0 } activityLinksTestId) +{ + var client = new HttpClient { BaseAddress = new Uri(builder.HostEnvironment.BaseAddress) }; + builder.Services.AddOpenTelemetry().WithTracing(tracing => tracing + .AddSource("Microsoft.AspNetCore.Components") + .AddProcessor(new SimpleActivityExportProcessor( + new ActivityLinksWebAssemblyExporter(client, activityLinksTestId)))); +} + builder.Services.AddAuthenticationStateDeserialization(options => { var originalCallback = options.DeserializationCallback; diff --git a/src/Components/test/testassets/TestContentPackage/ActivityLinksTestTelemetry.cs b/src/Components/test/testassets/TestContentPackage/ActivityLinksTestTelemetry.cs new file mode 100644 index 000000000000..795fc558038b --- /dev/null +++ b/src/Components/test/testassets/TestContentPackage/ActivityLinksTestTelemetry.cs @@ -0,0 +1,30 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +#nullable enable + +using System.Diagnostics; + +namespace TestContentPackage; + +public sealed record ActivityLinksTestSpan( + string Name, + string TraceId, + string SpanId, + string? Route, + string? CircuitId, + ActivityLinksTestLink[] Links) +{ + public static ActivityLinksTestSpan FromActivity(Activity activity) + => new( + activity.OperationName, + activity.TraceId.ToString(), + activity.SpanId.ToString(), + activity.GetTagItem("aspnetcore.components.route") as string, + activity.GetTagItem("aspnetcore.components.circuit.id") as string, + [.. activity.Links.Select(link => new ActivityLinksTestLink( + link.Context.TraceId.ToString(), + link.Context.SpanId.ToString()))]); +} + +public sealed record ActivityLinksTestLink(string TraceId, string SpanId); diff --git a/src/Components/test/testassets/TestContentPackage/ActivityLinksTestTrigger.razor b/src/Components/test/testassets/TestContentPackage/ActivityLinksTestTrigger.razor new file mode 100644 index 000000000000..f3ea5ed93fe3 --- /dev/null +++ b/src/Components/test/testassets/TestContentPackage/ActivityLinksTestTrigger.razor @@ -0,0 +1,24 @@ +@_instanceId +@_interactive + +@_count + +@code { + private readonly string _instanceId = Guid.NewGuid().ToString("N"); + private bool _interactive; + private int _count; + + protected override void OnAfterRender(bool firstRender) + { + if (firstRender) + { + _interactive = true; + StateHasChanged(); + } + } + + private void Trigger() + { + _count++; + } +} diff --git a/src/Shared/Components/ComponentsActivityLinkStore.cs b/src/Shared/Components/ComponentsActivityLinkStore.cs index 7ada7041a949..92221c2c38ab 100644 --- a/src/Shared/Components/ComponentsActivityLinkStore.cs +++ b/src/Shared/Components/ComponentsActivityLinkStore.cs @@ -41,6 +41,64 @@ public void SetActivityContext(string category, ActivityContext activityLink, Ke _store[category] = new CategoryLink(activityLink, tag); } + public bool TryGetActivityContext(string category, out ActivityContext activityLink, out KeyValuePair? tag) + { + if (_store.TryGetValue(category, out var link)) + { + activityLink = link.Item1; + tag = link.Item2; + return true; + } + + activityLink = default; + tag = default; + return false; + } + + public void RemoveActivityContext(string category) + { + _store.Remove(category); + } + + public bool TryCreatePersistentRouteState(out ComponentsActivityPersistentState? state) + { + if (TryGetActivityContext(Route, out var context, out var tag) && + context != default && + tag is { } routeTag && + routeTag.Key == "aspnetcore.components.route" && + routeTag.Value is string route) + { + state = new ComponentsActivityPersistentState( + $"00-{context.TraceId}-{context.SpanId}-{(byte)context.TraceFlags:x2}", + context.TraceState, + context.IsRemote, + route); + return true; + } + + state = null; + return false; + } + + public void RestorePersistentRouteState(ComponentsActivityPersistentState state) + { + if (ActivityContext.TryParse( + state.TraceParent, + state.TraceState, + state.IsRemote, + out var context)) + { + SetActivityContext( + Route, + context, + new KeyValuePair("aspnetcore.components.route", state.Route)); + } + else + { + RemoveActivityContext(Route); + } + } + public void AddActivityContexts(string exceptCategory, Activity targetActivity) { foreach (var kvp in _store) @@ -64,3 +122,11 @@ public void AddActivityContexts(string exceptCategory, Activity targetActivity) [UnsafeAccessor(UnsafeAccessorKind.Method, Name = "get_ActivityLinksStore")] static extern object GetActivityLinksStore(Renderer instance); } + +internal sealed record ComponentsActivityPersistentState( + string TraceParent, + string? TraceState, + bool IsRemote, + string Route); + +internal sealed record ComponentsActivityPersistentStateUpdate(ComponentsActivityPersistentState? Route);