diff --git a/sdk/monitor/Azure.Monitor.OpenTelemetry.Exporter/CHANGELOG.md b/sdk/monitor/Azure.Monitor.OpenTelemetry.Exporter/CHANGELOG.md index 22ccda883224..2bdde4fdf395 100644 --- a/sdk/monitor/Azure.Monitor.OpenTelemetry.Exporter/CHANGELOG.md +++ b/sdk/monitor/Azure.Monitor.OpenTelemetry.Exporter/CHANGELOG.md @@ -16,6 +16,9 @@ - Extended multi-endpoint routing to metrics, off by default and enabled with the same `Azure.Monitor.OpenTelemetry.EnableMultiEndpointRouting` AppContext switch used for traces and logs. A measurement carrying the `microsoft.instrumentation_key` and `microsoft.ingestion_endpoint` dimensions is sent to that endpoint instead of the exporter's own; measurements without both are dropped. Unlike traces and logs, the routing values are supplied as dimensions at measurement time rather than stamped afterwards, because a metric's dimensions are part of its aggregation key: each destination becomes its own time series, and one instrument can feed several endpoints. Routing is applied per `MetricPoint`, the three routing dimensions are consumed rather than emitted as custom properties, and `microsoft.multi_endpoint_cloud_role` sets `ai.cloud.role` as it does for traces and logs. Standard metrics and performance counters are not collected while the switch is on: routed destinations are not sent standard metrics, and a process-scoped performance counter has no single owner among the destinations a routed process carries. +- A connection string is no longer required when multi-endpoint routing is enabled. Routing takes every destination from the telemetry itself, so a process that only routes has no component of its own to name, and previously had to nominate one that received nothing. Telemetry carrying valid routing attributes is still sent to the endpoint it names; telemetry without them is dropped, as it already was. SDK statistics are not collected in this configuration, because they identify a component by instrumentation key and select their region from its ingestion endpoint, neither of which exists without a connection string. Behaviour is unchanged when a connection string is configured, and unchanged when the switch is off: a missing connection string is still an error. + ([#63004](https://github.com/Azure/azure-sdk-for-net/pull/63004)) + ### Breaking Changes ### Bugs Fixed diff --git a/sdk/monitor/Azure.Monitor.OpenTelemetry.Exporter/src/Internals/AzureMonitorTransmitter.cs b/sdk/monitor/Azure.Monitor.OpenTelemetry.Exporter/src/Internals/AzureMonitorTransmitter.cs index fb0a65868488..7120b9e5551c 100644 --- a/sdk/monitor/Azure.Monitor.OpenTelemetry.Exporter/src/Internals/AzureMonitorTransmitter.cs +++ b/sdk/monitor/Azure.Monitor.OpenTelemetry.Exporter/src/Internals/AzureMonitorTransmitter.cs @@ -66,8 +66,7 @@ internal AzureMonitorTransmitter(AzureMonitorExporterOptions options, IPlatform options.Retry.MaxRetries = 0; - _connectionVars = InitializeConnectionVars(options, platform); - + _connectionVars = InitializeConnectionVars(options, platform, multiEndpointEnabled); _transmissionStateManager = new TransmissionStateManager(_connectionVars.IngestionEndpoint); _applicationInsightsRestClient = InitializeRestClient(options, _connectionVars, out _isAadEnabled); @@ -87,9 +86,15 @@ internal AzureMonitorTransmitter(AzureMonitorExporterOptions options, IPlatform _storageDirectory = storageDirectory; - _statsbeat = InitializeStatsbeat(options, _connectionVars, platform); + // Statsbeat picks its region from the configured ingestion endpoint and attributes every + // measurement to the configured key. With no connection string there is neither, and a + // routed destination cannot supply them: it is chosen per item, long after this runs. + _statsbeat = _connectionVars.IsUnconfigured ? null : InitializeStatsbeat(options, _connectionVars, platform); - if (_fileBlobProvider != null) + // Nothing can be addressed to a component the process does not have, so the handler would + // drain an empty directory to an endpoint that names nobody. Not creating it keeps that + // impossible rather than merely unused. + if (_fileBlobProvider != null && !_connectionVars.IsUnconfigured) { _transmitFromStorageHandler = new TransmitFromStorageHandler(_applicationInsightsRestClient, _fileBlobProvider, _transmissionStateManager, _connectionVars, _isAadEnabled, _statsbeat?.NetworkSdkStatsManager, storageDirectory); } @@ -104,6 +109,9 @@ internal AzureMonitorTransmitter(AzureMonitorExporterOptions options, IPlatform } internal static ConnectionVars InitializeConnectionVars(AzureMonitorExporterOptions options, IPlatform platform) + => InitializeConnectionVars(options, platform, multiEndpointEnabled: false); + + internal static ConnectionVars InitializeConnectionVars(AzureMonitorExporterOptions options, IPlatform platform, bool multiEndpointEnabled) { if (options.ConnectionString == null) { @@ -119,6 +127,15 @@ internal static ConnectionVars InitializeConnectionVars(AzureMonitorExporterOpti return ConnectionStringParser.GetValues(options.ConnectionString); } + // Routing takes every destination from the telemetry, so a process that only routes has + // no component of its own to name. Without routing there is nowhere to send anything. + if (multiEndpointEnabled) + { + AzureMonitorExporterEventSource.Log.RoutingWithoutConnectionString(); + + return ConnectionVars.CreateUnconfigured(); + } + throw new InvalidOperationException("A connection string was not found. Please set your connection string."); } @@ -157,10 +174,28 @@ private static ApplicationInsightsRestClient InitializeRestClient(AzureMonitorEx { try { + // Dropping a connection string moves this directory, stranding whatever the prior + // configuration persisted, exactly as changing an instrumentation key does today. storageDirectory = StorageHelper.GetStorageDirectory( platform: platform, configuredStorageDirectory: configuredStorageDirectory, - instrumentationKey: connectionVars.InstrumentationKey); + instrumentationKey: connectionVars.InstrumentationKey, + omitInstrumentationKey: connectionVars.IsUnconfigured); + + // The directory is still needed: it roots the per-destination storage that routed + // telemetry persists into. A provider here would not be, though. Nothing writes to + // it, because every send is routed, and nothing drains it, because there is no + // component to drain it to. Building one would only create an empty directory and + // a maintenance timer that outlive the process's usefulness. + // + // Returning before the event matters as much as returning at all: announcing + // persistent storage here is where an operator would start when asking why a + // backlog is not moving, and there is no backlog to move. Event 77 has already + // explained the configuration. + if (connectionVars.IsUnconfigured) + { + return null; + } AzureMonitorExporterEventSource.Log.InitializedPersistentStorage(connectionVars.InstrumentationKey, storageDirectory); @@ -544,6 +579,17 @@ public async ValueTask TrackAsync(IEnumerable telem return result; } + // This is the unrouted path: it sends to the configured endpoint under the configured + // key. With no connection string, both are placeholders that seed the REST client, so a + // send here would deliver a customer's telemetry to a host that names nobody, stamped + // with an empty key. Callers are all gated already; refusing here is what keeps that + // true when a new one is added. + if (_connectionVars.IsUnconfigured) + { + AzureMonitorExporterEventSource.Log.DroppedUnroutedTelemetryWithoutConnectionString(); + return result; + } + var blobProvider = _fileBlobProvider; if (IsPersistOnly && blobProvider != null) { diff --git a/sdk/monitor/Azure.Monitor.OpenTelemetry.Exporter/src/Internals/ConnectionString/ConnectionVars.cs b/sdk/monitor/Azure.Monitor.OpenTelemetry.Exporter/src/Internals/ConnectionString/ConnectionVars.cs index e92bd5b62904..0d2e86e78478 100644 --- a/sdk/monitor/Azure.Monitor.OpenTelemetry.Exporter/src/Internals/ConnectionString/ConnectionVars.cs +++ b/sdk/monitor/Azure.Monitor.OpenTelemetry.Exporter/src/Internals/ConnectionString/ConnectionVars.cs @@ -9,13 +9,35 @@ namespace Azure.Monitor.OpenTelemetry.Exporter.Internals.ConnectionString internal class ConnectionVars { public ConnectionVars(string instrumentationKey, string ingestionEndpoint, string liveEndpoint, string? aadAudience) + : this(instrumentationKey, ingestionEndpoint, liveEndpoint, aadAudience, isUnconfigured: false) + { + } + + private ConnectionVars(string instrumentationKey, string ingestionEndpoint, string liveEndpoint, string? aadAudience, bool isUnconfigured) { this.InstrumentationKey = instrumentationKey; this.IngestionEndpoint = ingestionEndpoint; this.LiveEndpoint = liveEndpoint; this.AadAudience = aadAudience; + this.IsUnconfigured = isUnconfigured; } + /// + /// Stands in for a connection string the process does not have. The endpoint seeds the REST + /// client host, which every routed send replaces with the endpoint its own group names. + /// + /// + /// The endpoint is deliberately the same value defaults + /// to, so it cannot be told apart from a real destination by inspection. Callers must branch + /// on rather than on the values. + /// + public static ConnectionVars CreateUnconfigured() => new( + instrumentationKey: string.Empty, + ingestionEndpoint: Constants.DefaultIngestionEndpoint, + liveEndpoint: Constants.DefaultLiveEndpoint, + aadAudience: null, + isUnconfigured: true); + public string InstrumentationKey { get; } public string IngestionEndpoint { get; } @@ -23,5 +45,8 @@ public ConnectionVars(string instrumentationKey, string ingestionEndpoint, strin public string LiveEndpoint { get; } public string? AadAudience { get; } + + /// No connection string was configured, so these values name no destination. + public bool IsUnconfigured { get; } } } diff --git a/sdk/monitor/Azure.Monitor.OpenTelemetry.Exporter/src/Internals/CustomerSdkStats/CustomerSdkStatsRegistration.cs b/sdk/monitor/Azure.Monitor.OpenTelemetry.Exporter/src/Internals/CustomerSdkStats/CustomerSdkStatsRegistration.cs index 229eb72c29b9..07894d5c0f4d 100644 --- a/sdk/monitor/Azure.Monitor.OpenTelemetry.Exporter/src/Internals/CustomerSdkStats/CustomerSdkStatsRegistration.cs +++ b/sdk/monitor/Azure.Monitor.OpenTelemetry.Exporter/src/Internals/CustomerSdkStats/CustomerSdkStatsRegistration.cs @@ -3,6 +3,7 @@ using System; using Azure.Monitor.OpenTelemetry.Exporter.Internals.Diagnostics; +using Azure.Monitor.OpenTelemetry.Exporter.Internals.Platform; using OpenTelemetry; using OpenTelemetry.Metrics; @@ -18,12 +19,27 @@ internal static class CustomerSdkStatsRegistration /// /// Azure Monitor exporter options public static void RegisterCustomerSdkStats(AzureMonitorExporterOptions options) + => RegisterCustomerSdkStats(options, DefaultPlatform.Instance); + + /// + /// Overload taking the platform so the connection string can be read from the same source the + /// transmitter reads it from. + /// + internal static void RegisterCustomerSdkStats(AzureMonitorExporterOptions options, IPlatform platform) { if (!CustomerSdkStatsHelper.IsEnabled()) { return; } + // These statistics are addressed to the customer's own component. Without a connection + // string there is none, and the exporter built below would send them to whatever host + // seeded the REST client under an empty instrumentation key. + if (!HasConnectionString(options, platform)) + { + return; + } + try { // Create a separate MeterProvider for customer SDK stats with 15-minute interval @@ -77,5 +93,14 @@ internal static AzureMonitorExporterOptions CreateCustomerSdkStatsOptions(AzureM return options; } + + /// + /// Reads what the transmitter reads. It is deliberately the more permissive of the two: the + /// transmitter treats only as absent and would reject an empty or + /// whitespace string outright, so anything this rejects would have failed there anyway. + /// + internal static bool HasConnectionString(AzureMonitorExporterOptions options, IPlatform platform) + => !string.IsNullOrWhiteSpace(options.ConnectionString) + || !string.IsNullOrWhiteSpace(platform.GetEnvironmentVariable(EnvironmentVariableConstants.APPLICATIONINSIGHTS_CONNECTION_STRING)); } } diff --git a/sdk/monitor/Azure.Monitor.OpenTelemetry.Exporter/src/Internals/Diagnostics/AzureMonitorExporterEventSource.cs b/sdk/monitor/Azure.Monitor.OpenTelemetry.Exporter/src/Internals/Diagnostics/AzureMonitorExporterEventSource.cs index a9918c901f9f..204b10b21e49 100644 --- a/sdk/monitor/Azure.Monitor.OpenTelemetry.Exporter/src/Internals/Diagnostics/AzureMonitorExporterEventSource.cs +++ b/sdk/monitor/Azure.Monitor.OpenTelemetry.Exporter/src/Internals/Diagnostics/AzureMonitorExporterEventSource.cs @@ -683,5 +683,11 @@ public void RoutedInstrumentDropped(string meterName, string instrumentName, Mul [Event(76, Message = "Measurements from meter '{0}' instrument '{1}' were dropped because they could not be routed. Reason: {2}. Reported once per instrument per export; enable Verbose for the individual points.", Level = EventLevel.Informational)] public void RoutedInstrumentDropped(string meterName, string instrumentName, string reason) => WriteEvent(76, meterName, instrumentName, reason); + + [Event(77, Message = "No connection string was configured. Multi-endpoint routing is enabled, so telemetry carrying valid routing attributes is still sent to the endpoint it names; everything else is dropped, and this process can send nothing of its own. SDK statistics are not collected, because they identify a component this process does not have.", Level = EventLevel.Warning)] + public void RoutingWithoutConnectionString() => WriteEvent(77); + + [Event(78, Message = "Telemetry that was not routed has been dropped because no connection string is configured, so there is no destination of this process's own to send it to.", Level = EventLevel.Warning)] + public void DroppedUnroutedTelemetryWithoutConnectionString() => WriteEvent(78); } } diff --git a/sdk/monitor/Azure.Monitor.OpenTelemetry.Exporter/src/Internals/PersistentStorage/StorageHelper.cs b/sdk/monitor/Azure.Monitor.OpenTelemetry.Exporter/src/Internals/PersistentStorage/StorageHelper.cs index ef5add00632c..2c97f8a08622 100644 --- a/sdk/monitor/Azure.Monitor.OpenTelemetry.Exporter/src/Internals/PersistentStorage/StorageHelper.cs +++ b/sdk/monitor/Azure.Monitor.OpenTelemetry.Exporter/src/Internals/PersistentStorage/StorageHelper.cs @@ -12,6 +12,9 @@ namespace Azure.Monitor.OpenTelemetry.Exporter.Internals.PersistentStorage internal static class StorageHelper { internal static string GetStorageDirectory(IPlatform platform, string? configuredStorageDirectory, string instrumentationKey) + => GetStorageDirectory(platform, configuredStorageDirectory, instrumentationKey, omitInstrumentationKey: false); + + internal static string GetStorageDirectory(IPlatform platform, string? configuredStorageDirectory, string instrumentationKey, bool omitInstrumentationKey) { // get root directory var rootDirectory = configuredStorageDirectory @@ -22,7 +25,15 @@ internal static string GetStorageDirectory(IPlatform platform, string? configure var userName = platform.GetEnvironmentUserName(); var processName = platform.GetCurrentProcessName(); var applicationDirectory = platform.GetApplicationBaseDirectory(); - string subDirectory = HashHelper.GetSHA256Hash($"{instrumentationKey};{userName};{processName};{applicationDirectory}"); + + // The caller decides, never the value: a configured connection string can legitimately + // trim to an empty key, and inferring from that would move an existing directory and + // strand the backlog inside it. + string seed = omitInstrumentationKey + ? $"{userName};{processName};{applicationDirectory}" + : $"{instrumentationKey};{userName};{processName};{applicationDirectory}"; + + string subDirectory = HashHelper.GetSHA256Hash(seed); return Path.Combine(rootDirectory, subDirectory); } diff --git a/sdk/monitor/Azure.Monitor.OpenTelemetry.Exporter/tests/Azure.Monitor.OpenTelemetry.Exporter.Demo/Logs/MultiEndpointLogDemo.cs b/sdk/monitor/Azure.Monitor.OpenTelemetry.Exporter/tests/Azure.Monitor.OpenTelemetry.Exporter.Demo/Logs/MultiEndpointLogDemo.cs index 9db5dec74a2f..9e50d3b87955 100644 --- a/sdk/monitor/Azure.Monitor.OpenTelemetry.Exporter/tests/Azure.Monitor.OpenTelemetry.Exporter.Demo/Logs/MultiEndpointLogDemo.cs +++ b/sdk/monitor/Azure.Monitor.OpenTelemetry.Exporter/tests/Azure.Monitor.OpenTelemetry.Exporter.Demo/Logs/MultiEndpointLogDemo.cs @@ -32,7 +32,7 @@ internal sealed class MultiEndpointLogDemo : IDisposable private readonly ILoggerFactory _loggerFactory; private readonly EndpointRoutingLogProcessor _routingProcessor; - public MultiEndpointLogDemo(string exporterConnectionString, IReadOnlyList routes, string runId, bool faultRoutedEndpoints = false) + public MultiEndpointLogDemo(string? exporterConnectionString, IReadOnlyList routes, string runId, bool faultRoutedEndpoints = false) { _routingProcessor = new EndpointRoutingLogProcessor(routes, runId); @@ -58,7 +58,10 @@ public MultiEndpointLogDemo(string exporterConnectionString, IReadOnlyList { - o.ConnectionString = exporterConnectionString; + if (!string.IsNullOrWhiteSpace(exporterConnectionString)) + { + o.ConnectionString = exporterConnectionString; + } if (faultRoutedEndpoints) { diff --git a/sdk/monitor/Azure.Monitor.OpenTelemetry.Exporter/tests/Azure.Monitor.OpenTelemetry.Exporter.Demo/Metrics/MultiEndpointMetricDemo.cs b/sdk/monitor/Azure.Monitor.OpenTelemetry.Exporter/tests/Azure.Monitor.OpenTelemetry.Exporter.Demo/Metrics/MultiEndpointMetricDemo.cs index ebbb212ac30c..62ed2bac0d63 100644 --- a/sdk/monitor/Azure.Monitor.OpenTelemetry.Exporter/tests/Azure.Monitor.OpenTelemetry.Exporter.Demo/Metrics/MultiEndpointMetricDemo.cs +++ b/sdk/monitor/Azure.Monitor.OpenTelemetry.Exporter/tests/Azure.Monitor.OpenTelemetry.Exporter.Demo/Metrics/MultiEndpointMetricDemo.cs @@ -48,7 +48,7 @@ internal sealed class MultiEndpointMetricDemo : IDisposable private readonly Dictionary _counts = new(StringComparer.Ordinal); private readonly Dictionary _unroutableCounts = new(StringComparer.Ordinal); - public MultiEndpointMetricDemo(string exporterConnectionString, IReadOnlyList routes, string runId, bool faultRoutedEndpoints = false) + public MultiEndpointMetricDemo(string? exporterConnectionString, IReadOnlyList routes, string runId, bool faultRoutedEndpoints = false) { _routes = routes; _runId = runId; @@ -74,7 +74,10 @@ public MultiEndpointMetricDemo(string exporterConnectionString, IReadOnlyList { - o.ConnectionString = exporterConnectionString; + if (!string.IsNullOrWhiteSpace(exporterConnectionString)) + { + o.ConnectionString = exporterConnectionString; + } if (faultRoutedEndpoints) { diff --git a/sdk/monitor/Azure.Monitor.OpenTelemetry.Exporter/tests/Azure.Monitor.OpenTelemetry.Exporter.Demo/Program.cs b/sdk/monitor/Azure.Monitor.OpenTelemetry.Exporter/tests/Azure.Monitor.OpenTelemetry.Exporter.Demo/Program.cs index f156e03f8903..8fde327b0778 100644 --- a/sdk/monitor/Azure.Monitor.OpenTelemetry.Exporter/tests/Azure.Monitor.OpenTelemetry.Exporter.Demo/Program.cs +++ b/sdk/monitor/Azure.Monitor.OpenTelemetry.Exporter/tests/Azure.Monitor.OpenTelemetry.Exporter.Demo/Program.cs @@ -31,6 +31,22 @@ public class Program /// private const string RouteConnectionStringsVariable = "MULTIENDPOINT_ROUTE_CONNECTION_STRINGS"; + /// + /// The exporter falls back to this on its own, so a 'nohost' run would report that it has no + /// connection string while quietly having one. + /// + private static bool CanRunWithoutAHost() + { + if (string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable("APPLICATIONINSIGHTS_CONNECTION_STRING"))) + { + return true; + } + + Console.WriteLine("APPLICATIONINSIGHTS_CONNECTION_STRING is set, so the exporter would pick it up and"); + Console.WriteLine("this run would not exercise the unconfigured path. Clear it and run again."); + return false; + } + public static void Main(string[] args) { if (args.Length > 0 && string.Equals(args[0], "multiendpoint", StringComparison.OrdinalIgnoreCase)) @@ -38,6 +54,7 @@ public static void Main(string[] args) var faultEndpoints = Array.Exists(args, a => string.Equals(a, "down", StringComparison.OrdinalIgnoreCase)); var logs = Array.Exists(args, a => string.Equals(a, "logs", StringComparison.OrdinalIgnoreCase)); var metrics = Array.Exists(args, a => string.Equals(a, "metrics", StringComparison.OrdinalIgnoreCase)); + var noHost = Array.Exists(args, a => string.Equals(a, "nohost", StringComparison.OrdinalIgnoreCase)); var count = 1000; foreach (var arg in args) @@ -51,15 +68,15 @@ public static void Main(string[] args) if (metrics) { - RunMultiEndpointMetricDemo(count, faultEndpoints); + RunMultiEndpointMetricDemo(count, faultEndpoints, noHost); } else if (logs) { - RunMultiEndpointLogDemo(count, faultEndpoints); + RunMultiEndpointLogDemo(count, faultEndpoints, noHost); } else { - RunMultiEndpointDemo(count, faultEndpoints); + RunMultiEndpointDemo(count, faultEndpoints, noHost); } return; @@ -82,16 +99,22 @@ public static void Main(string[] args) Console.ReadLine(); } - private static void RunMultiEndpointDemo(int activityCount, bool faultEndpoints) + private static void RunMultiEndpointDemo(int activityCount, bool faultEndpoints, bool noHost) { - var hostConnectionString = Environment.GetEnvironmentVariable(HostConnectionStringVariable); + if (noHost && !CanRunWithoutAHost()) + { + return; + } + + var hostConnectionString = noHost ? null : Environment.GetEnvironmentVariable(HostConnectionStringVariable); var routes = ParseRoutes(Environment.GetEnvironmentVariable(RouteConnectionStringsVariable)); - if (string.IsNullOrWhiteSpace(hostConnectionString) || routes.Count == 0) + if ((!noHost && string.IsNullOrWhiteSpace(hostConnectionString)) || routes.Count == 0) { Console.WriteLine($"Set {HostConnectionStringVariable} to the exporter's own connection string,"); Console.WriteLine($"and {RouteConnectionStringsVariable} to a comma-separated list of one connection"); Console.WriteLine("string per destination, using components in different regions."); + Console.WriteLine("Pass 'nohost' to run with no connection string of the exporter's own."); return; } @@ -105,6 +128,7 @@ private static void RunMultiEndpointDemo(int activityCount, bool faultEndpoints) var distinctEndpoints = new HashSet(routes.ConvertAll(r => r.IngestionEndpoint), StringComparer.Ordinal).Count; Console.WriteLine($"Run id : {runId}"); + Console.WriteLine($"Host : {(noHost ? "NONE (no connection string of its own)" : "configured")}"); Console.WriteLine($"Activities : {activityCount} requests, each with one dependency"); Console.WriteLine($"Routes : {string.Join(", ", routes.ConvertAll(r => r.Name))}"); Console.WriteLine($"Groups : {distinctEndpoints} distinct endpoint(s), so expect {distinctEndpoints} routed POST(s) per export"); @@ -148,16 +172,22 @@ private static void RunMultiEndpointDemo(int activityCount, bool faultEndpoints) Console.WriteLine($"Done in {stopwatch.Elapsed.TotalSeconds:F1}s. Query each component for demo.run_id == '{runId}'."); } - private static void RunMultiEndpointMetricDemo(int measurementCount, bool faultEndpoints) + private static void RunMultiEndpointMetricDemo(int measurementCount, bool faultEndpoints, bool noHost) { - var hostConnectionString = Environment.GetEnvironmentVariable(HostConnectionStringVariable); + if (noHost && !CanRunWithoutAHost()) + { + return; + } + + var hostConnectionString = noHost ? null : Environment.GetEnvironmentVariable(HostConnectionStringVariable); var routes = ParseRoutes(Environment.GetEnvironmentVariable(RouteConnectionStringsVariable)); - if (string.IsNullOrWhiteSpace(hostConnectionString) || routes.Count == 0) + if ((!noHost && string.IsNullOrWhiteSpace(hostConnectionString)) || routes.Count == 0) { Console.WriteLine($"Set {HostConnectionStringVariable} to the exporter's own connection string,"); Console.WriteLine($"and {RouteConnectionStringsVariable} to a comma-separated list of one connection"); Console.WriteLine("string per destination, using components in different regions."); + Console.WriteLine("Pass 'nohost' to run with no connection string of the exporter's own."); return; } @@ -171,6 +201,7 @@ private static void RunMultiEndpointMetricDemo(int measurementCount, bool faultE var distinctEndpoints = new HashSet(routes.ConvertAll(r => r.IngestionEndpoint), StringComparer.Ordinal).Count; Console.WriteLine($"Run id : {runId}"); + Console.WriteLine($"Host : {(noHost ? "NONE (no connection string of its own)" : "configured")}"); Console.WriteLine($"Measurements : {measurementCount} across 2 instruments (counter and histogram)"); Console.WriteLine($"Routes : {string.Join(", ", routes.ConvertAll(r => r.Name))}"); Console.WriteLine($"Groups : {distinctEndpoints} distinct endpoint(s), so expect {distinctEndpoints} routed POST(s) per export"); @@ -214,16 +245,22 @@ private static void RunMultiEndpointMetricDemo(int measurementCount, bool faultE Console.WriteLine($"Done in {stopwatch.Elapsed.TotalSeconds:F1}s. Query each component for demo.run_id == '{runId}'."); } - private static void RunMultiEndpointLogDemo(int logCount, bool faultEndpoints) + private static void RunMultiEndpointLogDemo(int logCount, bool faultEndpoints, bool noHost) { - var hostConnectionString = Environment.GetEnvironmentVariable(HostConnectionStringVariable); + if (noHost && !CanRunWithoutAHost()) + { + return; + } + + var hostConnectionString = noHost ? null : Environment.GetEnvironmentVariable(HostConnectionStringVariable); var routes = ParseRoutes(Environment.GetEnvironmentVariable(RouteConnectionStringsVariable)); - if (string.IsNullOrWhiteSpace(hostConnectionString) || routes.Count == 0) + if ((!noHost && string.IsNullOrWhiteSpace(hostConnectionString)) || routes.Count == 0) { Console.WriteLine($"Set {HostConnectionStringVariable} to the exporter's own connection string,"); Console.WriteLine($"and {RouteConnectionStringsVariable} to a comma-separated list of one connection"); Console.WriteLine("string per destination, using components in different regions."); + Console.WriteLine("Pass 'nohost' to run with no connection string of the exporter's own."); return; } @@ -237,6 +274,7 @@ private static void RunMultiEndpointLogDemo(int logCount, bool faultEndpoints) var distinctEndpoints = new HashSet(routes.ConvertAll(r => r.IngestionEndpoint), StringComparer.Ordinal).Count; Console.WriteLine($"Run id : {runId}"); + Console.WriteLine($"Host : {(noHost ? "NONE (no connection string of its own)" : "configured")}"); Console.WriteLine($"Logs : {logCount} log records"); Console.WriteLine($"Routes : {string.Join(", ", routes.ConvertAll(r => r.Name))}"); Console.WriteLine($"Groups : {distinctEndpoints} distinct endpoint(s), so expect {distinctEndpoints} routed POST(s) per export"); diff --git a/sdk/monitor/Azure.Monitor.OpenTelemetry.Exporter/tests/Azure.Monitor.OpenTelemetry.Exporter.Demo/Traces/MultiEndpointTraceDemo.cs b/sdk/monitor/Azure.Monitor.OpenTelemetry.Exporter/tests/Azure.Monitor.OpenTelemetry.Exporter.Demo/Traces/MultiEndpointTraceDemo.cs index a830e6bf77d1..3120c349e547 100644 --- a/sdk/monitor/Azure.Monitor.OpenTelemetry.Exporter/tests/Azure.Monitor.OpenTelemetry.Exporter.Demo/Traces/MultiEndpointTraceDemo.cs +++ b/sdk/monitor/Azure.Monitor.OpenTelemetry.Exporter/tests/Azure.Monitor.OpenTelemetry.Exporter.Demo/Traces/MultiEndpointTraceDemo.cs @@ -34,7 +34,7 @@ internal sealed class MultiEndpointTraceDemo : IDisposable private readonly TracerProvider? _tracerProvider; private readonly EndpointRoutingProcessor _routingProcessor; - public MultiEndpointTraceDemo(string exporterConnectionString, IReadOnlyList routes, string runId, bool faultRoutedEndpoints = false) + public MultiEndpointTraceDemo(string? exporterConnectionString, IReadOnlyList routes, string runId, bool faultRoutedEndpoints = false) { _routingProcessor = new EndpointRoutingProcessor(routes, runId); @@ -50,7 +50,12 @@ public MultiEndpointTraceDemo(string exporterConnectionString, IReadOnlyList { - o.ConnectionString = exporterConnectionString; + // Left unset when absent: the exporter has no component of its own and every + // destination comes from the telemetry. + if (!string.IsNullOrWhiteSpace(exporterConnectionString)) + { + o.ConnectionString = exporterConnectionString; + } // Rate-limited sampling is the default at 5 traces/second and takes precedence // over SamplingRatio, which would drop almost everything this demo generates. diff --git a/sdk/monitor/Azure.Monitor.OpenTelemetry.Exporter/tests/Azure.Monitor.OpenTelemetry.Exporter.Tests/MultiEndpointIntegrationTests.cs b/sdk/monitor/Azure.Monitor.OpenTelemetry.Exporter/tests/Azure.Monitor.OpenTelemetry.Exporter.Tests/MultiEndpointIntegrationTests.cs index f3c5cb34ff7e..697e334b067c 100644 --- a/sdk/monitor/Azure.Monitor.OpenTelemetry.Exporter/tests/Azure.Monitor.OpenTelemetry.Exporter.Tests/MultiEndpointIntegrationTests.cs +++ b/sdk/monitor/Azure.Monitor.OpenTelemetry.Exporter/tests/Azure.Monitor.OpenTelemetry.Exporter.Tests/MultiEndpointIntegrationTests.cs @@ -14,11 +14,13 @@ using Azure.Core; using Azure.Core.TestFramework; using Azure.Monitor.OpenTelemetry.Exporter.Internals; +using Azure.Monitor.OpenTelemetry.Exporter.Internals.ConnectionString; using Azure.Monitor.OpenTelemetry.Exporter.Internals.CustomerSdkStats; using Azure.Monitor.OpenTelemetry.Exporter.Internals.Diagnostics; using Azure.Monitor.OpenTelemetry.Exporter.Internals.Platform; using TestEventListener = Azure.Monitor.OpenTelemetry.Exporter.Tests.CommonTestFramework.TestEventListener; +using MockPlatform = Azure.Monitor.OpenTelemetry.Exporter.Tests.CommonTestFramework.MockPlatform; using OpenTelemetry; @@ -472,9 +474,89 @@ public void RoutedExportEmitsNoCustomerSdkStatsWhileSingleEndpointDoes() Assert.Equal(control, Volatile.Read(ref measurements)); } + /// + /// The primary success path of a process that has no component of its own: routing is the + /// only way anything is sent, so this is the case that must keep working. + /// + [Fact] + public void EachDestinationReachesItsOwnStampWithoutAConnectionString() + { + var ingestion = new MockIngestion(); + using var exporter = CreateUnconfiguredExporter(ingestion); + + var result = exporter.Export(CreateBatch( + CreateActivity("ikey-east", EastUs), + CreateActivity("ikey-west", WestUs), + CreateActivity("ikey-north", NorthEurope))); + + Assert.Equal(ExportResult.Success, result); + + Assert.Equal( + new[] + { + EastUs + "v2.1/track", + WestUs + "v2.1/track", + NorthEurope + "v2.1/track", + }, + ingestion.Requests.Select(request => request.Uri)); + + // The exact serialized shape, so the negative assertion below cannot silently stop + // matching if the envelope's field name or formatting ever changes. + Assert.Contains("\"iKey\":\"ikey-east\"", ingestion.RequestTo(EastUs).Body, StringComparison.Ordinal); + Assert.Contains("\"iKey\":\"ikey-west\"", ingestion.RequestTo(WestUs).Body, StringComparison.Ordinal); + Assert.Contains("\"iKey\":\"ikey-north\"", ingestion.RequestTo(NorthEurope).Body, StringComparison.Ordinal); + + // The placeholder endpoint seeds the REST client, so a routed send that failed to rewrite + // the URI would land there rather than failing outright. + Assert.DoesNotContain( + ingestion.Requests, + request => request.Uri.Contains(Constants.DefaultIngestionEndpoint, StringComparison.OrdinalIgnoreCase)); + + // Nothing may be attributed to the component that does not exist. + Assert.All(ingestion.Requests, request => Assert.DoesNotContain("\"iKey\":\"\"", request.Body, StringComparison.Ordinal)); + } + + /// + /// Without a connection string there is nowhere of the process's own to fall back to, so an + /// item that names no destination must reach nothing at all rather than the placeholder. + /// + [Fact] + public void UnroutableActivitiesReachNoStampWithoutAConnectionString() + { + var ingestion = new MockIngestion(); + using var exporter = CreateUnconfiguredExporter(ingestion); + + var result = exporter.Export(CreateBatch( + CreateActivity(instrumentationKey: null, ingestionEndpoint: null), + CreateActivity("ikey-a", "not-a-uri"))); + + Assert.Equal(ExportResult.Success, result); + Assert.Empty(ingestion.Requests); + } + private static AzureMonitorTraceExporter CreateExporter(MockIngestion ingestion, out string instrumentationKey) => CreateExporter(ingestion, multiEndpointEnabled: true, out instrumentationKey); + /// The same wiring as , minus the connection string. + private static AzureMonitorTraceExporter CreateUnconfiguredExporter(MockIngestion ingestion) + { + var options = new AzureMonitorExporterOptions + { + Transport = ingestion.Transport, + DisableOfflineStorage = true, + EnableStatsbeat = false, + }; + + // MockPlatform, not DefaultPlatform: the latter snapshots the real environment, so an + // ambient APPLICATIONINSIGHTS_CONNECTION_STRING would quietly configure the transmitter + // and turn these tests back into the configured case they exist to be distinct from. + var transmitter = new AzureMonitorTransmitter(options, new MockPlatform(), multiEndpointEnabled: true); + + Assert.Equal(string.Empty, transmitter.InstrumentationKey); + + return new AzureMonitorTraceExporter(options, transmitter, multiEndpointEnabled: true); + } + private static AzureMonitorTraceExporter CreateExporter(MockIngestion ingestion, bool multiEndpointEnabled, out string instrumentationKey) { instrumentationKey = "00000000-0000-0000-0000-0000000000ff"; diff --git a/sdk/monitor/Azure.Monitor.OpenTelemetry.Exporter/tests/Azure.Monitor.OpenTelemetry.Exporter.Tests/OptionalConnectionStringTests.cs b/sdk/monitor/Azure.Monitor.OpenTelemetry.Exporter/tests/Azure.Monitor.OpenTelemetry.Exporter.Tests/OptionalConnectionStringTests.cs new file mode 100644 index 000000000000..3ebe1e522a2d --- /dev/null +++ b/sdk/monitor/Azure.Monitor.OpenTelemetry.Exporter/tests/Azure.Monitor.OpenTelemetry.Exporter.Tests/OptionalConnectionStringTests.cs @@ -0,0 +1,418 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System; +using System.Diagnostics.Tracing; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; + +using Azure.Monitor.OpenTelemetry.Exporter.Internals; +using Azure.Monitor.OpenTelemetry.Exporter.Internals.ConnectionString; +using Azure.Monitor.OpenTelemetry.Exporter.Internals.CustomerSdkStats; +using Azure.Monitor.OpenTelemetry.Exporter.Internals.Diagnostics; +using Azure.Monitor.OpenTelemetry.Exporter.Internals.PersistentStorage; +using Azure.Monitor.OpenTelemetry.Exporter.Models; +using Azure.Monitor.OpenTelemetry.Exporter.Tests.CommonTestFramework; + +using OpenTelemetry; + +using Xunit; + +namespace Azure.Monitor.OpenTelemetry.Exporter.Tests +{ + /// + /// Multi-endpoint routing takes every destination from the telemetry itself, so a process that + /// only routes has no component of its own. Requiring a connection string forced callers to + /// nominate one that receives nothing, yet whose key was stamped on SDK statistics as though it + /// owned the traffic. + /// + public class OptionalConnectionStringTests + { + private const string EnvironmentVariable = "APPLICATIONINSIGHTS_CONNECTION_STRING"; + + // ---------- Routing on, nothing configured: the new behaviour ---------- + + [Fact] + public void RoutingWithoutAConnectionStringIsAllowed() + { + var connectionVars = AzureMonitorTransmitter.InitializeConnectionVars( + new AzureMonitorExporterOptions(), new MockPlatform(), multiEndpointEnabled: true); + + Assert.True(connectionVars.IsUnconfigured); + Assert.Equal(string.Empty, connectionVars.InstrumentationKey); + } + + /// + /// The endpoint only seeds the REST client, which rewrites the URI for each routed group, so + /// it has to be a usable absolute URI even though nothing is sent to it. + /// + [Fact] + public void TheRoutingOnlyEndpointIsAUsableUri() + { + var connectionVars = ConnectionVars.CreateUnconfigured(); + + Assert.True(Uri.TryCreate(connectionVars.IngestionEndpoint, UriKind.Absolute, out _)); + Assert.True(Uri.TryCreate(connectionVars.LiveEndpoint, UriKind.Absolute, out _)); + } + + /// A credential has no audience to scope to, so it must not silently be honoured. + [Fact] + public void TheRoutingOnlyVarsCarryNoAudience() + { + Assert.Null(ConnectionVars.CreateUnconfigured().AadAudience); + } + + /// + /// Warning, not Informational: the operator is running a process that can send nothing of its + /// own, which is worth surfacing at the level most listeners actually subscribe to. + /// + [Fact] + public void RoutingWithoutAConnectionStringIsAnnounced() + { + using var listener = new TestEventListener(); + listener.EnableEvents(AzureMonitorExporterEventSource.Log, EventLevel.Verbose, EventKeywords.All); + + AzureMonitorTransmitter.InitializeConnectionVars( + new AzureMonitorExporterOptions(), new MockPlatform(), multiEndpointEnabled: true); + + var announcement = Assert.Single(listener.Messages.Where(e => e.EventName == "RoutingWithoutConnectionString")); + Assert.Equal(77, announcement.EventId); + Assert.Equal(EventLevel.Warning, announcement.Level); + } + + /// + /// A transmitter is the thing that actually had to be constructible; the vars alone prove + /// nothing about the storage, REST client and back-off state built from them. + /// + [Fact] + public void ATransmitterCanBeBuiltWithoutAConnectionString() + { + using var transmitter = new AzureMonitorTransmitter( + new AzureMonitorExporterOptions { DisableOfflineStorage = true }, + new MockPlatform(), + multiEndpointEnabled: true); + + Assert.Equal(string.Empty, transmitter.InstrumentationKey); + } + + /// + /// Statistics identify a component by key and pick their region from its endpoint. Without a + /// connection string there is neither, and a routed destination cannot supply them because it + /// is chosen per item, long after construction. + /// + [Fact] + public void NoSdkStatisticsAreCollectedWithoutAConnectionString() + { + using var listener = new TestEventListener(); + listener.EnableEvents(AzureMonitorExporterEventSource.Log, EventLevel.Verbose, EventKeywords.All); + + using var transmitter = new AzureMonitorTransmitter( + new AzureMonitorExporterOptions { DisableOfflineStorage = true }, + new MockPlatform(), + multiEndpointEnabled: true); + + Assert.Null(StatsbeatOf(transmitter)); + + // Null alone would not prove the guard: statsbeat cannot map the placeholder endpoint to + // a region, so removing the guard leaves it null anyway, by way of a caught exception. + // Never attempting it is the behaviour under test. + Assert.Empty(listener.Messages.Where(e => e.EventName == "ErrorInitializingStatsbeat")); + } + + // ---------- Routing off: unchanged ---------- + + [Fact] + public void WithoutRoutingAMissingConnectionStringStillThrows() + { + var exception = Assert.Throws( + () => AzureMonitorTransmitter.InitializeConnectionVars( + new AzureMonitorExporterOptions(), new MockPlatform(), multiEndpointEnabled: false)); + + Assert.Equal("A connection string was not found. Please set your connection string.", exception.Message); + } + + /// The overload every existing caller uses must keep throwing. + [Fact] + public void TheLegacyOverloadStillThrows() + { + Assert.Throws( + () => AzureMonitorTransmitter.InitializeConnectionVars(new AzureMonitorExporterOptions(), new MockPlatform())); + } + + [Fact] + public void WithoutRoutingATransmitterStillThrows() + { + Assert.Throws( + () => new AzureMonitorTransmitter(new AzureMonitorExporterOptions(), new MockPlatform())); + } + + // ---------- A configured connection string wins in both modes ---------- + + [Theory] + [InlineData(true)] + [InlineData(false)] + public void AConfiguredConnectionStringIsUsedRegardlessOfRouting(bool multiEndpointEnabled) + { + var connectionVars = AzureMonitorTransmitter.InitializeConnectionVars( + new AzureMonitorExporterOptions { ConnectionString = "InstrumentationKey=configured" }, + new MockPlatform(), + multiEndpointEnabled); + + Assert.False(connectionVars.IsUnconfigured); + Assert.Equal("configured", connectionVars.InstrumentationKey); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public void TheEnvironmentVariableIsStillHonouredRegardlessOfRouting(bool multiEndpointEnabled) + { + var platform = new MockPlatform(); + platform.SetEnvironmentVariable(EnvironmentVariable, "InstrumentationKey=from-env"); + + var connectionVars = AzureMonitorTransmitter.InitializeConnectionVars( + new AzureMonitorExporterOptions(), platform, multiEndpointEnabled); + + Assert.False(connectionVars.IsUnconfigured); + Assert.Equal("from-env", connectionVars.InstrumentationKey); + } + + /// A malformed value is still an error; routing is not a fallback for a typo. + [Theory] + [InlineData(true)] + [InlineData(false)] + public void AnInvalidConnectionStringStillThrowsRegardlessOfRouting(bool multiEndpointEnabled) + { + Assert.Throws( + () => AzureMonitorTransmitter.InitializeConnectionVars( + new AzureMonitorExporterOptions { ConnectionString = "IngestionEndpoint=https://example.com/" }, + new MockPlatform(), + multiEndpointEnabled)); + } + + /// Whitespace is not a connection string, so routing should treat it as absent. + [Fact] + public void AWhitespaceEnvironmentVariableFallsBackToRoutingOnly() + { + var platform = new MockPlatform(); + platform.SetEnvironmentVariable(EnvironmentVariable, " "); + + var connectionVars = AzureMonitorTransmitter.InitializeConnectionVars( + new AzureMonitorExporterOptions(), platform, multiEndpointEnabled: true); + + Assert.True(connectionVars.IsUnconfigured); + } + + // ---------- Storage directory ---------- + + /// + /// The seed changing would move every existing directory, orphaning backlogs that were + /// persisted for delivery. The expectation is a literal rather than a second call to + /// , so that a change to the hash itself cannot satisfy it. + /// + [Fact] + public void AConfiguredKeyProducesTheSameDirectoryAsBefore() + { + var directory = StorageHelper.GetStorageDirectory(MockPlatformForStorage(), configuredStorageDirectory: "C:\\root", instrumentationKey: "ikey-a"); + + Assert.Equal(Path.Combine("C:\\root", "1468ec91488193bdf84d4390f553c2ded5fd4848881cbd77592d09d3aba09b2b"), directory); + } + + /// + /// A configured connection string can legitimately carry an empty key, and such a process + /// already has a directory. The decision keys on the caller's mode, never on the value, so + /// that backlog stays where it is. + /// + [Theory] + [InlineData(null)] + [InlineData("")] + public void AnEmptyKeyFromAConnectionStringKeepsItsExistingDirectory(string? instrumentationKey) + { + var directory = StorageHelper.GetStorageDirectory(MockPlatformForStorage(), "C:\\root", instrumentationKey!, omitInstrumentationKey: false); + + Assert.Equal(Path.Combine("C:\\root", "a4ca073d30fde56a69f4a3e48ef0bd598f7e4bf34a890a863f5d2b1850869498"), directory); + } + + /// + /// User, process and application directory already identify an application on a machine, so + /// the segment is omitted rather than filled with a placeholder. + /// + [Fact] + public void WithoutAConnectionStringTheSegmentIsOmittedRatherThanBlank() + { + var directory = StorageHelper.GetStorageDirectory(MockPlatformForStorage(), "C:\\root", string.Empty, omitInstrumentationKey: true); + + Assert.Equal(Path.Combine("C:\\root", "cb7b21eaaf0cc9844a28c90a35480fbefb3872f51620a5ddee5454470694ecfb"), directory); + Assert.NotEqual(Path.Combine("C:\\root", "a4ca073d30fde56a69f4a3e48ef0bd598f7e4bf34a890a863f5d2b1850869498"), directory); + } + + [Fact] + public void TwoApplicationsWithoutKeysStillGetDifferentDirectories() + { + var first = StorageHelper.GetStorageDirectory(MockPlatformForStorage(processName: "first"), "C:\\root", string.Empty, omitInstrumentationKey: true); + var second = StorageHelper.GetStorageDirectory(MockPlatformForStorage(processName: "second"), "C:\\root", string.Empty, omitInstrumentationKey: true); + + Assert.NotEqual(first, second); + } + + // ---------- Nothing drains the backlog to a component that does not exist ---------- + + /// + /// Anything already on disk was addressed to a component this process no longer has, and the + /// handler would send it to whichever host seeded the REST client. Not creating it, nor the + /// provider that would feed it, makes that impossible rather than merely unused. Routed + /// storage is the one path that must survive, so it is asserted in the same breath. + /// + [Fact] + public void NothingDrainsStorageWithoutAConnectionString() + { + var root = StorageRoot(); + try + { + using var transmitter = new AzureMonitorTransmitter( + new AzureMonitorExporterOptions { StorageDirectory = root, EnableStatsbeat = false }, + MockPlatformForStorage(), + multiEndpointEnabled: true); + + Assert.Null(DrainHandlerOf(transmitter)); + Assert.Null(FieldOf(transmitter, "_fileBlobProvider")); + Assert.NotNull(FieldOf(transmitter, "_multiEndpointStorage")); + } + finally + { + DeleteStorageRoot(root); + } + } + + /// The same construction with a connection string must still drain, or this is a regression. + [Fact] + public void StorageStillDrainsWithAConnectionString() + { + var root = StorageRoot(); + try + { + using var transmitter = new AzureMonitorTransmitter( + new AzureMonitorExporterOptions { ConnectionString = "InstrumentationKey=configured", StorageDirectory = root, EnableStatsbeat = false }, + MockPlatformForStorage(), + multiEndpointEnabled: true); + + Assert.NotNull(DrainHandlerOf(transmitter)); + Assert.NotNull(FieldOf(transmitter, "_fileBlobProvider")); + } + finally + { + DeleteStorageRoot(root); + } + } + + /// + /// The unrouted send path addresses the configured endpoint under the configured key, both of + /// which are placeholders here. Every caller is gated already; this is what keeps the send + /// itself impossible when a new one is added. + /// + [Fact] + public async Task UnroutedTelemetryIsRefusedRatherThanSentToThePlaceholder() + { + using var transmitter = new AzureMonitorTransmitter( + new AzureMonitorExporterOptions { DisableOfflineStorage = true, EnableStatsbeat = false }, + MockPlatformForStorage(), + multiEndpointEnabled: true); + + using var listener = new TestEventListener(); + listener.EnableEvents(AzureMonitorExporterEventSource.Log, EventLevel.Verbose, EventKeywords.All); + + var result = await transmitter.TrackAsync( + Array.Empty(), + new TelemetrySchemaTypeCounter(), + TelemetryItemOrigin.AzureMonitorTraceExporter, + async: true, + CancellationToken.None); + + Assert.Equal(ExportResult.Failure, result); + Assert.Single(listener.Messages.Where(e => e.EventName == "DroppedUnroutedTelemetryWithoutConnectionString")); + } + + // ---------- Customer SDK statistics ---------- + + /// + /// These are addressed to the customer's own component. Without a connection string there is + /// none, so the provider must not be built at all. The decision is asserted directly as well: + /// running the registration for the positive case would leave an undisposable provider and a + /// cached transmitter behind for the rest of the run. + /// + [Fact] + public void NoCustomerSdkStatsWithoutAConnectionString() + { + Assert.False(CustomerSdkStatsRegistration.HasConnectionString(new AzureMonitorExporterOptions(), new MockPlatform())); + Assert.False(CustomerSdkStatsRegistration.HasConnectionString(new AzureMonitorExporterOptions { ConnectionString = " " }, new MockPlatform())); + + using var listener = new TestEventListener(); + listener.EnableEvents(AzureMonitorExporterEventSource.Log, EventLevel.Verbose, EventKeywords.All); + + CustomerSdkStatsRegistration.RegisterCustomerSdkStats(new AzureMonitorExporterOptions(), new MockPlatform()); + + // Neither collected nor failed: it was never attempted. + Assert.Empty(listener.Messages.Where(e => e.EventName == "CustomerSdkStatsEnabled")); + Assert.Empty(listener.Messages.Where(e => e.EventName == "CustomerSdkStatsInitializationFailed")); + } + + /// With a connection string the guard lets registration through, from either source. + [Fact] + public void CustomerSdkStatsStillAllowedWithAConnectionString() + { + Assert.True(CustomerSdkStatsHelper.IsEnabled(), "these stats are on by default; the guard under test is the connection string, not the switch"); + + Assert.True(CustomerSdkStatsRegistration.HasConnectionString( + new AzureMonitorExporterOptions { ConnectionString = "InstrumentationKey=configured" }, new MockPlatform())); + + var platform = new MockPlatform(); + platform.SetEnvironmentVariable(EnvironmentVariable, "InstrumentationKey=from-env"); + + Assert.True(CustomerSdkStatsRegistration.HasConnectionString(new AzureMonitorExporterOptions(), platform)); + } + + private static MockPlatform MockPlatformForStorage(string processName = "process") + => new() + { + UserName = "user", + ProcessName = processName, + ApplicationBaseDirectory = "appdir", + }; + + private static string StorageRoot() + { + var root = Path.Combine(Path.GetTempPath(), "otel-optional-cs", Guid.NewGuid().ToString("n")); + Directory.CreateDirectory(root); + return root; + } + + private static void DeleteStorageRoot(string root) + { + try + { + Directory.Delete(root, recursive: true); + } + catch (IOException) + { + // A maintenance timer may still hold a handle. Leaving the directory behind is not + // worth failing a passing test over. + } + catch (UnauthorizedAccessException) + { + } + } + + private static object? StatsbeatOf(AzureMonitorTransmitter transmitter) + => FieldOf(transmitter, "_statsbeat"); + + private static object? DrainHandlerOf(AzureMonitorTransmitter transmitter) + => FieldOf(transmitter, "_transmitFromStorageHandler"); + + private static object? FieldOf(AzureMonitorTransmitter transmitter, string name) + => typeof(AzureMonitorTransmitter) + .GetField(name, System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic)! + .GetValue(transmitter); + } +}