From 78972bbca0b7b3bbe9500ba097f57d5de605048d Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Wed, 9 Sep 2026 16:41:50 +1000 Subject: [PATCH 1/3] Make terminal-host telemetry opt-in Disable helper telemetry unless ShowTerminalHost is enabled, and preserve per-helper OTLP service identity for diagnostic runs. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 1c676f42-11fe-4ca3-9dba-8beeeca95fca --- .../ApplicationModel/TerminalAnnotation.cs | 10 +- .../TerminalResourceBuilderExtensions.cs | 42 +++--- src/Aspire.TerminalHost/TerminalHostApp.cs | 132 +++++++----------- .../TerminalHostTelemetry.cs | 10 +- src/Shared/KnownConfigNames.cs | 1 + .../Aspire.Hosting.Tests/WithTerminalTests.cs | 49 +++++++ .../TerminalHostTelemetryTests.cs | 79 +++++++++++ 7 files changed, 207 insertions(+), 116 deletions(-) create mode 100644 tests/Aspire.TerminalHost.Tests/TerminalHostTelemetryTests.cs diff --git a/src/Aspire.Hosting/ApplicationModel/TerminalAnnotation.cs b/src/Aspire.Hosting/ApplicationModel/TerminalAnnotation.cs index 2da91522c18..70e0ad3ffbf 100644 --- a/src/Aspire.Hosting/ApplicationModel/TerminalAnnotation.cs +++ b/src/Aspire.Hosting/ApplicationModel/TerminalAnnotation.cs @@ -129,17 +129,19 @@ public int Rows /// /// Gets or sets a value indicating whether the per-replica terminal host resources - /// (named {parent}-terminalhost-{index}) should appear in the resource list. + /// (named {parent}-terminalhost-{index}) should appear in the resource list + /// and export diagnostic logs, metrics, and traces. /// /// /// Defaults to false: terminal host resources are hidden from the dashboard - /// and CLI resource list because they are an implementation detail of the + /// and CLI resource list and do not export telemetry because they are an implementation detail of the /// /// feature, not something the user explicitly added to their app model. /// /// Set to true when diagnosing terminal-host startup / connectivity issues so - /// the host's state, exit code, logs, and (eventually) telemetry are visible alongside - /// the parent resource. This is useful when investigating cases like "DCP never dialed + /// the host's state, exit code, and diagnostic telemetry are visible alongside + /// the parent resource. Telemetry is exported when a dashboard OTLP endpoint is available. + /// This is useful when investigating cases like "DCP never dialed /// the producer UDS" or "the host crashed during recycle". /// /// diff --git a/src/Aspire.Hosting/TerminalResourceBuilderExtensions.cs b/src/Aspire.Hosting/TerminalResourceBuilderExtensions.cs index 70b0577e6d6..ef1f4348b3c 100644 --- a/src/Aspire.Hosting/TerminalResourceBuilderExtensions.cs +++ b/src/Aspire.Hosting/TerminalResourceBuilderExtensions.cs @@ -222,34 +222,20 @@ private static async Task MaterializeTerminalHostsAsync( appHostPid, appHostProcessIdentity); - // Wire OTLP env vars onto each terminal host so it can ship logs/traces/metrics to - // the dashboard. Without this, terminal host failures (DCP never dials, control - // socket bind fails, replica recycles in a loop) are only visible by attaching a - // debugger — there is no other log sink: the host explicitly does not write to - // stderr because DCP captures stderr into the consumer's resource log stream - // and any host-generated bytes would corrupt that view. - // - // AddOtlpEnvironment also injects OTEL_RESOURCE_ATTRIBUTES=service.instance.id=..., - // which combined with DCP's OTEL_SERVICE_NAME annotation gives each replica's - // host process a unique identity in the dashboard. We do not pin a protocol here - // (gRPC vs HTTP/protobuf); the env-callback picks the dashboard's preferred - // protocol and the host's composite `UseOtlpExporter()` honours - // OTEL_EXPORTER_OTLP_PROTOCOL — same path every ServiceDefaults-wired Aspire - // project takes. - OtlpConfigurationExtensions.AddOtlpEnvironment(terminalHost, configuration, @event.Services.GetRequiredService()); - - // Propagate ASPIRE_TERMINAL_HOST_LOG_LEVEL from the AppHost process so playground/dev - // can dial up host verbosity (e.g. "Debug") from launchSettings.json without code - // changes. The terminal host itself reads this env var and applies it to - // ILoggingBuilder.SetMinimumLevel; only relevant when OTLP is wired so the resulting - // log records reach the dashboard. - var hostLogLevel = Environment.GetEnvironmentVariable("ASPIRE_TERMINAL_HOST_LOG_LEVEL"); - if (!string.IsNullOrWhiteSpace(hostLogLevel)) + // Telemetry creates dashboard resource entries independently of IsHidden. + // Only export helper diagnostics when the user explicitly makes the host visible. + if (options.ShowTerminalHost) { - terminalHost.Annotations.Add(new EnvironmentCallbackAnnotation(ctx => + OtlpConfigurationExtensions.AddOtlpEnvironment(terminalHost, configuration, @event.Services.GetRequiredService()); + + var hostLogLevel = Environment.GetEnvironmentVariable("ASPIRE_TERMINAL_HOST_LOG_LEVEL"); + if (!string.IsNullOrWhiteSpace(hostLogLevel)) { - ctx.EnvironmentVariables["ASPIRE_TERMINAL_HOST_LOG_LEVEL"] = hostLogLevel; - })); + terminalHost.Annotations.Add(new EnvironmentCallbackAnnotation(ctx => + { + ctx.EnvironmentVariables["ASPIRE_TERMINAL_HOST_LOG_LEVEL"] = hostLogLevel; + })); + } } @event.Model.Resources.Add(terminalHost); @@ -400,6 +386,10 @@ private static void ConfigureTerminalHostAnnotations( host.Annotations.Add(new EnvironmentCallbackAnnotation(context => { + // Explicitly disable telemetry for hidden hosts even if OTLP settings or a + // diagnostic opt-in were inherited from the AppHost's environment. + context.EnvironmentVariables[KnownConfigNames.TerminalHostTelemetryEnabled] = + options.ShowTerminalHost ? "true" : "false"; context.EnvironmentVariables[KnownConfigNames.TerminalHostParentProcessId] = appHostPid.ToString(CultureInfo.InvariantCulture); context.EnvironmentVariables[KnownConfigNames.TerminalHostParentProcessStartedStable] = diff --git a/src/Aspire.TerminalHost/TerminalHostApp.cs b/src/Aspire.TerminalHost/TerminalHostApp.cs index 68aa34c70c8..af9656ede30 100644 --- a/src/Aspire.TerminalHost/TerminalHostApp.cs +++ b/src/Aspire.TerminalHost/TerminalHostApp.cs @@ -2,7 +2,9 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.Diagnostics.Tracing; +using Aspire.Hosting; using Aspire.Shared.TerminalHost; +using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; @@ -263,35 +265,25 @@ await Console.Error.WriteLineAsync($"[Aspire.TerminalHost] {ex.Message}") return 64; // EX_USAGE } - // The Aspire AppHost wires OTEL_EXPORTER_OTLP_ENDPOINT (and protocol/headers) into the - // host environment via OtlpConfigurationExtensions.AddOtlpEnvironment on each - // TerminalHostResource. When that variable isn't set — e.g. a standalone - // `dotnet run --project src/Aspire.TerminalHost` invocation for local debugging — we - // intentionally fall back to NullLoggerFactory rather than scribbling on stderr, since - // DCP captures stderr into the resource log stream and any accidental log line would - // surface as noisy resource output. The dashboard is the only intended sink. - var otlpEndpoint = Environment.GetEnvironmentVariable("OTEL_EXPORTER_OTLP_ENDPOINT"); - var otlpProtocol = Environment.GetEnvironmentVariable("OTEL_EXPORTER_OTLP_PROTOCOL"); - var otlpHeaders = Environment.GetEnvironmentVariable("OTEL_EXPORTER_OTLP_HEADERS"); - var serviceName = Environment.GetEnvironmentVariable("OTEL_SERVICE_NAME"); - var resourceAttrs = Environment.GetEnvironmentVariable("OTEL_RESOURCE_ATTRIBUTES"); - var otelEnabled = !string.IsNullOrEmpty(otlpEndpoint); - - // One-shot stderr diagnostic at startup so the dashboard's resource log tab for - // *-terminalhost-N shows whether OTLP is wired. Single line; subsequent operational - // logs go through the OTel pipeline (or NullLoggerFactory) per the gating below. - // headers length is logged (not the value) because it contains the dashboard's x-otlp-api-key: - // a missing/empty header yields 401 from the dashboard OTLP listener and silently drops - // every signal, which presents as "telemetry is wired but nothing shows up". - await Console.Error.WriteLineAsync( - $"[Aspire.TerminalHost] startup pid={Environment.ProcessId} otel={(otelEnabled ? "on" : "off")} endpoint='{otlpEndpoint}' protocol='{otlpProtocol}' headers.len={otlpHeaders?.Length ?? 0} service='{serviceName}' resource='{resourceAttrs}'") - .ConfigureAwait(false); + var configuration = new ConfigurationBuilder().AddEnvironmentVariables().Build(); + var hostBuilder = CreateTelemetryHostBuilder(configuration); ILoggerFactory loggerFactory; IHost? host = null; OtelSelfDiagnosticsListener? selfDiag = null; - if (otelEnabled) + if (hostBuilder is not null) { + var otlpEndpoint = configuration["OTEL_EXPORTER_OTLP_ENDPOINT"]; + var otlpProtocol = configuration["OTEL_EXPORTER_OTLP_PROTOCOL"]; + var otlpHeaders = configuration["OTEL_EXPORTER_OTLP_HEADERS"]; + var serviceName = configuration["OTEL_SERVICE_NAME"]; + var resourceAttrs = configuration["OTEL_RESOURCE_ATTRIBUTES"]; + + // Log only the header length: its value contains the dashboard OTLP API key. + await Console.Error.WriteLineAsync( + $"[Aspire.TerminalHost] startup pid={Environment.ProcessId} otel=on endpoint='{otlpEndpoint}' protocol='{otlpProtocol}' headers.len={otlpHeaders?.Length ?? 0} service='{serviceName}' resource='{resourceAttrs}'") + .ConfigureAwait(false); + // Surface OTLP exporter failures (cert trust, connection refused, 401, schema // mismatches) to stderr. Without this, every Warning/Error from // OpenTelemetry-Exporter-OpenTelemetryProtocol (and the SDK proper) is swallowed @@ -299,63 +291,8 @@ await Console.Error.WriteLineAsync( // Listener is disposed in the finally below. selfDiag = new OtelSelfDiagnosticsListener(); - // Configure OTel via the same composite pattern the Aspire ServiceDefaults template - // emits: a single `services.AddOpenTelemetry()...UseOtlpExporter()` chain that wires - // logs, metrics, and traces to one shared OtlpExporterOptions. The legacy per-signal - // `.AddOtlpExporter()` shorthand (three separate calls on `Sdk.Create*Builder()`) - // resolves endpoints inconsistently — under gRPC it sends to the root path and the - // dashboard's gRPC OTLP listener returns `Status(Unimplemented, "Service is - // unimplemented.")` for executable consumers, silently dropping every signal. - // UseOtlpExporter goes through the same code path every ServiceDefaults-wired - // project uses, so by definition it talks to the dashboard the way the dashboard - // expects. - // - // OTEL_SERVICE_NAME and the service.instance.id resource attribute are set by DCP - // via CustomResource.OtelServiceNameAnnotation / - // CustomResource.OtelServiceInstanceIdAnnotation on each executable; the default - // resource detector picks them up from the environment, so we don't override them. - // - // We build an IHost (via Host.CreateEmptyApplicationBuilder so we don't inherit a - // console logger — DCP captures stderr into the consumer log stream and any - // accidental log line would corrupt that view) and start it, rather than using a - // bare ServiceCollection + BuildServiceProvider. The reason: OpenTelemetry's - // tracer and meter providers are registered as DI singletons by - // `services.AddOpenTelemetry().With{Tracing,Metrics}()`, but the thing that - // instantiates them — and thereby starts the OTLP export pipelines — is - // OpenTelemetry.Extensions.Hosting's `TelemetryHostedService.StartAsync`. Without - // an IHost to run that hosted service, metrics and spans never reach the - // exporter even though the code looks correctly wired. Logs happen to work - // without IHost because resolving ILoggerFactory transitively builds the logging - // pipeline, but tracer/meter providers have no such eager resolver. - - // Minimum log level — honour ASPIRE_TERMINAL_HOST_LOG_LEVEL so playground/dev - // can crank verbosity from launchSettings.json without code changes. Recognised - // values match the Microsoft.Extensions.Logging.LogLevel enum (Trace, Debug, - // Information, Warning, Error, Critical, None). Default: Information. - var minLevel = ParseLogLevel(Environment.GetEnvironmentVariable("ASPIRE_TERMINAL_HOST_LOG_LEVEL")); - - // Use Host.CreateApplicationBuilder so we get the standard set of services every - // other .NET host gets: configuration providers, logging registrations (console + - // debug + eventsource), and ILoggerFactory wiring. The console logger writes to - // the host process's own stdout/stderr, which DCP captures into this terminal - // host's "Console logs" tab in the dashboard — completely separate from the PTY - // consumer stream, which is over the consumer UDS. So we're not corrupting - // anything by emitting console output here. - var hostBuilder = Host.CreateApplicationBuilder(); - - hostBuilder.Logging.SetMinimumLevel(minLevel); - hostBuilder.Logging.AddOpenTelemetry(logging => - { - logging.IncludeFormattedMessage = true; - logging.IncludeScopes = true; - }); - - hostBuilder.Services.AddOpenTelemetry() - .ConfigureResource(r => r.AddService(TerminalHostTelemetry.SourceName)) - .WithTracing(t => t.AddSource(TerminalHostTelemetry.SourceName)) - .WithMetrics(m => m.AddMeter(TerminalHostTelemetry.SourceName)) - .UseOtlpExporter(); - + // Starting the host eagerly initializes the tracer and meter providers through + // OpenTelemetry's hosted service, rather than only activating the logging pipeline. host = hostBuilder.Build(); await host.StartAsync(cancellationToken).ConfigureAwait(false); @@ -415,6 +352,39 @@ await Console.Error.WriteLineAsync( } } + internal static HostApplicationBuilder? CreateTelemetryHostBuilder(IConfiguration configuration) + { + // An inherited OTLP endpoint must not turn a hidden implementation detail into + // a telemetry resource. The AppHost explicitly sets this flag from ShowTerminalHost. + if (!configuration.GetValue(KnownConfigNames.TerminalHostTelemetryEnabled) || + string.IsNullOrEmpty(configuration["OTEL_EXPORTER_OTLP_ENDPOINT"])) + { + return null; + } + + var hostBuilder = Host.CreateApplicationBuilder(); + hostBuilder.Configuration.AddConfiguration(configuration); + hostBuilder.Logging.SetMinimumLevel(ParseLogLevel(configuration["ASPIRE_TERMINAL_HOST_LOG_LEVEL"])); + hostBuilder.Logging.AddOpenTelemetry(logging => + { + logging.IncludeFormattedMessage = true; + logging.IncludeScopes = true; + }); + + // Preserve DCP's service name and instance ID so telemetry matches the helper + // resource instead of creating an unrelated resource in the dashboard. + var serviceName = configuration["OTEL_SERVICE_NAME"]; + hostBuilder.Services.AddOpenTelemetry() + .ConfigureResource(r => r.AddService( + string.IsNullOrEmpty(serviceName) ? TerminalHostTelemetry.SourceName : serviceName, + autoGenerateServiceInstanceId: false)) + .WithTracing(t => t.AddSource(TerminalHostTelemetry.SourceName)) + .WithMetrics(m => m.AddMeter(TerminalHostTelemetry.SourceName)) + .UseOtlpExporter(); + + return hostBuilder; + } + // Parse a friendly LogLevel string from the env var. Case-insensitive; bad/empty values // fall back to Information. We accept the standard Microsoft.Extensions.Logging.LogLevel // names (Trace, Debug, Information, Warning, Error, Critical, None). diff --git a/src/Aspire.TerminalHost/TerminalHostTelemetry.cs b/src/Aspire.TerminalHost/TerminalHostTelemetry.cs index f09ac3132b6..3bffbeccf54 100644 --- a/src/Aspire.TerminalHost/TerminalHostTelemetry.cs +++ b/src/Aspire.TerminalHost/TerminalHostTelemetry.cs @@ -8,17 +8,17 @@ namespace Aspire.TerminalHost; /// /// Shared and -/// for the Aspire terminal host. Telemetry is exported via OTLP to the Aspire dashboard so failures +/// for the Aspire terminal host. Opted-in telemetry is exported via OTLP to the Aspire dashboard so failures /// like "DCP never dialed in" or "control socket bound but no clients" are diagnosable without /// resorting to attaching a debugger. /// /// /// /// The OTLP exporter wiring in -/// only attaches when OTEL_EXPORTER_OTLP_ENDPOINT is set in the environment. The Aspire -/// AppHost injects that via OtlpConfigurationExtensions.AddOtlpEnvironment on each -/// TerminalHostResource, so production runs always have it. Standalone debug runs of the -/// host (dotnet run --project src/Aspire.TerminalHost) drop telemetry silently. +/// only attaches when ASPIRE_TERMINAL_HOST_TELEMETRY_ENABLED is true and +/// OTEL_EXPORTER_OTLP_ENDPOINT is set. The Aspire AppHost configures these for each +/// TerminalHostResource when TerminalOptions.ShowTerminalHost is enabled. +/// Standalone diagnostic runs must also explicitly enable telemetry and configure an endpoint. /// /// /// Source / meter names follow the assembly name convention diff --git a/src/Shared/KnownConfigNames.cs b/src/Shared/KnownConfigNames.cs index 8aa855a3b3c..97e4b4403e5 100644 --- a/src/Shared/KnownConfigNames.cs +++ b/src/Shared/KnownConfigNames.cs @@ -46,6 +46,7 @@ internal static class KnownConfigNames // this identity so it can shut down and unlink its sockets if the AppHost disappears. public const string TerminalHostParentProcessId = "ASPIRE_TERMINAL_HOST_PARENT_PID"; public const string TerminalHostParentProcessStartedStable = "ASPIRE_TERMINAL_HOST_PARENT_STARTED_STABLE"; + public const string TerminalHostTelemetryEnabled = "ASPIRE_TERMINAL_HOST_TELEMETRY_ENABLED"; // Identity (PID + start time) of the foreground CLI that spawned a detached `aspire start` / // `aspire run --detach` child. The detached child watches this during startup and tears the diff --git a/tests/Aspire.Hosting.Tests/WithTerminalTests.cs b/tests/Aspire.Hosting.Tests/WithTerminalTests.cs index e7d621e4ad8..47a68f3e012 100644 --- a/tests/Aspire.Hosting.Tests/WithTerminalTests.cs +++ b/tests/Aspire.Hosting.Tests/WithTerminalTests.cs @@ -5,6 +5,7 @@ using System.Reflection; using System.Text.Json; using Aspire.Hosting.Testing; +using Aspire.Hosting.Tests.Utils; using Aspire.Hosting.Lifecycle; using Aspire.Hosting.Utils; using Aspire.Shared.TerminalHost; @@ -274,6 +275,54 @@ public async Task ShowTerminalHostOptionMakesTerminalHostsVisible() } } + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task TerminalHostTelemetryFollowsVisibility(bool showTerminalHost) + { + using var builder = CreateBuilder(); + const string otlpEndpoint = "http://localhost:4317"; + builder.Configuration[KnownConfigNames.DashboardOtlpGrpcEndpointUrl] = otlpEndpoint; + builder.Configuration[KnownConfigNames.TerminalHostTelemetryEnabled] = "true"; + + var resource = builder.AddExecutable("myapp", "myapp", ".") + .WithAnnotation(new ReplicaAnnotation(2)) + .WithOtlpExporter() + .WithTerminal(options => options.ShowTerminalHost = showTerminalHost); + + await using var app = builder.Build(); + var model = app.Services.GetRequiredService(); + await builder.Eventing.PublishAsync(new BeforeStartEvent(app.Services, model)); + + var hosts = resource.Resource.Annotations.OfType().Single().TerminalHosts; + Assert.Equal(2, hosts.Count); + foreach (var host in hosts) + { + var environment = await EnvironmentVariableEvaluator.GetEnvironmentVariablesAsync(host, serviceProvider: app.Services); + Assert.Equal(showTerminalHost ? "true" : "false", environment[KnownConfigNames.TerminalHostTelemetryEnabled]); + + if (showTerminalHost) + { + Assert.Equal(otlpEndpoint, environment["OTEL_EXPORTER_OTLP_ENDPOINT"]); + Assert.Equal("grpc", environment["OTEL_EXPORTER_OTLP_PROTOCOL"]); + } + else + { + Assert.Equal( + [ + KnownConfigNames.TerminalHostParentProcessId, + KnownConfigNames.TerminalHostParentProcessStartedStable, + KnownConfigNames.TerminalHostTelemetryEnabled, + ], + environment.Keys.Order(StringComparer.Ordinal)); + } + } + + var parentEnvironment = await EnvironmentVariableEvaluator.GetEnvironmentVariablesAsync(resource.Resource, serviceProvider: app.Services); + Assert.Equal(otlpEndpoint, parentEnvironment["OTEL_EXPORTER_OTLP_ENDPOINT"]); + Assert.False(parentEnvironment.ContainsKey(KnownConfigNames.TerminalHostTelemetryEnabled)); + } + [Fact] public async Task WithTerminalCleansUpPerReplicaFilesOnApplicationStopped() { diff --git a/tests/Aspire.TerminalHost.Tests/TerminalHostTelemetryTests.cs b/tests/Aspire.TerminalHost.Tests/TerminalHostTelemetryTests.cs new file mode 100644 index 00000000000..ab7e07156eb --- /dev/null +++ b/tests/Aspire.TerminalHost.Tests/TerminalHostTelemetryTests.cs @@ -0,0 +1,79 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.Hosting; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using OpenTelemetry; +using OpenTelemetry.Logs; +using OpenTelemetry.Metrics; +using OpenTelemetry.Trace; + +namespace Aspire.TerminalHost.Tests; + +[Collection(nameof(TerminalHostAppTestsCollection))] +public class TerminalHostTelemetryTests +{ + [Theory] + [InlineData(null)] + [InlineData("false")] + public void TelemetryRequiresExplicitOptIn(string? enabled) + { + var configuration = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary + { + [KnownConfigNames.TerminalHostTelemetryEnabled] = enabled, + ["OTEL_EXPORTER_OTLP_ENDPOINT"] = "http://localhost:4317", + ["OTEL_SERVICE_NAME"] = "myapp-terminalhost-0", + ["ASPIRE_TERMINAL_HOST_LOG_LEVEL"] = "Trace", + }).Build(); + + Assert.Null(TerminalHostApp.CreateTelemetryHostBuilder(configuration)); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + public void TelemetryRequiresOtlpEndpoint(string? endpoint) + { + var configuration = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary + { + [KnownConfigNames.TerminalHostTelemetryEnabled] = "true", + ["OTEL_EXPORTER_OTLP_ENDPOINT"] = endpoint, + }).Build(); + + Assert.Null(TerminalHostApp.CreateTelemetryHostBuilder(configuration)); + } + + [Theory] + [InlineData("myapp-terminalhost-0", "myapp-terminalhost-0")] + [InlineData("myapp-terminalhost-1", "myapp-terminalhost-1")] + [InlineData(null, TerminalHostTelemetry.SourceName)] + [InlineData("", TerminalHostTelemetry.SourceName)] + public void EnabledTelemetryRegistersAllSignalsWithResourceIdentity(string? serviceName, string expectedServiceName) + { + var configuration = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary + { + [KnownConfigNames.TerminalHostTelemetryEnabled] = "true", + ["OTEL_EXPORTER_OTLP_ENDPOINT"] = "http://localhost:4317", + ["OTEL_SERVICE_NAME"] = serviceName, + ["OTEL_RESOURCE_ATTRIBUTES"] = "service.instance.id=test-instance", + ["ASPIRE_TERMINAL_HOST_LOG_LEVEL"] = "Debug", + }).Build(); + + var builder = Assert.IsType(TerminalHostApp.CreateTelemetryHostBuilder(configuration)); + using var host = builder.Build(); + var tracerProvider = host.Services.GetRequiredService(); + var meterProvider = host.Services.GetRequiredService(); + Assert.Single(host.Services.GetServices().OfType()); + + Assert.Equal(expectedServiceName, tracerProvider.GetResource().Attributes.Single(attribute => attribute.Key == "service.name").Value); + Assert.Equal(expectedServiceName, meterProvider.GetResource().Attributes.Single(attribute => attribute.Key == "service.name").Value); + Assert.Equal("test-instance", tracerProvider.GetResource().Attributes.Single(attribute => attribute.Key == "service.instance.id").Value); + Assert.Equal("test-instance", meterProvider.GetResource().Attributes.Single(attribute => attribute.Key == "service.instance.id").Value); + Assert.True(TerminalHostTelemetry.ActivitySource.HasListeners()); + Assert.True(TerminalHostTelemetry.UpstreamRecycles.Enabled); + Assert.True(host.Services.GetRequiredService>().IsEnabled(LogLevel.Debug)); + } +} From 6ac0f05050189f8ea3f693ac4cb822c730606076 Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Fri, 11 Sep 2026 16:59:17 +1000 Subject: [PATCH 2/3] Fail closed for malformed terminal telemetry opt-in Use bool.TryParse so invalid diagnostic configuration leaves telemetry disabled without terminating the terminal host. Cover empty, whitespace, malformed, and numeric values. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 1c676f42-11fe-4ca3-9dba-8beeeca95fca --- src/Aspire.TerminalHost/TerminalHostApp.cs | 4 +++- tests/Aspire.TerminalHost.Tests/TerminalHostTelemetryTests.cs | 4 ++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/src/Aspire.TerminalHost/TerminalHostApp.cs b/src/Aspire.TerminalHost/TerminalHostApp.cs index af9656ede30..f43505db21a 100644 --- a/src/Aspire.TerminalHost/TerminalHostApp.cs +++ b/src/Aspire.TerminalHost/TerminalHostApp.cs @@ -356,7 +356,9 @@ await Console.Error.WriteLineAsync( { // An inherited OTLP endpoint must not turn a hidden implementation detail into // a telemetry resource. The AppHost explicitly sets this flag from ShowTerminalHost. - if (!configuration.GetValue(KnownConfigNames.TerminalHostTelemetryEnabled) || + // Malformed values (e.g. "not-a-bool") must leave telemetry disabled, not stop the terminal. + if (!bool.TryParse(configuration[KnownConfigNames.TerminalHostTelemetryEnabled], out var telemetryEnabled) || + !telemetryEnabled || string.IsNullOrEmpty(configuration["OTEL_EXPORTER_OTLP_ENDPOINT"])) { return null; diff --git a/tests/Aspire.TerminalHost.Tests/TerminalHostTelemetryTests.cs b/tests/Aspire.TerminalHost.Tests/TerminalHostTelemetryTests.cs index ab7e07156eb..a86cb564aca 100644 --- a/tests/Aspire.TerminalHost.Tests/TerminalHostTelemetryTests.cs +++ b/tests/Aspire.TerminalHost.Tests/TerminalHostTelemetryTests.cs @@ -19,6 +19,10 @@ public class TerminalHostTelemetryTests [Theory] [InlineData(null)] [InlineData("false")] + [InlineData("")] + [InlineData(" ")] + [InlineData("not-a-bool")] + [InlineData("1")] public void TelemetryRequiresExplicitOptIn(string? enabled) { var configuration = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary From ad937d49401cb960ed6f2cb828a878095986aea0 Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Fri, 11 Sep 2026 18:34:32 +1000 Subject: [PATCH 3/3] Use shared configuration parsing for terminal telemetry Link IConfigurationExtensions into TerminalHost using the existing non-hosting attribute shim pattern, and use GetBool with a false default. Cover zero and nonzero numeric opt-in values alongside malformed input. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 1c676f42-11fe-4ca3-9dba-8beeeca95fca --- .../Aspire.TerminalHost.csproj | 2 ++ src/Aspire.TerminalHost/TerminalHostApp.cs | 3 +-- src/Shared/IConfigurationExtensions.cs | 4 ++-- .../TerminalHostTelemetryTests.cs | 16 +++++++++------- 4 files changed, 14 insertions(+), 11 deletions(-) diff --git a/src/Aspire.TerminalHost/Aspire.TerminalHost.csproj b/src/Aspire.TerminalHost/Aspire.TerminalHost.csproj index 58b1c737a8d..63c50fa7c5a 100644 --- a/src/Aspire.TerminalHost/Aspire.TerminalHost.csproj +++ b/src/Aspire.TerminalHost/Aspire.TerminalHost.csproj @@ -7,6 +7,7 @@ win-x64;win-arm64;linux-x64;linux-arm64;linux-musl-x64;osx-x64;osx-arm64 enable enable + $(DefineConstants);ASPIRE_TERMINAL_HOST true Major