File: OtlpConfigurationExtensions.cs
Web Access
Project: src\src\Aspire.Hosting\Aspire.Hosting.csproj (Aspire.Hosting)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
using System.Diagnostics.CodeAnalysis;
using Aspire.Hosting.ApplicationModel;
using Aspire.Hosting.Dcp.Model;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
 
namespace Aspire.Hosting;
 
/// <summary>
/// Provides extension methods for configuring OpenTelemetry in projects using environment variables.
/// </summary>
public static class OtlpConfigurationExtensions
{
    private const string ServiceInstanceIdAttributeName = "service.instance.id";
 
    // DCP replaces this template with the instance id of each replica when it starts the process or container.
    private const string ServiceInstanceIdTemplate = "{{- index .Annotations \"" + CustomResource.OtelServiceInstanceIdAnnotation + "\" -}}";
 
    /// <summary>
    /// Configures OpenTelemetry in projects using environment variables.
    /// </summary>
    /// <param name="resource">The resource to add annotations to.</param>
    /// <param name="configuration">The configuration to use for the OTLP exporter endpoint URL.</param>
    /// <param name="environment">The host environment to check if the application is running in development mode.</param>
    public static void AddOtlpEnvironment(IResource resource, IConfiguration configuration, IHostEnvironment environment)
    {
        ArgumentNullException.ThrowIfNull(resource);
        ArgumentNullException.ThrowIfNull(configuration);
        ArgumentNullException.ThrowIfNull(environment);
 
        // Add annotation to mark this resource as having OTLP exporter configured
        resource.Annotations.Add(new OtlpExporterAnnotation());
 
        RegisterOtlpEnvironment(resource, configuration, environment);
    }
 
    /// <summary>
    /// Configures OpenTelemetry in projects using environment variables.
    /// </summary>
    /// <param name="resource">The resource to add annotations to.</param>
    /// <param name="configuration">The configuration to use for the OTLP exporter endpoint URL.</param>
    /// <param name="environment">The host environment to check if the application is running in development mode.</param>
    /// <param name="protocol">The protocol to use for the OTLP exporter. If not set, it will try gRPC then Http.</param>
    public static void AddOtlpEnvironment(IResource resource, IConfiguration configuration, IHostEnvironment environment, OtlpProtocol protocol)
    {
        ArgumentNullException.ThrowIfNull(resource);
        ArgumentNullException.ThrowIfNull(configuration);
        ArgumentNullException.ThrowIfNull(environment);
 
        // Add annotation to mark this resource as having OTLP exporter configured with a required protocol
        resource.Annotations.Add(new OtlpExporterAnnotation { RequiredProtocol = protocol });
 
        RegisterOtlpEnvironment(resource, configuration, environment);
    }
 
    /// <summary>
    /// Configures OTLP export only when the required collector endpoint is available.
    /// </summary>
    /// <typeparam name="T">The resource type.</typeparam>
    /// <param name="builder">The resource builder.</param>
    /// <param name="protocol">The required OTLP protocol.</param>
    /// <returns>The <see cref="IResourceBuilder{T}"/>.</returns>
    /// <remarks>
    /// The exporter annotation is retained when no endpoint is available, but OTLP environment variables are
    /// omitted so workloads that can operate without telemetry remain runnable.
    /// </remarks>
    [AspireExportIgnore(Reason = "Optional OTLP exporter registration is currently used by C# hosting integrations only.")]
    public static IResourceBuilder<T> WithOtlpExporterIfEndpointAvailable<T>(
        this IResourceBuilder<T> builder,
        OtlpProtocol protocol) where T : IResourceWithEnvironment
    {
        ArgumentNullException.ThrowIfNull(builder);
 
        builder.Resource.Annotations.Add(new OtlpExporterAnnotation { RequiredProtocol = protocol });
        RegisterOtlpEnvironment(
            builder.Resource,
            builder.ApplicationBuilder.Configuration,
            builder.ApplicationBuilder.Environment,
            skipIfEndpointUnavailable: true);
 
        return builder;
    }
 
