File: Api\TelemetryApiService.cs
Web Access
Project: src\src\Aspire.Dashboard\Aspire.Dashboard.csproj (Aspire.Dashboard)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
using System.Runtime.CompilerServices;
using System.Text.Json;
using Aspire.Dashboard.Model;
using Aspire.Dashboard.Model.Assistant;
using Aspire.Dashboard.Model.Otlp;
using Aspire.Dashboard.Otlp.Model;
using Aspire.Dashboard.Otlp.Model.Serialization;
using Aspire.Dashboard.Otlp.Storage;
 
namespace Aspire.Dashboard.Api;
 
/// <summary>
/// Handles telemetry API requests, returning data in OTLP JSON format.
/// </summary>
internal sealed class TelemetryApiService(
    TelemetryRepository telemetryRepository,
    IEnumerable<IOutgoingPeerResolver> outgoingPeerResolvers)
{
    private const int DefaultLimit = 200;
    private const int DefaultTraceLimit = 100;
    private const int MaxQueryCount = 10000;
 
    private readonly IOutgoingPeerResolver[] _outgoingPeerResolvers = outgoingPeerResolvers.ToArray();
 
    /// <summary>
    /// Gets spans in OTLP JSON format.
    /// Returns null if resource filter is specified but not found.
    /// Supports multiple resource names.
    /// </summary>
    public TelemetryApiResponse<OtlpTelemetryDataJson>? GetSpans(string[]? resourceNames, string? traceId, bool? hasError, int? limit)
    {
        // Resolve resource keys for all specified resources
        var resources = telemetryRepository.GetResources();
        var resourceKeys = ResolveResourceKeys(resources, resourceNames);
        if (resourceKeys is null)
        {
            return null;
        }
 
        var effectiveLimit = limit ?? DefaultLimit;
 
        // Get spans for all resource keys
        var allSpans = new List<OtlpSpan>();
        foreach (var resourceKey in resourceKeys)
        {
            var result = telemetryRepository.GetTraces(new GetTracesRequest
            {
                ResourceKey = resourceKey,
                StartIndex = 0,
                Count = MaxQueryCount,
                Filters = [],
                FilterText = string.Empty
            });
            allSpans.AddRange(result.PagedResult.Items.SelectMany(t => t.Spans));
        }
 
        var spans = allSpans;
 
        // TODO: Consider adding an ExcludeFromApi property on resources in the future.
        // Currently the API returns all telemetry data for all resources.
 
        // Filter by traceId
        if (!string.IsNullOrEmpty(traceId))
        {
            spans = spans.Where(s => OtlpHelpers.MatchTelemetryId(s.TraceId, traceId)).ToList();
        }
 
        // Filter by hasError
        if (hasError == true)
        {
            spans = spans.Where(s => s.Status == OtlpSpanStatusCode.Error).ToList();
        }
        else if (hasError == false)
        {
            spans = spans.Where(s => s.Status != OtlpSpanStatusCode.Error).ToList();
        }
 
        var totalCount = spans.Count;
 
        // Apply limit (take from end for most recent)
        if (spans.Count > effectiveLimit)
        {
            spans = spans.Skip(spans.Count - effectiveLimit).ToList();
        }
 
        var otlpData = TelemetryExportService.ConvertSpansToOtlpJson(spans, _outgoingPeerResolvers);
 
        return new TelemetryApiResponse<OtlpTelemetryDataJson>
        {
            Data = otlpData,
            TotalCount = totalCount,
            ReturnedCount = spans.Count
        };
    }
 
    /// <summary>
    /// Gets traces in OTLP JSON format (grouped by trace).
    /// Returns null if resource filter is specified but not found.
    /// Supports multiple resource names.
    /// </summary>
    public TelemetryApiResponse<OtlpTelemetryDataJson>? GetTraces(string[]? resourceNames, bool? hasError, int? limit)
    {
        // Resolve resource keys for all specified resources
        var resources = telemetryRepository.GetResources();
        var resourceKeys = ResolveResourceKeys(resources, resourceNames);
        if (resourceKeys is null)
        {
            return null;
        }
 
        var effectiveLimit = limit ?? DefaultTraceLimit;
 
        // Get traces for all resource keys
        var allTraces = new List<OtlpTrace>();
        foreach (var resourceKey in resourceKeys)
        {
            var result = telemetryRepository.GetTraces(new GetTracesRequest
            {
                ResourceKey = resourceKey,
                StartIndex = 0,
                Count = MaxQueryCount,
                Filters = [],
                FilterText = string.Empty
            });
            allTraces.AddRange(result.PagedResult.Items);
        }
 
        var traces = allTraces;
 
        // Filter traces by hasError
        if (hasError == true)
        {
            traces = traces.Where(t => t.Spans.Any(s => s.Status == OtlpSpanStatusCode.Error)).ToList();
        }
        else if (hasError == false)
        {
            traces = traces.Where(t => !t.Spans.Any(s => s.Status == OtlpSpanStatusCode.Error)).ToList();
        }
 
        var totalCount = traces.Count;
 
        // Apply limit (take from end for most recent)
        if (traces.Count > effectiveLimit)
        {
            traces = traces.Skip(traces.Count - effectiveLimit).ToList();
        }
 
        // Get all spans from filtered traces
        var spans = traces.SelectMany(t => t.Spans).ToList();
 
        var otlpData = TelemetryExportService.ConvertSpansToOtlpJson(spans, _outgoingPeerResolvers);
 
        return new TelemetryApiResponse<OtlpTelemetryDataJson>
        {
            Data = otlpData,
            TotalCount = totalCount,
            ReturnedCount = traces.Count
        };
    }
 
    /// <summary>
    /// Gets a specific trace by ID with all spans in OTLP format.
    /// Returns null if trace not found.
    /// </summary>
    public TelemetryApiResponse<OtlpTelemetryDataJson>? GetTrace(string traceId)
    {
        var result = telemetryRepository.GetTraces(new GetTracesRequest
        {
            ResourceKey = null,
            StartIndex = 0,
            Count = MaxQueryCount,
            Filters = [],
            FilterText = string.Empty
        });
 
        var trace = result.PagedResult.Items.FirstOrDefault(t => OtlpHelpers.MatchTelemetryId(t.TraceId, traceId));
        if (trace is null)
        {
            return null;
        }
 
        var spans = trace.Spans.ToList();
 
        var otlpData = TelemetryExportService.ConvertSpansToOtlpJson(spans, _outgoingPeerResolvers);
 
        return new TelemetryApiResponse<OtlpTelemetryDataJson>
        {
            Data = otlpData,
            TotalCount = spans.Count,
            ReturnedCount = spans.Count
        };
    }
 
    /// <summary>
    /// Gets logs in OTLP JSON format.
    /// Returns null if resource filter is specified but not found.
    /// Supports multiple resource names.
    /// </summary>
    public TelemetryApiResponse<OtlpTelemetryDataJson>? GetLogs(string[]? resourceNames, string? traceId, string? severity, int? limit)
    {
        // Resolve resource keys for all specified resources
        var resources = telemetryRepository.GetResources();
        var resourceKeys = ResolveResourceKeys(resources, resourceNames);
        if (resourceKeys is null)
        {
            return null;
        }
 
        var effectiveLimit = limit ?? DefaultLimit;
 
        var filters = new List<TelemetryFilter>();
 
        if (!string.IsNullOrEmpty(traceId))
        {
            filters.Add(new FieldTelemetryFilter
            {
                Field = KnownStructuredLogFields.TraceIdField,
                Value = traceId,
                Condition = FilterCondition.Contains
            });
        }
 
        // Severity filter uses GreaterThanOrEqual - e.g., "error" returns Error and Critical
        if (!string.IsNullOrEmpty(severity) && Enum.TryParse<LogLevel>(severity, ignoreCase: true, out var logLevel))
        {
            // Trace is the lowest level, so no filter needed for it
            if (logLevel != LogLevel.Trace)
            {
                filters.Add(new FieldTelemetryFilter
                {
                    Field = nameof(OtlpLogEntry.Severity),
                    Value = logLevel.ToString(),
                    Condition = FilterCondition.GreaterThanOrEqual
                });
            }
        }
 
        // Get logs for all resource keys
        var allLogs = new List<OtlpLogEntry>();
        foreach (var resourceKey in resourceKeys)
        {
            var result = telemetryRepository.GetLogs(new GetLogsContext
            {
                ResourceKey = resourceKey,
                StartIndex = 0,
                Count = MaxQueryCount,
                Filters = filters
            });
            allLogs.AddRange(result.Items);
        }
 
        var logs = allLogs;
        var totalCount = logs.Count;
 
        // Apply limit (take from end for most recent)
        if (logs.Count > effectiveLimit)
        {
            logs = logs.Skip(logs.Count - effectiveLimit).ToList();
        }
 
        var otlpData = TelemetryExportService.ConvertLogsToOtlpJson(logs);
 
        return new TelemetryApiResponse<OtlpTelemetryDataJson>
        {
            Data = otlpData,
            TotalCount = totalCount,
            ReturnedCount = logs.Count
        };
    }
 
    /// <summary>
    /// Streams span updates as they arrive in OTLP JSON format.
    /// Supports multiple resource names.
    /// </summary>
    public async IAsyncEnumerable<string> FollowSpansAsync(
        string[]? resourceNames,
        string? traceId,
        bool? hasError,
        [EnumeratorCancellation] CancellationToken cancellationToken)
    {
        // Resolve resource keys
        var resources = telemetryRepository.GetResources();
        var resourceKeys = ResolveResourceKeys(resources, resourceNames);
 
        // For streaming, if resources were specified but can't be resolved, filter everything out
        var hasResourceFilter = resourceNames is { Length: > 0 };
        var invalidResourceFilter = hasResourceFilter && resourceKeys is null;
 
        // Watch all spans and filter
        await foreach (var span in telemetryRepository.WatchSpansAsync(null, cancellationToken).ConfigureAwait(false))
        {
            // If resource filter is invalid (resources specified but not found), skip all
            if (invalidResourceFilter)
            {
                continue;
            }
 
            // Filter by resource if specified
            if (resourceKeys is { Count: > 0 } && !resourceKeys.Any(k => k is null) &&
                !resourceKeys.Any(k => k?.EqualsCompositeName(span.Source.ResourceKey.GetCompositeName()) == true))
            {
                continue;
            }
 
            // Apply traceId filter
            if (!string.IsNullOrEmpty(traceId) && !OtlpHelpers.MatchTelemetryId(span.TraceId, traceId))
            {
                continue;
            }
 
            // Apply hasError filter
            if (hasError.HasValue && (span.Status == OtlpSpanStatusCode.Error) != hasError.Value)
            {
                continue;
            }
 
            // Use compact JSON for NDJSON streaming (no indentation)
            yield return TelemetryExportService.ConvertSpanToJson(span, _outgoingPeerResolvers, logs: null, indent: false);
        }
    }
 
    /// <summary>
    /// Streams log updates as they arrive in OTLP JSON format.
    /// Supports multiple resource names.
    /// </summary>
    public async IAsyncEnumerable<string> FollowLogsAsync(
        string[]? resourceNames,
        string? traceId,
        string? severity,
        [EnumeratorCancellation] CancellationToken cancellationToken)
    {
        // Resolve resource keys
        var resources = telemetryRepository.GetResources();
        var resourceKeys = ResolveResourceKeys(resources, resourceNames);
 
        // For streaming, if resources were specified but can't be resolved, filter everything out
        var hasResourceFilter = resourceNames is { Length: > 0 };
        var invalidResourceFilter = hasResourceFilter && resourceKeys is null;
 
        // Build filters
        var filters = new List<TelemetryFilter>();
 
        if (!string.IsNullOrEmpty(traceId))
        {
            filters.Add(new FieldTelemetryFilter
            {
                Field = KnownStructuredLogFields.TraceIdField,
                Value = traceId,
                Condition = FilterCondition.Contains
            });
        }
 
        if (!string.IsNullOrEmpty(severity) && Enum.TryParse<LogLevel>(severity, ignoreCase: true, out var parsedLevel))
        {
            // Trace is the lowest level, so no filter needed for it
            if (parsedLevel != LogLevel.Trace)
            {
                filters.Add(new FieldTelemetryFilter
                {
                    Field = nameof(OtlpLogEntry.Severity),
                    Value = parsedLevel.ToString(),
                    Condition = FilterCondition.GreaterThanOrEqual
                });
            }
        }
 
        // Watch all logs and filter by resource
        await foreach (var log in telemetryRepository.WatchLogsAsync(null, filters, cancellationToken).ConfigureAwait(false))
        {
            // If resource filter is invalid (resources specified but not found), skip all
            if (invalidResourceFilter)
            {
                continue;
            }
 
            // Filter by resource if specified
            if (resourceKeys is { Count: > 0 } && !resourceKeys.Any(k => k is null) &&
                !resourceKeys.Any(k => k?.EqualsCompositeName(log.ResourceView.ResourceKey.GetCompositeName()) == true))
            {
                continue;
            }
 
            var otlpData = TelemetryExportService.ConvertLogsToOtlpJson([log]);
            yield return JsonSerializer.Serialize(otlpData, OtlpJsonSerializerContext.DefaultOptions);
        }
    }
 
    /// <summary>
    /// Gets the list of available resources that have telemetry data.
    /// </summary>
    public ResourceInfo[] GetResources()
    {
        var resources = telemetryRepository.GetResources();
        return resources
            .Where(r => !r.UninstrumentedPeer) // Exclude uninstrumented peers
            .Select(r => new ResourceInfo
            {
                Name = r.ResourceName,
                InstanceId = r.InstanceId,
                DisplayName = r.ResourceKey.GetCompositeName(),
                HasLogs = r.HasLogs,
                HasTraces = r.HasTraces,
                HasMetrics = r.HasMetrics
            })
            .ToArray();
    }
 
    /// <summary>
    /// Resolves resource names to ResourceKeys.
    /// Returns null if any specified resource is not found.
    /// If no resources are specified, returns a list with a single null key (no filter).
    /// </summary>
    private static List<ResourceKey?>? ResolveResourceKeys(IReadOnlyList<OtlpResource> resources, string[]? resourceNames)
    {
        if (resourceNames is null || resourceNames.Length == 0)
        {
            // No filter - return a list with null to indicate "all resources"
            return [null];
        }
 
        var keys = new List<ResourceKey?>();
        foreach (var resourceName in resourceNames)
        {
            if (!AIHelpers.TryResolveResourceForTelemetry(resources, resourceName, out _, out var resourceKey))
            {
                return null;
            }
            keys.Add(resourceKey);
        }
        return keys;
    }
}
 
/// <summary>
/// Generic response wrapper for telemetry API responses.
/// </summary>
public sealed class TelemetryApiResponse<T>
{
    public required T Data { get; init; }
    public required int TotalCount { get; init; }
    public required int ReturnedCount { get; init; }
}
 
/// <summary>
/// Information about a resource that has telemetry data.
/// </summary>
public sealed class ResourceInfo
{
    /// <summary>
    /// The base resource name (e.g., "catalogservice").
    /// </summary>
    public required string Name { get; init; }
 
    /// <summary>
    /// The instance ID if this is a replica (e.g., "abc123"), or null if single instance.
    /// </summary>
    public string? InstanceId { get; init; }
 
    /// <summary>
    /// The full display name including instance ID (e.g., "catalogservice-abc123" or "catalogservice").
    /// Use this when querying the telemetry API.
    /// </summary>
    public required string DisplayName { get; init; }
 
    /// <summary>
    /// Whether this resource has structured logs.
    /// </summary>
    public bool HasLogs { get; init; }
 
    /// <summary>
    /// Whether this resource has traces/spans.
    /// </summary>
    public bool HasTraces { get; init; }
 
    /// <summary>
    /// Whether this resource has metrics.
    /// </summary>
    public bool HasMetrics { get; init; }
}