File: src\Shared\Telemetry\AspireTelemetryBase.cs
Web Access
Project: src\src\Aspire.Cli\Aspire.Cli.csproj (aspire)
// 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;
using System.Runtime.CompilerServices;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
 
namespace Aspire.Shared.Telemetry;
 
/// <summary>
/// Provides reported and diagnostic activities and structured events for Aspire product telemetry.
/// </summary>
public abstract class AspireTelemetryBase : IDisposable
{
    private readonly ActivitySource _reportedActivitySource;
    private readonly ActivitySource _diagnosticsActivitySource;
    private readonly ILogger _logger;
    private readonly Lock _lifecycleLock = new();
    private ILogger _eventLogger = NullLogger.Instance;
    private bool _disposed;
    private readonly string _errorEventName;
 
    /// <summary>
    /// Initializes telemetry with product-specific activity source and error event names.
    /// </summary>
    /// <param name="logger">The logger for local errors.</param>
    /// <param name="reportedSourceName">The externally reported activity source name.</param>
    /// <param name="diagnosticsSourceName">The local diagnostic activity source name.</param>
    /// <param name="errorEventName">The name of reported error events.</param>
    protected AspireTelemetryBase(ILogger logger, string reportedSourceName, string diagnosticsSourceName, string errorEventName)
    {
        _logger = logger;
        _reportedActivitySource = new ActivitySource(reportedSourceName);
        _diagnosticsActivitySource = new ActivitySource(diagnosticsSourceName);
        _errorEventName = errorEventName;
    }
 
    internal void SetEventLogger(ILogger? eventLogger)
    {
        lock (_lifecycleLock)
        {
            // Managers can detach their logger even if the recording service was disposed first.
            if (eventLogger is not null)
            {
                ObjectDisposedException.ThrowIf(_disposed, this);
            }
            Volatile.Write(ref _eventLogger, eventLogger ?? NullLogger.Instance);
        }
    }
 
    /// <summary>
    /// Starts an externally reported activity.
    /// </summary>
    /// <param name="name">The activity name.</param>
    /// <param name="kind">The activity kind.</param>
    /// <returns>The activity, or <see langword="null"/> when no listener samples it.</returns>
    public Activity? StartReportedActivity([CallerMemberName] string name = "", ActivityKind kind = ActivityKind.Internal)
    {
        ArgumentException.ThrowIfNullOrWhiteSpace(name);
        return _reportedActivitySource.StartActivity(name, kind);
    }
 
    /// <summary>
    /// Starts an externally reported activity with an explicit parent.
    /// </summary>
    /// <param name="name">The activity name.</param>
    /// <param name="kind">The activity kind.</param>
    /// <param name="parentContext">The parent context.</param>
    /// <returns>The sampled activity, or <see langword="null"/>.</returns>
    public Activity? StartReportedActivity(string name, ActivityKind kind, ActivityContext parentContext)
    {
        ArgumentException.ThrowIfNullOrWhiteSpace(name);
        return _reportedActivitySource.StartActivity(name, kind, parentContext);
    }
 
    /// <summary>
    /// Starts an externally reported activity with explicit correlation links.
    /// </summary>
    /// <param name="name">The activity name.</param>
    /// <param name="kind">The activity kind.</param>
    /// <param name="parentContext">The parent context.</param>
    /// <param name="links">Links to related activities.</param>
    /// <returns>The sampled activity, or <see langword="null"/>.</returns>
    protected Activity? StartReportedActivity(string name, ActivityKind kind, ActivityContext parentContext, IEnumerable<ActivityLink> links)
    {
        ArgumentException.ThrowIfNullOrWhiteSpace(name);
        return _reportedActivitySource.StartActivity(name, kind, parentContext, links: links);
    }
 
    /// <summary>
    /// Starts a local diagnostic activity.
    /// </summary>
    /// <param name="name">The activity name.</param>
    /// <param name="kind">The activity kind.</param>
    /// <returns>The sampled activity, or <see langword="null"/>.</returns>
    public Activity? StartDiagnosticActivity([CallerMemberName] string name = "", ActivityKind kind = ActivityKind.Internal)
    {
        ArgumentException.ThrowIfNullOrWhiteSpace(name);
        return _diagnosticsActivitySource.StartActivity(name, kind);
    }
 