    private static void RegisterOtlpEnvironment(
        IResource resource,
        IConfiguration configuration,
        IHostEnvironment environment,
        bool skipIfEndpointUnavailable = false)
    {
        // Configure OpenTelemetry in projects using environment variables.
        // https://github.com/open-telemetry/opentelemetry-specification/blob/main/specification/configuration/sdk-environment-variables.md
 
        resource.Annotations.Add(new EnvironmentCallbackAnnotation(async context =>
        {
            if (context.ExecutionContext.IsPublishMode)
            {
                // REVIEW:  Do we want to set references to an imaginary otlp provider as a requirement?
                return;
            }
 
            var otlpExporterAnnotation = GetEffectiveOtlpExporterAnnotation(resource);
            if (otlpExporterAnnotation is null)
            {
                return;
            }
 
            var dashboardEndpoint = ResolveOtlpEndpointFromDashboard(context, otlpExporterAnnotation.RequiredProtocol);
 
            if (dashboardEndpoint is not null)
            {
                // Use the dashboard endpoint reference directly. This resolves to the actual allocated URL,
                // including when ports are randomized (e.g. isolated mode).
                context.EnvironmentVariables[KnownOtelConfigNames.ExporterOtlpEndpoint] = dashboardEndpoint.Value.Endpoint;
                context.EnvironmentVariables[KnownOtelConfigNames.ExporterOtlpProtocol] = dashboardEndpoint.Value.Protocol;
            }
            else
            {
                if (skipIfEndpointUnavailable &&
                    !HasConfiguredOtlpEndpoint(configuration, otlpExporterAnnotation.RequiredProtocol))
                {
                    return;
                }
 
                // Fall back to resolving from configuration. This is the case when the dashboard resource
                // is not in the model (e.g. in tests or publish mode).
                var (url, protocol) = OtlpEndpointResolver.ResolveOtlpEndpoint(configuration, otlpExporterAnnotation.RequiredProtocol);
                context.EnvironmentVariables[KnownOtelConfigNames.ExporterOtlpEndpoint] = new HostUrl(url);
                context.EnvironmentVariables[KnownOtelConfigNames.ExporterOtlpProtocol] = protocol;
            }
 
            // Runtime-specific exporters are activated in the same step that confirms an endpoint exists.
            // This keeps activation independent of environment callback ordering and avoids enabling exporters
            // for dashboard-free workloads.
            ApplyActivationEnvironmentVariables(resource, context.EnvironmentVariables);
 
            // Set the service name and instance id to the resource name and UID. Values are injected by DCP.
            // OTEL_RESOURCE_ATTRIBUTES can already hold attributes from a callback that ran earlier, for example
            // WithEnvironment("OTEL_RESOURCE_ATTRIBUTES", "deployment.environment=dev") before WithOtlpExporter() on a
            // container, so add the instance id to that value instead of replacing it. An explicit service.instance.id
            // is kept. A value that is not a plain string (for example a ReferenceExpression) cannot be inspected
            // before it resolves, so it is replaced as before.
            context.EnvironmentVariables.TryGetValue(KnownOtelConfigNames.ResourceAttributes, out var resourceAttributes);
            context.EnvironmentVariables[KnownOtelConfigNames.ResourceAttributes] = AddResourceAttributeIfMissing(
                resourceAttributes as string,
                ServiceInstanceIdAttributeName,
                ServiceInstanceIdTemplate);
            context.EnvironmentVariables[KnownOtelConfigNames.ServiceName] = "{{- index .Annotations \"" + CustomResource.OtelServiceNameAnnotation + "\" -}}";
 
            if (configuration["AppHost:OtlpApiKey"] is { } otlpApiKey)
            {
                context.EnvironmentVariables[KnownOtelConfigNames.ExporterOtlpHeaders] = $"x-otlp-api-key={otlpApiKey}";
            }
 
            // Configure OTLP to quickly provide all data with a small delay in development.
            if (environment.IsDevelopment())
            {
                // Set a small batch schedule delay in development.
                // This reduces the delay that OTLP exporter waits to sends telemetry and makes the dashboard telemetry pages responsive.
                var value = "1000"; // milliseconds
                context.EnvironmentVariables[KnownOtelConfigNames.BlrpScheduleDelay] = value;
                context.EnvironmentVariables[KnownOtelConfigNames.BspScheduleDelay] = value;
                context.EnvironmentVariables[KnownOtelConfigNames.MetricExportInterval] = value;
 
                // Configure trace sampler to send all traces to the dashboard.
                context.EnvironmentVariables[KnownOtelConfigNames.TracesSampler] = "always_on";
                // Configure metrics to include exemplars.
                context.EnvironmentVariables[KnownOtelConfigNames.MetricsExemplarFilter] = "trace_based";
 
                // Output sensitive message content for GenAI.
                // A convention for libraries that output GenAI telemetry is to use `OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT` env var.
                // See:
                // - https://opentelemetry.io/blog/2024/otel-generative-ai/
                // - https://github.com/search?q=org%3Aopen-telemetry+OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT&type=code
                context.EnvironmentVariables[KnownOtelConfigNames.InstrumentationGenAiCaptureMessageContent] = "true";
            }
        }));
    }
 
