Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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);
}
Expand All @@ -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)
{
Expand All @@ -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();
Comment thread
Copilot marked this conversation as resolved.
Resolved
}

throw new InvalidOperationException("A connection string was not found. Please set your connection string.");
}

Expand Down Expand Up @@ -160,7 +177,23 @@ private static ApplicationInsightsRestClient InitializeRestClient(AzureMonitorEx
storageDirectory = StorageHelper.GetStorageDirectory(
platform: platform,
configuredStorageDirectory: configuredStorageDirectory,
instrumentationKey: connectionVars.InstrumentationKey);
instrumentationKey: connectionVars.InstrumentationKey,
omitInstrumentationKey: connectionVars.IsUnconfigured);
Comment thread
rajkumar-rangaraj marked this conversation as resolved.
Resolved

// 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);

Expand Down Expand Up @@ -544,6 +577,17 @@ public async ValueTask<ExportResult> TrackAsync(IEnumerable<TelemetryItem> 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)
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,19 +9,44 @@ 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;
}

/// <summary>
/// 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.
/// </summary>
/// <remarks>
/// The endpoint is deliberately the same value <see cref="ConnectionStringParser"/> defaults
/// to, so it cannot be told apart from a real destination by inspection. Callers must branch
/// on <see cref="IsUnconfigured"/> rather than on the values.
/// </remarks>
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; }

public string LiveEndpoint { get; }

public string? AadAudience { get; }

/// <summary>No connection string was configured, so these values name no destination.</summary>
public bool IsUnconfigured { get; }
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -18,12 +19,27 @@ internal static class CustomerSdkStatsRegistration
/// </summary>
/// <param name="options">Azure Monitor exporter options</param>
public static void RegisterCustomerSdkStats(AzureMonitorExporterOptions options)
=> RegisterCustomerSdkStats(options, DefaultPlatform.Instance);

/// <summary>
/// Overload taking the platform so the connection string can be read from the same source the
/// transmitter reads it from.
/// </summary>
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
Expand Down Expand Up @@ -77,5 +93,14 @@ internal static AzureMonitorExporterOptions CreateCustomerSdkStatsOptions(AzureM

return options;
}

/// <summary>
/// Reads what the transmitter reads. It is deliberately the more permissive of the two: the
/// transmitter treats only <see langword="null"/> as absent and would reject an empty or
/// whitespace string outright, so anything this rejects would have failed there anyway.
/// </summary>
internal static bool HasConnectionString(AzureMonitorExporterOptions options, IPlatform platform)
=> !string.IsNullOrWhiteSpace(options.ConnectionString)
|| !string.IsNullOrWhiteSpace(platform.GetEnvironmentVariable(EnvironmentVariableConstants.APPLICATIONINSIGHTS_CONNECTION_STRING));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ internal sealed class MultiEndpointLogDemo : IDisposable
private readonly ILoggerFactory _loggerFactory;
private readonly EndpointRoutingLogProcessor _routingProcessor;

public MultiEndpointLogDemo(string exporterConnectionString, IReadOnlyList<MultiEndpointTraceDemo.EndpointRoute> routes, string runId, bool faultRoutedEndpoints = false)
public MultiEndpointLogDemo(string? exporterConnectionString, IReadOnlyList<MultiEndpointTraceDemo.EndpointRoute> routes, string runId, bool faultRoutedEndpoints = false)
{
_routingProcessor = new EndpointRoutingLogProcessor(routes, runId);

Expand All @@ -58,7 +58,10 @@ public MultiEndpointLogDemo(string exporterConnectionString, IReadOnlyList<Multi

options.AddAzureMonitorLogExporter(o =>
{
o.ConnectionString = exporterConnectionString;
if (!string.IsNullOrWhiteSpace(exporterConnectionString))
{
o.ConnectionString = exporterConnectionString;
}

if (faultRoutedEndpoints)
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ internal sealed class MultiEndpointMetricDemo : IDisposable
private readonly Dictionary<string, int> _counts = new(StringComparer.Ordinal);
private readonly Dictionary<string, int> _unroutableCounts = new(StringComparer.Ordinal);

public MultiEndpointMetricDemo(string exporterConnectionString, IReadOnlyList<MultiEndpointTraceDemo.EndpointRoute> routes, string runId, bool faultRoutedEndpoints = false)
public MultiEndpointMetricDemo(string? exporterConnectionString, IReadOnlyList<MultiEndpointTraceDemo.EndpointRoute> routes, string runId, bool faultRoutedEndpoints = false)
{
_routes = routes;
_runId = runId;
Expand All @@ -74,7 +74,10 @@ public MultiEndpointMetricDemo(string exporterConnectionString, IReadOnlyList<Mu
.AddMeter(meterName)
.AddAzureMonitorMetricExporter(o =>
{
o.ConnectionString = exporterConnectionString;
if (!string.IsNullOrWhiteSpace(exporterConnectionString))
{
o.ConnectionString = exporterConnectionString;
}

if (faultRoutedEndpoints)
{
Expand Down
Loading
Loading