File: MauiOtlpExtensions.cs
Web Access
Project: src\src\Aspire.Hosting.Maui\Aspire.Hosting.Maui.csproj (Aspire.Hosting.Maui)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
using Aspire.Hosting.ApplicationModel;
using Aspire.Hosting.DevTunnels;
using Aspire.Hosting.Maui;
using Aspire.Hosting.Maui.Annotations;
using Aspire.Hosting.Maui.Otlp;
 
namespace Aspire.Hosting;
 
/// <summary>
/// Provides extension methods for configuring OpenTelemetry endpoints for MAUI platform resources.
/// </summary>
public static class MauiOtlpExtensions
{
    /// <summary>
    /// Configures the MAUI platform resource to send OpenTelemetry data through an automatically created dev tunnel.
    /// This is the easiest option for most scenarios, as it handles tunnel creation, configuration, and endpoint
    /// injection automatically.
    /// </summary>
    /// <typeparam name="T">The MAUI platform resource type.</typeparam>
    /// <param name="builder">The resource builder.</param>
    /// <returns>The resource builder.</returns>
    /// <remarks>
    /// <para>
    /// This method creates a dev tunnel automatically and configures the MAUI platform resource to route
    /// OTLP traffic through it. This is the recommended approach for most scenarios as it requires minimal
    /// configuration and works reliably across all mobile platforms.
    /// </para>
    /// <para>
    /// Prerequisites:
    /// <list type="bullet">
    ///   <item>Aspire.Hosting.DevTunnels package must be referenced</item>
    ///   <item>Dev tunnel CLI must be installed (automatic prompt if missing)</item>
    ///   <item>User must be logged in to dev tunnel service (automatic prompt if needed)</item>
    /// </list>
    /// </para>
    /// </remarks>
    /// <example>
    /// Configure a MAUI Android device to automatically use a dev tunnel for telemetry:
    /// <code lang="csharp">
    /// var builder = DistributedApplication.CreateBuilder(args);
    /// 
    /// var maui = builder.AddMauiProject("mauiapp", "../MyMauiApp/MyMauiApp.csproj");
    /// maui.AddAndroidDevice()
    ///     .WithOtlpDevTunnel(); // That's it - everything is configured automatically!
    /// 
    /// builder.Build().Run();
    /// </code>
    /// </example>
    public static IResourceBuilder<T> WithOtlpDevTunnel<T>(
        this IResourceBuilder<T> builder)
        where T : IMauiPlatformResource, IResourceWithEnvironment
    {
        ArgumentNullException.ThrowIfNull(builder);
 
        // Get shared state - only create stub + tunnel once per app
        var platformResource = builder.Resource;
        var parentBuilder = builder.ApplicationBuilder.CreateResourceBuilder(platformResource.Parent);
        var configuration = builder.ApplicationBuilder.Configuration;
 
        // Check if we already created the stub + tunnel for this MAUI project
        if (!parentBuilder.Resource.TryGetLastAnnotation<OtlpDevTunnelConfigurationAnnotation>(out var tunnelConfig))
        {
            // First time - create stub and dev tunnel
            tunnelConfig = CreateOtlpDevTunnelInfrastructure(parentBuilder, configuration);
            parentBuilder.Resource.Annotations.Add(tunnelConfig);
        }
 
        // Now apply the configuration to this specific platform
        ApplyOtlpConfigurationToPlatform(builder, tunnelConfig);
 
        return builder;
    }
 