    private static bool HasConfiguredOtlpEndpoint(IConfiguration configuration, OtlpProtocol? requiredProtocol)
    {
        var grpcEndpoint = configuration.GetString(
            KnownConfigNames.DashboardOtlpGrpcEndpointUrl,
            KnownConfigNames.Legacy.DashboardOtlpGrpcEndpointUrl);
        var httpEndpoint = configuration.GetString(
            KnownConfigNames.DashboardOtlpHttpEndpointUrl,
            KnownConfigNames.Legacy.DashboardOtlpHttpEndpointUrl);
 
        return requiredProtocol switch
        {
            OtlpProtocol.Grpc => !string.IsNullOrWhiteSpace(grpcEndpoint),
            OtlpProtocol.HttpProtobuf or OtlpProtocol.HttpJson => !string.IsNullOrWhiteSpace(httpEndpoint),
            _ => !string.IsNullOrWhiteSpace(grpcEndpoint) || !string.IsNullOrWhiteSpace(httpEndpoint),
        };
    }
 
    private static void ApplyActivationEnvironmentVariables(
        IResource resource,
        IDictionary<string, object> environmentVariables)
    {
        if (GetEffectiveOtlpExporterAnnotation(resource) is not IReadOnlyDictionary<string, string> activationEnvironmentVariables)
        {
            return;
        }
 
        foreach (var (name, value) in activationEnvironmentVariables)
        {
            environmentVariables.TryAdd(name, value);
        }
    }
 
    private static OtlpExporterAnnotation? GetEffectiveOtlpExporterAnnotation(IResource resource)
        => resource.Annotations.OfType<OtlpExporterAnnotation>().LastOrDefault();
 
    /// <summary>
    /// Injects the appropriate environment variables to allow the resource to enable sending telemetry to the dashboard.
    /// <list type="number">
    ///   <item>It sets the OTLP endpoint to the value of the <c>ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL</c> environment variable.</item>
    ///   <item>It sets the service name and instance id to the resource name and UID. Values are injected by the orchestrator.</item>
    ///   <item>It sets a small batch schedule delay in development. This reduces the delay that OTLP exporter waits to sends telemetry and makes the dashboard telemetry pages responsive.</item>
    /// </list>
    /// </summary>
    /// <typeparam name="T">The resource type.</typeparam>
    /// <param name="builder">The resource builder.</param>
    /// <returns>The <see cref="IResourceBuilder{T}"/>.</returns>
    [AspireExportIgnore(Reason = "Polyglot AppHosts use the internal withOtlpExporter dispatcher export.")]
    public static IResourceBuilder<T> WithOtlpExporter<T>(this IResourceBuilder<T> builder) where T : IResourceWithEnvironment
    {
        ArgumentNullException.ThrowIfNull(builder);
 
        AddOtlpEnvironment(builder.Resource, builder.ApplicationBuilder.Configuration, builder.ApplicationBuilder.Environment);
 
        return builder;
    }
 