    /// <summary>
    /// Starts a local diagnostic activity with an explicit parent.
    /// </summary>
    /// <param name="name">The activity name.</param>
    /// <param name="kind">The activity kind.</param>
    /// <param name="parentContext">The parent context.</param>
    /// <returns>The sampled activity, or <see langword="null"/>.</returns>
    public Activity? StartDiagnosticActivity(string name, ActivityKind kind, ActivityContext parentContext)
    {
        ArgumentException.ThrowIfNullOrWhiteSpace(name);
        return _diagnosticsActivitySource.StartActivity(name, kind, parentContext);
    }
 
    /// <summary>
    /// Records a structured event immediately.
    /// </summary>
    /// <remarks>
    /// Applies the product's property privacy policy to the log, including default metadata.
    /// Sets the log's Azure Monitor operation name to the event name.
    /// </remarks>
    /// <param name="eventName">The event name.</param>
    /// <param name="properties">The product-specific event properties.</param>
    protected void RecordEventCore(string eventName, IEnumerable<KeyValuePair<string, object?>>? properties)
    {
        ArgumentException.ThrowIfNullOrWhiteSpace(eventName);
        if (!IsReportedTelemetryEnabled)
        {
            return;
        }
 
        var tags = CreateProperties(properties);
        List<KeyValuePair<string, object?>> attributes =
        [
            // Azure Monitor stringifies log attributes with Convert.ToString, which turns a string[]
            // into "System.String[]". Join collections after privacy filtering; span tags stay typed.
            // https://github.com/Azure/azure-sdk-for-net/blob/Azure.Monitor.OpenTelemetry.Exporter_1.9.0/sdk/monitor/Azure.Monitor.OpenTelemetry.Exporter/src/Internals/LogsHelper.cs
            .. tags.Select(tag => new KeyValuePair<string, object?>(tag.Key,
                tag.Value is IEnumerable<string> values
                    ? string.Join(",", values)
                    : tag.Value)),
            new("{OriginalFormat}", eventName),
            // Azure Monitor reads OperationName from this attribute, not EventId.Name.
            // https://github.com/Azure/azure-sdk-for-net/blob/main/sdk/monitor/Azure.Monitor.OpenTelemetry.Exporter/src/Internals/LogsHelper.cs
            new("microsoft.operation_name", eventName)
        ];
        // Logging without an exception keeps sanitized errors in Application Insights' traces table.
        Volatile.Read(ref _eventLogger).Log(LogLevel.Information, new EventId(0, eventName), attributes,
            exception: null, formatter: (_, _) => eventName);
    }
 
    /// <summary>
    /// Adds default metadata and properties to a reported activity after applying the product's privacy policy.
    /// </summary>
    /// <param name="activity">The reported activity.</param>
    /// <param name="properties">The product-specific activity properties.</param>
    protected void AddReportedActivityProperties(Activity activity, IEnumerable<KeyValuePair<string, object?>>? properties)
    {
        SetActivityProperties(activity, GetDefaultTags().Concat(properties ?? []));
    }
 
    /// <summary>
    /// Sets an activity property after applying the product's privacy policy.
    /// </summary>
    /// <remarks>
    /// Does not add default metadata. A disallowed property leaves any existing tag unchanged.
    /// </remarks>
    /// <param name="activity">The activity, or <see langword="null"/> when not recorded.</param>
    /// <param name="key">The property name.</param>
    /// <param name="value">The value, including any product-specific privacy classification.</param>
    /// <exception cref="ArgumentException">The property name is null, empty, or whitespace.</exception>
    public void SetActivityProperty(Activity? activity, string key, object? value)
    {
        ArgumentException.ThrowIfNullOrWhiteSpace(key);
        if (activity is not null && TrySanitizeProperty(key, value, out var sanitizedValue))
        {
            activity.SetTag(key, sanitizedValue);
        }
    }
 