    /// <summary>
    /// Creates the OTLP dev tunnel infrastructure (stub resource + dev tunnel).
    /// This is only created once per MAUI project and shared across all platforms.
    /// </summary>
    private static OtlpDevTunnelConfigurationAnnotation CreateOtlpDevTunnelInfrastructure(
        IResourceBuilder<MauiProjectResource> parentBuilder,
        Microsoft.Extensions.Configuration.IConfiguration configuration)
    {
        var appBuilder = parentBuilder.ApplicationBuilder;
 
        // Resolve OTLP scheme and port from configuration
        var (otlpScheme, otlpPort) = OtlpEndpointResolver.Resolve(configuration);
 
        // Create names for the tunnel infrastructure
        // Use a short random suffix to ensure uniqueness (similar to DCP naming strategy)
        // The dev tunnel port resource name will be: {parent resource name}-{random}-otlp
        var randomSuffix = Guid.NewGuid().ToString("N")[..8];
        var tunnelName = parentBuilder.Resource.Name;
        var stubName = $"t{randomSuffix}"; // Prefix with 't' to ensure valid resource name
 
        // Create OtlpLoopbackResource - a synthetic IResourceWithEndpoints for service discovery
        var stubResource = new OtlpLoopbackResource(stubName, otlpPort, otlpScheme);
 
        var stubBuilder = appBuilder.AddResource(stubResource)
            .ExcludeFromManifest();
 
        // Hide the stub from the dashboard UI
        stubBuilder.WithInitialState(new CustomResourceSnapshot
        {
            ResourceType = "OtlpStub",
            Properties = [],
            IsHidden = true
        });
 
        // Create dev tunnel with anonymous access for OTLP
        var devTunnel = appBuilder.AddDevTunnel(tunnelName)
            .WithAnonymousAccess()
            .WithReference(stubBuilder, new DevTunnelPortOptions { Protocol = "https" });
 
        // Manually allocate the stub endpoint so dev tunnel can start
        // Dev tunnels wait for ResourceEndpointsAllocatedEvent before starting
        appBuilder.Eventing.Subscribe<BeforeStartEvent>((evt, ct) =>
        {
            var endpoint = stubResource.Annotations.OfType<EndpointAnnotation>().FirstOrDefault();
            if (endpoint is not null && endpoint.AllocatedEndpoint is null)
            {
                endpoint.AllocatedEndpoint = new AllocatedEndpoint(endpoint, "localhost", otlpPort);
                return appBuilder.Eventing.PublishAsync(new ResourceEndpointsAllocatedEvent(stubResource, evt.Services), ct);
            }
            return Task.CompletedTask;
        });
 
        return new OtlpDevTunnelConfigurationAnnotation(stubResource, stubBuilder, devTunnel);
    }
 
    /// <summary>
    /// Applies OTLP configuration to a specific MAUI platform resource.
    /// Uses service discovery through WithReference to get the tunneled endpoint, then overrides OTEL_EXPORTER_OTLP_ENDPOINT.
    /// </summary>
    private static void ApplyOtlpConfigurationToPlatform<T>(
        IResourceBuilder<T> platformBuilder,
        OtlpDevTunnelConfigurationAnnotation tunnelConfig)
        where T : IMauiPlatformResource, IResourceWithEnvironment
    {
        // Use WithReference to inject service discovery variables for the stub through the dev tunnel
        // This adds SERVICES__<STUBNAME>__OTLP__0=https://tunnel-url which we'll use and then clean up
        platformBuilder.WithReference(tunnelConfig.OtlpStubBuilder, tunnelConfig.DevTunnel);
 
        // Override OTEL_EXPORTER_OTLP_ENDPOINT with the tunneled URL and clean up extra variables
        platformBuilder.WithEnvironment(context =>
        {
            // Read the service discovery variable that WithReference just added
            // Format: services__{resourcename}__otlp__0 (lowercase)
            var serviceDiscoveryKey = $"services__{tunnelConfig.OtlpStub.Name}__otlp__0";
            if (context.EnvironmentVariables.TryGetValue(serviceDiscoveryKey, out var tunnelUrl))
            {
                // Override OTEL_EXPORTER_OTLP_ENDPOINT with the tunnel URL
                context.EnvironmentVariables["OTEL_EXPORTER_OTLP_ENDPOINT"] = tunnelUrl;
 
                // Remove the service discovery variables since we're using direct OTLP configuration
                context.EnvironmentVariables.Remove(serviceDiscoveryKey);
 
                // Also remove the {RESOURCENAME}_{ENDPOINTNAME} format variable (e.g., MAUIAPP-OTLP_OTLP)
                // The resource name keeps its case/dashes, endpoint name is uppercased
                var directEndpointKey = $"{tunnelConfig.OtlpStub.Name.ToUpperInvariant()}_OTLP";
                context.EnvironmentVariables.Remove(directEndpointKey);
            }
        });
    }
}