    /// <summary>
    /// Configures OTLP telemetry export
    /// </summary>
    [AspireExport("withOtlpExporter")]
    internal static IResourceBuilder<T> WithOtlpExporterForPolyglot<T>(
        this IResourceBuilder<T> builder,
        OtlpProtocol? protocol = null) where T : IResourceWithEnvironment
    {
        ArgumentNullException.ThrowIfNull(builder);
 
        return protocol is null
            ? builder.WithOtlpExporter()
            : builder.WithOtlpExporter(protocol.Value);
    }
 
    /// <summary>
    /// Injects the appropriate environment variables to allow the resource to enable sending telemetry to the dashboard.
    /// <list type="number">
    ///   <item>It sets the OTLP endpoint to the value of the <c>ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL</c> environment variable.</item>
    ///   <item>It sets the service name and instance id to the resource name and UID. Values are injected by the orchestrator.</item>
    ///   <item>It sets a small batch schedule delay in development. This reduces the delay that OTLP exporter waits to sends telemetry and makes the dashboard telemetry pages responsive.</item>
    /// </list>
    /// </summary>
    /// <typeparam name="T">The resource type.</typeparam>
    /// <param name="builder">The resource builder.</param>
    /// <param name="protocol">The protocol to use for the OTLP exporter. If not set, it will try gRPC then Http.</param>
    /// <returns>The <see cref="IResourceBuilder{T}"/>.</returns>
    [AspireExportIgnore(Reason = "Polyglot AppHosts use the internal withOtlpExporter dispatcher export.")]
    public static IResourceBuilder<T> WithOtlpExporter<T>(this IResourceBuilder<T> builder, OtlpProtocol protocol) where T : IResourceWithEnvironment
    {
        ArgumentNullException.ThrowIfNull(builder);
 
        AddOtlpEnvironment(builder.Resource, builder.ApplicationBuilder.Configuration, builder.ApplicationBuilder.Environment, protocol);
 
        return builder;
    }
 
    /// <summary>
    /// Tries to resolve the OTLP endpoint from the dashboard resource in the distributed application model.
    /// This ensures that when ports are randomized (e.g. isolated mode), resources use the actual
    /// allocated endpoint rather than the statically configured port.
    /// </summary>
    /// <remarks>
    /// The returned <see cref="EndpointReference"/> has no network context baked in, so it resolves
    /// using the calling resource's network at evaluation time. This means containers automatically
    /// get container-network URLs and non-containers get localhost URLs.
    /// </remarks>
    private static (EndpointReference Endpoint, string Protocol)? ResolveOtlpEndpointFromDashboard(EnvironmentCallbackContext context, OtlpProtocol? requiredProtocol)
    {
        DistributedApplicationModel? model;
        try
        {
            model = context.ExecutionContext.Services.GetService<DistributedApplicationModel>();
        }
        catch (InvalidOperationException)
        {
            // ServiceProvider may not be available if the container hasn't been built yet
            // (e.g. env var evaluation during testing without a fully built host).
            return null;
        }
 
        if (model is null)
        {
            return null;
        }
 
        if (!model.Resources.TryGetByName(KnownResourceNames.AspireDashboard, out var resource) || resource is not IResourceWithEndpoints dashboardResource)
        {
            return null;
        }
 
        var grpcEndpoint = dashboardResource.GetEndpoint(KnownEndpointNames.OtlpGrpcEndpointName);
        var httpEndpoint = dashboardResource.GetEndpoint(KnownEndpointNames.OtlpHttpEndpointName);
 
        return (requiredProtocol, grpcEndpoint.Exists, httpEndpoint.Exists) switch
        {
            (OtlpProtocol.Grpc, true, _) => (grpcEndpoint, "grpc"),
            (OtlpProtocol.HttpProtobuf, _, true) => (httpEndpoint, "http/protobuf"),
            (OtlpProtocol.HttpJson, _, true) => (httpEndpoint, "http/json"),
            (_, true, _) => (grpcEndpoint, "grpc"),
            (_, _, true) => (httpEndpoint, "http/protobuf"),
            _ => null
        };
    }
 
