File: Otlp\Storage\SqliteTelemetryRepository.Resources.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.Data;
using System.Text;
using Aspire.Dashboard.Otlp.Model;
using Dapper;
using Microsoft.Data.Sqlite;
 
namespace Aspire.Dashboard.Otlp.Storage;
 
public sealed partial class SqliteTelemetryRepository
{
    public List<OtlpResource> GetResources(bool includeUninstrumentedPeers = false) => GetTelemetryResources(includeUninstrumentedPeers, name: null);
 
    public List<OtlpResource> GetResourcesByName(string name, bool includeUninstrumentedPeers = false) => GetTelemetryResources(includeUninstrumentedPeers, name);
 
    public OtlpResource? GetResourceByCompositeName(string compositeName) => GetResources(includeUninstrumentedPeers: true).SingleOrDefault(resource => resource.ResourceKey.EqualsCompositeName(compositeName));
 
    public OtlpResource? GetResource(ResourceKey key) => GetResources(includeUninstrumentedPeers: true).SingleOrDefault(resource => resource.ResourceKey == key);
 
    public List<OtlpResource> GetResources(ResourceKey key, bool includeUninstrumentedPeers = false)
    {
        return key.InstanceId is null
            ? GetResourcesByName(key.Name, includeUninstrumentedPeers)
            : GetResources(includeUninstrumentedPeers).Where(resource => resource.ResourceKey == key).ToList();
    }
 
    private async Task DeleteTelemetryResourceFromDatabaseAsync(ResourceKey resourceKey)
    {
        using (await _database.WriteLock.LockAsync().ConfigureAwait(false))
        {
            using var connection = _database.OpenConnection();
            using var transaction = connection.BeginTransaction();
            var sql = new StringBuilder("""
                DELETE FROM telemetry_resources
                WHERE resource_name = @ResourceName COLLATE NOCASE
                """);
            var parameters = new DynamicParameters();
            parameters.Add("ResourceName", resourceKey.Name);
            if (resourceKey.InstanceId is not null)
            {
                sql.Append(" AND instance_id = @InstanceId COLLATE NOCASE");
                parameters.Add("InstanceId", resourceKey.InstanceId);
            }
            sql.Append(';');
            // A trace can contain spans from multiple resources. Capture affected trace IDs before
            // resource deletion cascades its spans, then delete each complete trace to avoid retaining partial trace data.
            var affectedTraceIds = connection.Query<string>($"""
                SELECT DISTINCT spans.trace_id
                FROM telemetry_spans spans
                JOIN telemetry_resources resources ON resources.resource_id = spans.resource_id
                WHERE resources.resource_name = @ResourceName COLLATE NOCASE
                  {(resourceKey.InstanceId is null ? string.Empty : "AND resources.instance_id = @InstanceId COLLATE NOCASE")};
                """, parameters, transaction).AsList();
            connection.Execute(sql.ToString(), parameters, transaction);
            foreach (var traceBatch in affectedTraceIds.Chunk(MaxTraceBatchSize))
            {
                connection.Execute(
                    "DELETE FROM telemetry_traces WHERE trace_id IN @TraceIds;",
                    new { TraceIds = traceBatch },
                    transaction);
            }
            connection.Execute("""
                UPDATE telemetry_resources
                SET has_traces = EXISTS (SELECT 1 FROM telemetry_spans WHERE telemetry_spans.resource_id = telemetry_resources.resource_id);
                """, transaction: transaction);
            DeleteOrphanedUninstrumentedPeers(connection, transaction);
            DeleteOrphanedScopes(connection, transaction);
            transaction.Commit();
            ClearIngestionCaches();
        }
    }
 
    /// <summary>
    /// Deletes resources that only ever existed as the target of an outgoing span once nothing references them.
    /// </summary>
    /// <remarks>
    /// Uninstrumented peers are synthesised from span attributes rather than reported by a real resource, so
    /// nothing else deletes them. Without this they survive every clear and accumulate for the life of the
    /// database, showing up in resource lists and peer lookups long after the spans that named them are gone.
    /// Resources that report their own telemetry are excluded because they exist independently of any span.
    /// </remarks>
    private static void DeleteOrphanedUninstrumentedPeers(SqliteConnection connection, IDbTransaction transaction)
    {
        connection.Execute("""
            DELETE FROM telemetry_resources
            WHERE uninstrumented_peer = 1
              AND NOT EXISTS (SELECT 1 FROM telemetry_spans WHERE telemetry_spans.uninstrumented_peer_resource_id = telemetry_resources.resource_id)
              AND NOT EXISTS (SELECT 1 FROM telemetry_spans WHERE telemetry_spans.resource_id = telemetry_resources.resource_id)
              AND NOT EXISTS (SELECT 1 FROM telemetry_logs WHERE telemetry_logs.resource_id = telemetry_resources.resource_id)
              AND NOT EXISTS (SELECT 1 FROM telemetry_metric_instruments WHERE telemetry_metric_instruments.resource_id = telemetry_resources.resource_id);
            """, transaction: transaction);
    }
 
    private static void DeleteOrphanedScopes(SqliteConnection connection, IDbTransaction transaction)
    {
        connection.Execute("""
            DELETE FROM telemetry_scopes
            WHERE NOT EXISTS (SELECT 1 FROM telemetry_logs WHERE telemetry_logs.scope_id = telemetry_scopes.scope_id)
              AND NOT EXISTS (SELECT 1 FROM telemetry_spans WHERE telemetry_spans.scope_id = telemetry_scopes.scope_id)
              AND NOT EXISTS (SELECT 1 FROM telemetry_metric_instruments WHERE telemetry_metric_instruments.scope_id = telemetry_scopes.scope_id);
            """, transaction: transaction);
    }
 
    private List<OtlpResource> GetTelemetryResources(bool includeUninstrumentedPeers, string? name)
    {
        EnsureCachePopulated();
        lock (_cacheLock)
        {
            return _cachedResourcesByKey.Values
                .Select(resource => resource.Resource)
                .Where(resource => includeUninstrumentedPeers || !resource.UninstrumentedPeer)
                .Where(resource => name is null || string.Equals(resource.ResourceName, name, StringComparisons.ResourceName))
                .OrderBy(resource => resource.ResourceKey)
                .ToList();
        }
    }
 
    private sealed class ResourceViewPropertiesComparer : IEqualityComparer<KeyValuePair<string, string>[]>
    {
        public static readonly ResourceViewPropertiesComparer Instance = new();
 
        public bool Equals(KeyValuePair<string, string>[]? left, KeyValuePair<string, string>[]? right) =>
            ReferenceEquals(left, right) || left is not null && right is not null && left.SequenceEqual(right);
 
        public int GetHashCode(KeyValuePair<string, string>[] properties)
        {
            var hash = new HashCode();
            foreach (var property in properties)
            {
                hash.Add(property);
            }
            return hash.ToHashCode();
        }
    }
 
    private class TelemetryResourceRecord
    {
        public required string ResourceName { get; init; }
        public string? InstanceId { get; init; }
    }
}