    /// <summary>
    /// Sets activity properties after applying the product's privacy policy.
    /// </summary>
    /// <remarks>
    /// Does not add default metadata or enumerate properties when the activity is <see langword="null"/>.
    /// Disallowed properties leave existing tags unchanged.
    /// </remarks>
    /// <param name="activity">The activity, or <see langword="null"/> when not recorded.</param>
    /// <param name="properties">The properties, including any product-specific privacy classifications.</param>
    /// <exception cref="ArgumentNullException">The properties collection is null.</exception>
    /// <exception cref="ArgumentException">A property name is null, empty, or whitespace.</exception>
    public void SetActivityProperties(Activity? activity, IEnumerable<KeyValuePair<string, object?>> properties)
    {
        ArgumentNullException.ThrowIfNull(properties);
        if (activity is null)
        {
            return;
        }
 
        foreach (var (key, value) in properties)
        {
            SetActivityProperty(activity, key, value);
        }
    }
 
    /// <summary>
    /// Logs an error locally and records a structured event.
    /// </summary>
    /// <param name="message">The local log message.</param>
    /// <param name="exception">The exception to record.</param>
    public virtual void RecordError(string message, Exception exception)
    {
        RecordErrorCore(message, exception, writeToLogging: true);
    }
 
    /// <summary>
    /// Records an error as a structured log, optionally logging the exception locally.
    /// </summary>
    /// <param name="message">The local log message.</param>
    /// <param name="exception">The exception to record.</param>
    /// <param name="writeToLogging">Whether to also log the error locally.</param>
    protected void RecordErrorCore(string message, Exception exception, bool writeToLogging)
    {
        if (writeToLogging)
        {
            _logger.LogError(exception, message);
        }
 
        if (!IsReportedTelemetryEnabled)
        {
            return;
        }
 
        RecordEventCore(_errorEventName, CreateErrorTags(exception));
    }
 
    /// <summary>
    /// Creates product-specific exception fields that will be filtered by the product's privacy policy.
    /// </summary>
    /// <param name="exception">The exception to describe.</param>
    /// <returns>The error event tags.</returns>
    protected virtual ActivityTagsCollection CreateErrorTags(Exception exception) => new()
    {
        ["exception.type"] = exception.GetType().FullName,
        ["exception.message"] = exception.Message,
        ["exception.stacktrace"] = exception.StackTrace
    };
 
    /// <summary>
    /// Gets product-specific metadata to include on structured events.
    /// </summary>
    /// <returns>The default tags.</returns>
    protected abstract IReadOnlyList<KeyValuePair<string, object?>> GetDefaultTags();
 
    /// <summary>
    /// Determines whether a property may be reported and sanitizes its value.
    /// </summary>
    /// <param name="key">The property name.</param>
    /// <param name="value">The property value, including any product-specific privacy classification.</param>
    /// <param name="sanitizedValue">The value to report when the property is allowed.</param>
    /// <returns><see langword="true"/> when the property may be reported; otherwise, <see langword="false"/>.</returns>
    protected abstract bool TrySanitizeProperty(string key, object? value, out object? sanitizedValue);
 
    /// <summary>
    /// Gets whether structured events and errors may be recorded.
    /// </summary>
    protected virtual bool IsReportedTelemetryEnabled => true;
 
    private ActivityTagsCollection CreateProperties(IEnumerable<KeyValuePair<string, object?>>? properties)
    {
        var tags = new ActivityTagsCollection();
        foreach (var (key, value) in GetDefaultTags().Concat(properties ?? []))
        {
            if (TrySanitizeProperty(key, value, out var sanitizedValue))
            {
                tags[key] = sanitizedValue;
            }
        }
 
        return tags;
    }
 
    /// <summary>
    /// Releases the product's activity sources.
    /// </summary>
    public void Dispose()
    {
        lock (_lifecycleLock)
        {
            if (_disposed)
            {
                return;
            }
            _disposed = true;
            Volatile.Write(ref _eventLogger, NullLogger.Instance);
            _reportedActivitySource.Dispose();
            _diagnosticsActivitySource.Dispose();
        }
        GC.SuppressFinalize(this);
    }
}