    /// <summary>
    /// Gets the <c>service.instance.id</c> value in the <c>OTEL_RESOURCE_ATTRIBUTES</c> environment variable.
    /// </summary>
    /// <param name="environmentVariables">The environment variables of a resource.</param>
    /// <returns>The value, or <see langword="null"/> when the variable is missing, is not a string, or has no such attribute.</returns>
    internal static string? GetServiceInstanceId(Dictionary<string, object> environmentVariables)
    {
        return environmentVariables.TryGetValue(KnownOtelConfigNames.ResourceAttributes, out var value) &&
            value is string resourceAttributes &&
            TryGetResourceAttribute(resourceAttributes, ServiceInstanceIdAttributeName, out var serviceInstanceId)
            ? serviceInstanceId
            : null;
    }
 
    /// <summary>
    /// Adds <c>service.instance.id</c> back to the <c>OTEL_RESOURCE_ATTRIBUTES</c> environment variable when its value no longer has one.
    /// </summary>
    /// <param name="environmentVariables">The environment variables of a resource.</param>
    /// <param name="serviceInstanceId">The <c>service.instance.id</c> value to add.</param>
    /// <returns>
    /// <paramref name="environmentVariables"/> when nothing changes; otherwise a copy with the attribute added. The input is
    /// not modified because it can be the cached result of an environment callback.
    /// </returns>
    internal static Dictionary<string, object> RestoreServiceInstanceId(Dictionary<string, object> environmentVariables, string serviceInstanceId)
    {
        // A removed variable stays removed, since removing it is a deliberate choice. A value that is not a plain string
        // (for example a ReferenceExpression) is left alone because it cannot be inspected before it resolves.
        if (!environmentVariables.TryGetValue(KnownOtelConfigNames.ResourceAttributes, out var value) ||
            value is not string resourceAttributes ||
            TryGetResourceAttribute(resourceAttributes, ServiceInstanceIdAttributeName, out _))
        {
            return environmentVariables;
        }
 
        return new Dictionary<string, object>(environmentVariables)
        {
            [KnownOtelConfigNames.ResourceAttributes] = AddResourceAttributeIfMissing(resourceAttributes, ServiceInstanceIdAttributeName, serviceInstanceId)
        };
    }
 
    /// <summary>
    /// Adds an attribute to an <c>OTEL_RESOURCE_ATTRIBUTES</c> value unless the value already has an attribute with that key.
    /// </summary>
    internal static string AddResourceAttributeIfMissing(string? resourceAttributes, string key, string value)
    {
        if (string.IsNullOrWhiteSpace(resourceAttributes))
        {
            return $"{key}={value}";
        }
 
        return TryGetResourceAttribute(resourceAttributes, key, out _)
            ? resourceAttributes
            : $"{resourceAttributes},{key}={value}";
    }
 
    /// <summary>
    /// Gets the value of an attribute in an <c>OTEL_RESOURCE_ATTRIBUTES</c> value.
    /// </summary>
    internal static bool TryGetResourceAttribute(string resourceAttributes, string key, [NotNullWhen(true)] out string? value)
    {
        // OTEL_RESOURCE_ATTRIBUTES is a comma-separated list of key=value pairs, for example:
        //   deployment.environment=dev,service.instance.id=api-x7k2
        // ',' and '=' inside keys and values must be percent-encoded, so splitting on every ',' and then on the first
        // '=' finds each pair. Whitespace around a key is ignored and an entry without '=' is skipped, as the
        // OpenTelemetry .NET SDK does. The value is returned as written, still percent-encoded.
        // https://opentelemetry.io/docs/specs/otel/configuration/sdk-environment-variables/
        // https://opentelemetry.io/docs/specs/otel/resource/sdk/#specifying-resource-information-via-an-environment-variable
        foreach (var pair in resourceAttributes.Split(','))
        {
            var separatorIndex = pair.IndexOf('=');
            if (separatorIndex >= 0 && pair.AsSpan(0, separatorIndex).Trim().Equals(key, StringComparison.Ordinal))
            {
                value = pair[(separatorIndex + 1)..];
                return true;
            }
        }
 
        value = null;
        return false;
    }
}