From 6f73e5574816a1809ec27c57c34f6da0be1e7d53 Mon Sep 17 00:00:00 2001 From: Rajkumar Rangaraj Date: Mon, 14 Sep 2026 15:13:33 -0700 Subject: [PATCH 1/5] Make a connection string optional when multi-endpoint routing is enabled Routing takes every destination from the telemetry, so a process that only routes has no component of its own. It still had to nominate one, which received nothing yet had its instrumentation key stamped on SDK statistics as though it owned the traffic. InitializeConnectionVars now takes the gate and returns routing-only connection vars instead of throwing, but only when routing is on and nothing is configured anywhere. The existing two-argument overload delegates with the gate off, so every current caller keeps the same behaviour and the same message. SDK statistics are skipped in this configuration: they identify a component by key and choose their region from its ingestion endpoint, and a routed destination supplies neither - it is chosen per item, long after the transmitter is built. Event 77 reports the mode rather than leaving it to be inferred. The storage directory omits the instrumentation key segment when there is none rather than substituting a placeholder, so every existing directory name is byte-identical and no persisted backlog is orphaned on upgrade. User, process name and application directory already identify an application on a machine. --- .../CHANGELOG.md | 2 + .../src/Internals/AzureMonitorTransmitter.cs | 19 +- .../ConnectionString/ConnectionVars.cs | 22 ++ .../AzureMonitorExporterEventSource.cs | 3 + .../PersistentStorage/StorageHelper.cs | 12 +- .../OptionalConnectionStringTests.cs | 245 ++++++++++++++++++ 6 files changed, 299 insertions(+), 4 deletions(-) create mode 100644 sdk/monitor/Azure.Monitor.OpenTelemetry.Exporter/tests/Azure.Monitor.OpenTelemetry.Exporter.Tests/OptionalConnectionStringTests.cs diff --git a/sdk/monitor/Azure.Monitor.OpenTelemetry.Exporter/CHANGELOG.md b/sdk/monitor/Azure.Monitor.OpenTelemetry.Exporter/CHANGELOG.md index 22ccda883224..3d9c0bdba119 100644 --- a/sdk/monitor/Azure.Monitor.OpenTelemetry.Exporter/CHANGELOG.md +++ b/sdk/monitor/Azure.Monitor.OpenTelemetry.Exporter/CHANGELOG.md @@ -16,6 +16,8 @@ - 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. + ### 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..e791fe97c62e 100644 --- a/sdk/monitor/Azure.Monitor.OpenTelemetry.Exporter/src/Internals/AzureMonitorTransmitter.cs +++ b/sdk/monitor/Azure.Monitor.OpenTelemetry.Exporter/src/Internals/AzureMonitorTransmitter.cs @@ -66,7 +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); @@ -87,7 +87,10 @@ 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.IsRoutingOnly ? null : InitializeStatsbeat(options, _connectionVars, platform); if (_fileBlobProvider != null) { @@ -104,6 +107,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 +125,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.CreateRoutingOnly(); + } + throw new InvalidOperationException("A connection string was not found. Please set your connection string."); } 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..97585cb64673 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,32 @@ 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, isRoutingOnly: false) + { + } + + private ConnectionVars(string instrumentationKey, string ingestionEndpoint, string liveEndpoint, string? aadAudience, bool isRoutingOnly) { this.InstrumentationKey = instrumentationKey; this.IngestionEndpoint = ingestionEndpoint; this.LiveEndpoint = liveEndpoint; this.AadAudience = aadAudience; + this.IsRoutingOnly = isRoutingOnly; } + /// + /// Stands in for a connection string the process does not have. Multi-endpoint routing takes + /// every destination from the telemetry itself, so there is no component that owns the + /// exporter, and nothing is ever sent to these values: the endpoint only seeds the REST + /// client, which rewrites the URI for each routed group. + /// + public static ConnectionVars CreateRoutingOnly() => new( + instrumentationKey: string.Empty, + ingestionEndpoint: Constants.DefaultIngestionEndpoint, + liveEndpoint: Constants.DefaultLiveEndpoint, + aadAudience: null, + isRoutingOnly: true); + public string InstrumentationKey { get; } public string IngestionEndpoint { get; } @@ -23,5 +42,8 @@ public ConnectionVars(string instrumentationKey, string ingestionEndpoint, strin public string LiveEndpoint { get; } public string? AadAudience { get; } + + /// No connection string was configured; only routed telemetry can be sent. + public bool IsRoutingOnly { get; } } } 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..fab082f8edda 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,8 @@ 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. SDK statistics are not collected, because they identify a component this process does not have.", Level = EventLevel.Informational)] + public void RoutingWithoutConnectionString() => WriteEvent(77); } } 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..9e32b4a1686e 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 @@ -11,7 +11,7 @@ namespace Azure.Monitor.OpenTelemetry.Exporter.Internals.PersistentStorage { internal static class StorageHelper { - internal static string GetStorageDirectory(IPlatform platform, string? configuredStorageDirectory, string instrumentationKey) + internal static string GetStorageDirectory(IPlatform platform, string? configuredStorageDirectory, string? instrumentationKey) { // get root directory var rootDirectory = configuredStorageDirectory @@ -22,7 +22,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}"); + + // Without a connection string there is no key to distinguish by. The remaining three + // already identify an application on a machine, and omitting the segment rather than + // substituting a placeholder keeps every existing directory name unchanged. + string seed = string.IsNullOrEmpty(instrumentationKey) + ? $"{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.Tests/OptionalConnectionStringTests.cs b/sdk/monitor/Azure.Monitor.OpenTelemetry.Exporter/tests/Azure.Monitor.OpenTelemetry.Exporter.Tests/OptionalConnectionStringTests.cs new file mode 100644 index 000000000000..048d7b2d729d --- /dev/null +++ b/sdk/monitor/Azure.Monitor.OpenTelemetry.Exporter/tests/Azure.Monitor.OpenTelemetry.Exporter.Tests/OptionalConnectionStringTests.cs @@ -0,0 +1,245 @@ +// 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 Azure.Monitor.OpenTelemetry.Exporter.Internals; +using Azure.Monitor.OpenTelemetry.Exporter.Internals.ConnectionString; +using Azure.Monitor.OpenTelemetry.Exporter.Internals.Diagnostics; +using Azure.Monitor.OpenTelemetry.Exporter.Internals.PersistentStorage; +using Azure.Monitor.OpenTelemetry.Exporter.Tests.CommonTestFramework; + +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.IsRoutingOnly); + 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.CreateRoutingOnly(); + + 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.CreateRoutingOnly().AadAudience); + } + + [Fact] + public void RoutingWithoutAConnectionStringIsAnnounced() + { + using var listener = new TestEventListener(); + listener.EnableEvents(AzureMonitorExporterEventSource.Log, EventLevel.Informational, EventKeywords.All); + + AzureMonitorTransmitter.InitializeConnectionVars( + new AzureMonitorExporterOptions(), new MockPlatform(), multiEndpointEnabled: true); + + Assert.Single(listener.Messages.Where(e => e.EventName == "RoutingWithoutConnectionString")); + } + + /// + /// 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 transmitter = new AzureMonitorTransmitter( + new AzureMonitorExporterOptions { DisableOfflineStorage = true }, + new MockPlatform(), + multiEndpointEnabled: true); + + Assert.Null(StatsbeatOf(transmitter)); + } + + // ---------- 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.IsRoutingOnly); + 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.IsRoutingOnly); + 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.IsRoutingOnly); + } + + // ---------- Storage directory ---------- + + /// + /// The seed changing would move every existing directory, orphaning backlogs that were + /// persisted for delivery. + /// + [Fact] + public void AConfiguredKeyProducesTheSameDirectoryAsBefore() + { + var platform = MockPlatformForStorage(); + + var directory = StorageHelper.GetStorageDirectory(platform, configuredStorageDirectory: "C:\\root", instrumentationKey: "ikey-a"); + var expected = Path.Combine("C:\\root", HashHelper.GetSHA256Hash("ikey-a;user;process;appdir")); + + Assert.Equal(expected, directory); + } + + /// + /// User, process and application directory already identify an application on a machine, so + /// the segment is omitted rather than filled with a placeholder. + /// + [Theory] + [InlineData(null)] + [InlineData("")] + public void WithoutAKeyTheSegmentIsOmittedRatherThanBlank(string? instrumentationKey) + { + var platform = MockPlatformForStorage(); + + var directory = StorageHelper.GetStorageDirectory(platform, configuredStorageDirectory: "C:\\root", instrumentationKey: instrumentationKey); + var expected = Path.Combine("C:\\root", HashHelper.GetSHA256Hash("user;process;appdir")); + + Assert.Equal(expected, directory); + Assert.NotEqual(Path.Combine("C:\\root", HashHelper.GetSHA256Hash(";user;process;appdir")), directory); + } + + [Fact] + public void TwoApplicationsWithoutKeysStillGetDifferentDirectories() + { + var first = StorageHelper.GetStorageDirectory(MockPlatformForStorage(processName: "first"), "C:\\root", instrumentationKey: null); + var second = StorageHelper.GetStorageDirectory(MockPlatformForStorage(processName: "second"), "C:\\root", instrumentationKey: null); + + Assert.NotEqual(first, second); + } + + private static MockPlatform MockPlatformForStorage(string processName = "process") + => new() + { + UserName = "user", + ProcessName = processName, + ApplicationBaseDirectory = "appdir", + }; + + private static object? StatsbeatOf(AzureMonitorTransmitter transmitter) + => typeof(AzureMonitorTransmitter) + .GetField("_statsbeat", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic)! + .GetValue(transmitter); + } +} From 9455d6f6954b93b9e05728a3a47decd7c5da992e Mon Sep 17 00:00:00 2001 From: Rajkumar Rangaraj Date: Mon, 14 Sep 2026 16:33:06 -0700 Subject: [PATCH 2/5] Address review: make the unconfigured path structurally unreachable Fixes from two review rounds on the optional-connection-string change. Rename IsRoutingOnly to IsUnconfigured: the name described why the mode exists rather than what it is, and the placeholder values are indistinguishable from a real destination by inspection. Key the storage directory seed on an explicit flag from the caller rather than on the instrumentation key being empty. A configured connection string can legitimately parse to an empty key, and inferring from the value would have moved such a process's directory and stranded its backlog. Build neither the FileBlobProvider nor the TransmitFromStorageHandler when unconfigured. Nothing writes to that storage, because every send is routed, and nothing may drain it, because there is no component to drain it to. Refuse the unrouted send path in TrackAsync instead of relying on its callers being gated elsewhere, so a new caller cannot silently deliver telemetry to the placeholder endpoint under an empty key. Skip Customer SDK Stats registration without a connection string; these identify the customer's own component. The guard takes an IPlatform so it reads the same source the transmitter reads. Raise event 77 to Warning and add event 78 for the refused send. --- .../src/Internals/AzureMonitorTransmitter.cs | 39 ++- .../ConnectionString/ConnectionVars.cs | 25 +- .../CustomerSdkStatsRegistration.cs | 25 ++ .../AzureMonitorExporterEventSource.cs | 5 +- .../PersistentStorage/StorageHelper.cs | 13 +- .../OptionalConnectionStringTests.cs | 223 ++++++++++++++++-- 6 files changed, 283 insertions(+), 47 deletions(-) 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 e791fe97c62e..e33ec0d7c254 100644 --- a/sdk/monitor/Azure.Monitor.OpenTelemetry.Exporter/src/Internals/AzureMonitorTransmitter.cs +++ b/sdk/monitor/Azure.Monitor.OpenTelemetry.Exporter/src/Internals/AzureMonitorTransmitter.cs @@ -67,7 +67,6 @@ internal AzureMonitorTransmitter(AzureMonitorExporterOptions options, IPlatform options.Retry.MaxRetries = 0; _connectionVars = InitializeConnectionVars(options, platform, multiEndpointEnabled); - _transmissionStateManager = new TransmissionStateManager(_connectionVars.IngestionEndpoint); _applicationInsightsRestClient = InitializeRestClient(options, _connectionVars, out _isAadEnabled); @@ -90,9 +89,12 @@ internal AzureMonitorTransmitter(AzureMonitorExporterOptions options, IPlatform // 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.IsRoutingOnly ? null : InitializeStatsbeat(options, _connectionVars, platform); + _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); } @@ -131,7 +133,7 @@ internal static ConnectionVars InitializeConnectionVars(AzureMonitorExporterOpti { AzureMonitorExporterEventSource.Log.RoutingWithoutConnectionString(); - return ConnectionVars.CreateRoutingOnly(); + return ConnectionVars.CreateUnconfigured(); } throw new InvalidOperationException("A connection string was not found. Please set your connection string."); @@ -175,7 +177,23 @@ private static ApplicationInsightsRestClient InitializeRestClient(AzureMonitorEx 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); @@ -559,6 +577,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 97585cb64673..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,31 +9,34 @@ 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, isRoutingOnly: false) + : this(instrumentationKey, ingestionEndpoint, liveEndpoint, aadAudience, isUnconfigured: false) { } - private ConnectionVars(string instrumentationKey, string ingestionEndpoint, string liveEndpoint, string? aadAudience, bool isRoutingOnly) + 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.IsRoutingOnly = isRoutingOnly; + this.IsUnconfigured = isUnconfigured; } /// - /// Stands in for a connection string the process does not have. Multi-endpoint routing takes - /// every destination from the telemetry itself, so there is no component that owns the - /// exporter, and nothing is ever sent to these values: the endpoint only seeds the REST - /// client, which rewrites the URI for each routed group. + /// 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. /// - public static ConnectionVars CreateRoutingOnly() => new( + /// + /// 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, - isRoutingOnly: true); + isUnconfigured: true); public string InstrumentationKey { get; } @@ -43,7 +46,7 @@ private ConnectionVars(string instrumentationKey, string ingestionEndpoint, stri public string? AadAudience { get; } - /// No connection string was configured; only routed telemetry can be sent. - public bool IsRoutingOnly { 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 fab082f8edda..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 @@ -684,7 +684,10 @@ 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. SDK statistics are not collected, because they identify a component this process does not have.", Level = EventLevel.Informational)] + [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 9e32b4a1686e..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 @@ -11,7 +11,10 @@ namespace Azure.Monitor.OpenTelemetry.Exporter.Internals.PersistentStorage { internal static class StorageHelper { - internal static string GetStorageDirectory(IPlatform platform, string? configuredStorageDirectory, string? instrumentationKey) + 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 @@ -23,10 +26,10 @@ internal static string GetStorageDirectory(IPlatform platform, string? configure var processName = platform.GetCurrentProcessName(); var applicationDirectory = platform.GetApplicationBaseDirectory(); - // Without a connection string there is no key to distinguish by. The remaining three - // already identify an application on a machine, and omitting the segment rather than - // substituting a placeholder keeps every existing directory name unchanged. - string seed = string.IsNullOrEmpty(instrumentationKey) + // 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}"; 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 index 048d7b2d729d..3ebe1e522a2d 100644 --- 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 @@ -5,13 +5,19 @@ 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 @@ -34,7 +40,7 @@ public void RoutingWithoutAConnectionStringIsAllowed() var connectionVars = AzureMonitorTransmitter.InitializeConnectionVars( new AzureMonitorExporterOptions(), new MockPlatform(), multiEndpointEnabled: true); - Assert.True(connectionVars.IsRoutingOnly); + Assert.True(connectionVars.IsUnconfigured); Assert.Equal(string.Empty, connectionVars.InstrumentationKey); } @@ -45,7 +51,7 @@ public void RoutingWithoutAConnectionStringIsAllowed() [Fact] public void TheRoutingOnlyEndpointIsAUsableUri() { - var connectionVars = ConnectionVars.CreateRoutingOnly(); + var connectionVars = ConnectionVars.CreateUnconfigured(); Assert.True(Uri.TryCreate(connectionVars.IngestionEndpoint, UriKind.Absolute, out _)); Assert.True(Uri.TryCreate(connectionVars.LiveEndpoint, UriKind.Absolute, out _)); @@ -55,19 +61,25 @@ public void TheRoutingOnlyEndpointIsAUsableUri() [Fact] public void TheRoutingOnlyVarsCarryNoAudience() { - Assert.Null(ConnectionVars.CreateRoutingOnly().AadAudience); + 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.Informational, EventKeywords.All); + listener.EnableEvents(AzureMonitorExporterEventSource.Log, EventLevel.Verbose, EventKeywords.All); AzureMonitorTransmitter.InitializeConnectionVars( new AzureMonitorExporterOptions(), new MockPlatform(), multiEndpointEnabled: true); - Assert.Single(listener.Messages.Where(e => e.EventName == "RoutingWithoutConnectionString")); + var announcement = Assert.Single(listener.Messages.Where(e => e.EventName == "RoutingWithoutConnectionString")); + Assert.Equal(77, announcement.EventId); + Assert.Equal(EventLevel.Warning, announcement.Level); } /// @@ -93,12 +105,20 @@ public void ATransmitterCanBeBuiltWithoutAConnectionString() [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 ---------- @@ -140,7 +160,7 @@ public void AConfiguredConnectionStringIsUsedRegardlessOfRouting(bool multiEndpo new MockPlatform(), multiEndpointEnabled); - Assert.False(connectionVars.IsRoutingOnly); + Assert.False(connectionVars.IsUnconfigured); Assert.Equal("configured", connectionVars.InstrumentationKey); } @@ -155,7 +175,7 @@ public void TheEnvironmentVariableIsStillHonouredRegardlessOfRouting(bool multiE var connectionVars = AzureMonitorTransmitter.InitializeConnectionVars( new AzureMonitorExporterOptions(), platform, multiEndpointEnabled); - Assert.False(connectionVars.IsRoutingOnly); + Assert.False(connectionVars.IsUnconfigured); Assert.Equal("from-env", connectionVars.InstrumentationKey); } @@ -182,53 +202,177 @@ public void AWhitespaceEnvironmentVariableFallsBackToRoutingOnly() var connectionVars = AzureMonitorTransmitter.InitializeConnectionVars( new AzureMonitorExporterOptions(), platform, multiEndpointEnabled: true); - Assert.True(connectionVars.IsRoutingOnly); + Assert.True(connectionVars.IsUnconfigured); } // ---------- Storage directory ---------- /// /// The seed changing would move every existing directory, orphaning backlogs that were - /// persisted for delivery. + /// 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 platform = MockPlatformForStorage(); - - var directory = StorageHelper.GetStorageDirectory(platform, configuredStorageDirectory: "C:\\root", instrumentationKey: "ikey-a"); - var expected = Path.Combine("C:\\root", HashHelper.GetSHA256Hash("ikey-a;user;process;appdir")); + var directory = StorageHelper.GetStorageDirectory(MockPlatformForStorage(), configuredStorageDirectory: "C:\\root", instrumentationKey: "ikey-a"); - Assert.Equal(expected, directory); + Assert.Equal(Path.Combine("C:\\root", "1468ec91488193bdf84d4390f553c2ded5fd4848881cbd77592d09d3aba09b2b"), directory); } /// - /// User, process and application directory already identify an application on a machine, so - /// the segment is omitted rather than filled with a placeholder. + /// 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 WithoutAKeyTheSegmentIsOmittedRatherThanBlank(string? instrumentationKey) + public void AnEmptyKeyFromAConnectionStringKeepsItsExistingDirectory(string? instrumentationKey) { - var platform = MockPlatformForStorage(); + var directory = StorageHelper.GetStorageDirectory(MockPlatformForStorage(), "C:\\root", instrumentationKey!, omitInstrumentationKey: false); - var directory = StorageHelper.GetStorageDirectory(platform, configuredStorageDirectory: "C:\\root", instrumentationKey: instrumentationKey); - var expected = Path.Combine("C:\\root", HashHelper.GetSHA256Hash("user;process;appdir")); + Assert.Equal(Path.Combine("C:\\root", "a4ca073d30fde56a69f4a3e48ef0bd598f7e4bf34a890a863f5d2b1850869498"), directory); + } - Assert.Equal(expected, directory); - Assert.NotEqual(Path.Combine("C:\\root", HashHelper.GetSHA256Hash(";user;process;appdir")), 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", instrumentationKey: null); - var second = StorageHelper.GetStorageDirectory(MockPlatformForStorage(processName: "second"), "C:\\root", instrumentationKey: null); + 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() { @@ -237,9 +381,38 @@ private static MockPlatform MockPlatformForStorage(string processName = "process 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("_statsbeat", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic)! + .GetField(name, System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic)! .GetValue(transmitter); } } From b2e8113034aa6ea16f27243931818aa3b99f5b6a Mon Sep 17 00:00:00 2001 From: Rajkumar Rangaraj Date: Mon, 14 Sep 2026 16:43:38 -0700 Subject: [PATCH 3/5] Let the demo run with no connection string of its own Adds a 'nohost' argument to the multi-endpoint demo for all three signals, so the configuration this change enables can actually be exercised. Without it the demo refused to start unless MULTIENDPOINT_HOST_CONNECTION_STRING was set, which is the requirement being removed. The exporter's ConnectionString is left unset rather than assigned null or empty, because an empty string is a parse error rather than an absent value. --- .../Logs/MultiEndpointLogDemo.cs | 7 +++-- .../Metrics/MultiEndpointMetricDemo.cs | 7 +++-- .../Program.cs | 31 ++++++++++++------- .../Traces/MultiEndpointTraceDemo.cs | 9 ++++-- 4 files changed, 36 insertions(+), 18 deletions(-) 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..755d0a3c8e2e 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 @@ -38,6 +38,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 +52,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 +83,17 @@ 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); + 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 +107,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 +151,17 @@ 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); + 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 +175,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 +219,17 @@ 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); + 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 +243,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. From 0a7f517aeba231c59bf1d3da0f1f30cb9b63d290 Mon Sep 17 00:00:00 2001 From: Rajkumar Rangaraj Date: Mon, 14 Sep 2026 16:50:27 -0700 Subject: [PATCH 4/5] Cover the routed success path without a connection string Copilot's PR review, and both earlier review rounds, flagged the same gap: every new test proved construction, storage wiring or refusal, while nothing asserted that routed telemetry is still delivered when the process has no connection string. A regression that stopped routing-only sends entirely would have gone undetected. Exports three destinations through the real exporter and transmitter with no connection string, and asserts each request's URI, that the serialized instrumentation key is the routed one, that nothing was addressed to the placeholder ingestion endpoint, and that no envelope carries an empty key. The positive assertions use the same serialized shape as the negative one so the latter cannot quietly stop matching. Also links the CHANGELOG entry to the pull request. --- .../CHANGELOG.md | 1 + .../MultiEndpointIntegrationTests.cs | 77 +++++++++++++++++++ 2 files changed, 78 insertions(+) diff --git a/sdk/monitor/Azure.Monitor.OpenTelemetry.Exporter/CHANGELOG.md b/sdk/monitor/Azure.Monitor.OpenTelemetry.Exporter/CHANGELOG.md index 3d9c0bdba119..2bdde4fdf395 100644 --- a/sdk/monitor/Azure.Monitor.OpenTelemetry.Exporter/CHANGELOG.md +++ b/sdk/monitor/Azure.Monitor.OpenTelemetry.Exporter/CHANGELOG.md @@ -17,6 +17,7 @@ - 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 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..2d0a52b95dc2 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,6 +14,7 @@ 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; @@ -472,9 +473,85 @@ 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, + }; + + return new AzureMonitorTraceExporter( + options, + new AzureMonitorTransmitter(options, DefaultPlatform.Instance, multiEndpointEnabled: true), + multiEndpointEnabled: true); + } + private static AzureMonitorTraceExporter CreateExporter(MockIngestion ingestion, bool multiEndpointEnabled, out string instrumentationKey) { instrumentationKey = "00000000-0000-0000-0000-0000000000ff"; From a6b3537c6ec05489ac965581e73debf8aa43b252 Mon Sep 17 00:00:00 2001 From: Rajkumar Rangaraj Date: Mon, 14 Sep 2026 17:13:32 -0700 Subject: [PATCH 5/5] Make the unconfigured mode impossible to fake in tests and the demo Both review models and Copilot independently found the same hole: the new integration tests built their transmitter with DefaultPlatform.Instance, which snapshots the real environment. On any machine or CI agent where APPLICATIONINSIGHTS_CONNECTION_STRING is set, the transmitter would be configured and every assertion would still hold, so the one test added to cover the unconfigured success path was the test most easily neutered by an ambient variable. It now uses MockPlatform and asserts the transmitter has no instrumentation key before returning. The demo had the same hole from the other direction: 'nohost' only skipped its own variable, so the exporter could still pick up the standard one and report "Host: NONE" while configured. It now refuses to run in that case rather than producing a verification run that proves nothing. Also notes at the seed site that dropping a connection string moves the storage directory, which strands what the previous configuration persisted. --- .../src/Internals/AzureMonitorTransmitter.cs | 2 ++ .../Program.cs | 31 +++++++++++++++++++ .../MultiEndpointIntegrationTests.cs | 13 +++++--- 3 files changed, 42 insertions(+), 4 deletions(-) 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 e33ec0d7c254..7120b9e5551c 100644 --- a/sdk/monitor/Azure.Monitor.OpenTelemetry.Exporter/src/Internals/AzureMonitorTransmitter.cs +++ b/sdk/monitor/Azure.Monitor.OpenTelemetry.Exporter/src/Internals/AzureMonitorTransmitter.cs @@ -174,6 +174,8 @@ 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, 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 755d0a3c8e2e..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)) @@ -85,6 +101,11 @@ public static void Main(string[] args) private static void RunMultiEndpointDemo(int activityCount, bool faultEndpoints, bool noHost) { + if (noHost && !CanRunWithoutAHost()) + { + return; + } + var hostConnectionString = noHost ? null : Environment.GetEnvironmentVariable(HostConnectionStringVariable); var routes = ParseRoutes(Environment.GetEnvironmentVariable(RouteConnectionStringsVariable)); @@ -153,6 +174,11 @@ private static void RunMultiEndpointDemo(int activityCount, bool faultEndpoints, private static void RunMultiEndpointMetricDemo(int measurementCount, bool faultEndpoints, bool noHost) { + if (noHost && !CanRunWithoutAHost()) + { + return; + } + var hostConnectionString = noHost ? null : Environment.GetEnvironmentVariable(HostConnectionStringVariable); var routes = ParseRoutes(Environment.GetEnvironmentVariable(RouteConnectionStringsVariable)); @@ -221,6 +247,11 @@ private static void RunMultiEndpointMetricDemo(int measurementCount, bool faultE private static void RunMultiEndpointLogDemo(int logCount, bool faultEndpoints, bool noHost) { + if (noHost && !CanRunWithoutAHost()) + { + return; + } + var hostConnectionString = noHost ? null : Environment.GetEnvironmentVariable(HostConnectionStringVariable); var routes = ParseRoutes(Environment.GetEnvironmentVariable(RouteConnectionStringsVariable)); 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 2d0a52b95dc2..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 @@ -20,6 +20,7 @@ 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; @@ -546,10 +547,14 @@ private static AzureMonitorTraceExporter CreateUnconfiguredExporter(MockIngestio EnableStatsbeat = false, }; - return new AzureMonitorTraceExporter( - options, - new AzureMonitorTransmitter(options, DefaultPlatform.Instance, multiEndpointEnabled: true), - multiEndpointEnabled: true); + // 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)