// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
using System.Collections.Concurrent;
using System.Data.Common;
using System.Diagnostics;
using Aspire.Dashboard.Configuration;
using Aspire.Dashboard.Model;
using Aspire.Dashboard.Otlp.Model;
using Aspire.Dashboard.Otlp.Storage;
using Aspire.Tests;
using Dapper;
using Microsoft.Data.Sqlite;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using Xunit;
namespace Aspire.Dashboard.Tests.Model;
public sealed class DashboardSqliteDatabaseTests(ITestOutputHelper testOutputHelper) : IDisposable
{
private readonly TemporaryWorkspace _workspace = TemporaryWorkspace.Create(testOutputHelper);
[Fact]
public void OpenConnection_ConfiguresSynchronousNormal()
{
using var database = new DashboardSqliteDatabase(Path.Combine(_workspace.Path, "dashboard.db"), pooling: false);
using var firstConnection = database.OpenConnection();
using var secondConnection = database.OpenConnection();
Assert.Equal(1, firstConnection.QuerySingle<int>("PRAGMA synchronous;"));
Assert.Equal(1, secondConnection.QuerySingle<int>("PRAGMA synchronous;"));
}
[Fact]
public async Task InitializeSchema_IncompatibleVersionReportsExistingAndExpectedVersions()
{
var databasePath = Path.Combine(_workspace.Path, "dashboard.db");
using (var connection = new SqliteConnection($"Data Source={databasePath};Pooling=False"))
{
connection.Open();
connection.Execute("CREATE TABLE dashboard_schema (version INTEGER NOT NULL) STRICT; INSERT INTO dashboard_schema VALUES (1);");
}
using var database = new DashboardSqliteDatabase(databasePath, pooling: false);
var exception = await Assert.ThrowsAsync<InvalidOperationException>(() => database.InitializeSchemaAsync(CancellationToken.None));
Assert.Equal(
$"The dashboard database schema version 1 does not match the expected version {DashboardSqliteDatabase.SchemaVersion}.",
exception.Message);
}
[Fact]
public async Task InitializeSchema_HistogramValuesUseBlobStorage()
{
using var database = new DashboardSqliteDatabase(Path.Combine(_workspace.Path, "dashboard.db"), pooling: false);
await database.InitializeSchemaAsync(cancellationToken: CancellationToken.None);
using var connection = database.OpenConnection();
var histogramCountColumnType = connection.QuerySingle<string>("SELECT type FROM pragma_table_info('telemetry_metric_points') WHERE name = 'histogram_count';");
var histogramColumnTypes = connection.Query<(string Name, string Type)>("SELECT name, type FROM pragma_table_info('telemetry_metric_points') WHERE name IN ('bucket_counts', 'explicit_bounds') ORDER BY name;");
Assert.Equal("INTEGER", histogramCountColumnType);
Assert.Collection(
histogramColumnTypes,
column => Assert.Equal(("bucket_counts", "BLOB"), column),
column => Assert.Equal(("explicit_bounds", "BLOB"), column));
Assert.Equal(0, connection.QuerySingle<int>("SELECT COUNT(*) FROM sqlite_schema WHERE type = 'table' AND name = 'telemetry_metric_histograms';"));
}
[Fact]
public async Task RepositoryWrites_ShareDatabaseWriteLock()
{
using var database = new DashboardSqliteDatabase(Path.Combine(_workspace.Path, "dashboard.db"), pooling: false);
await database.InitializeSchemaAsync(cancellationToken: CancellationToken.None);
using var telemetryRepository = new SqliteTelemetryRepository(
database,
NullLoggerFactory.Instance,
Options.Create(new DashboardOptions()),
new PauseManager(),
TimeProvider.System,
[]);
using var resourceRepository = new SqliteResourceRepository(database, new MockKnownPropertyLookup(), NullLoggerFactory.Instance);
Task telemetryWriteTask;
Task resourceWriteTask;
using (await database.WriteLock.LockAsync())
{
telemetryWriteTask = ((ITelemetryRepositoryWriter)telemetryRepository).ClearMetricsAsync();
resourceWriteTask = ((IResourceRepositoryWriter)resourceRepository).ReplaceResourcesAsync([]);
Assert.False(telemetryWriteTask.IsCompleted);
Assert.False(resourceWriteTask.IsCompleted);
}
await Task.WhenAll(telemetryWriteTask, resourceWriteTask);
}
[Fact]
public async Task DapperQuery_CreatesActivityWithQueryInformation()
{
using var database = new DashboardSqliteDatabase(Path.Combine(_workspace.Path, "dashboard.db"), pooling: false);
var activities = new ConcurrentQueue<Activity>();
using var listener = ActivityListenerHelper.Create(database.ActivitySource, onActivityStopped: activities.Enqueue);
using var connection = database.OpenConnection();
var query = $"-- {Guid.NewGuid():N}{Environment.NewLine}SELECT 42;";
var result = await connection.QuerySingleAsync<int>(query);
Assert.Equal(42, result);
var activity = Assert.Single(activities, activity => Equals(activity.GetTagItem("db.query.text"), query));
Assert.Equal(TracingSqliteConnection.ActivitySourceName, activity.Source.Name);
Assert.Equal("SELECT sqlite", activity.OperationName);
Assert.Equal(ActivityKind.Client, activity.Kind);
Assert.Equal("sqlite", activity.GetTagItem("db.system.name"));
Assert.Equal("dashboard.db", activity.GetTagItem(OtlpSpan.PeerServiceAttributeKey));
Assert.Equal("dashboard.db", activity.GetTagItem("db.namespace"));
Assert.Equal("SELECT", activity.GetTagItem("db.operation.name"));
Assert.Equal(ActivityStatusCode.Unset, activity.Status);
}
[Fact]
public void DapperFailure_SetsActivityErrorInformation()
{
using var database = new DashboardSqliteDatabase(Path.Combine(_workspace.Path, "dashboard.db"), pooling: false);
var activities = new ConcurrentQueue<Activity>();
using var listener = ActivityListenerHelper.Create(database.ActivitySource, onActivityStopped: activities.Enqueue);
using var connection = database.OpenConnection();
var query = $"SELECT * FROM missing_{Guid.NewGuid():N};";
var exception = Assert.Throws<SqliteException>(() => connection.Query(query));
var activity = Assert.Single(activities, activity => Equals(activity.GetTagItem("db.query.text"), query));
Assert.Equal(ActivityStatusCode.Error, activity.Status);
Assert.Equal(exception.Message, activity.StatusDescription);
Assert.Equal(typeof(SqliteException).FullName, activity.GetTagItem("error.type"));
}
[Fact]
public void DataReader_ActivityStopsWhenReaderIsDisposed()
{
using var database = new DashboardSqliteDatabase(Path.Combine(_workspace.Path, "dashboard.db"), pooling: false);
var activities = new ConcurrentQueue<Activity>();
using var listener = ActivityListenerHelper.Create(database.ActivitySource, onActivityStopped: activities.Enqueue);
using DbConnection connection = database.OpenConnection();
using var command = connection.CreateCommand();
var query = $"SELECT '{Guid.NewGuid():N}';";
command.CommandText = query;
var reader = command.ExecuteReader();
Assert.DoesNotContain(activities, activity => Equals(activity.GetTagItem("db.query.text"), query));
Assert.True(reader.Read());
reader.Dispose();
Assert.Single(activities, activity => Equals(activity.GetTagItem("db.query.text"), query));
}
[Fact]
public void DapperQueryMultiple_ActivitySpansAllResultSets()
{
using var database = new DashboardSqliteDatabase(Path.Combine(_workspace.Path, "dashboard.db"), pooling: false);
var activities = new ConcurrentQueue<Activity>();
using var listener = ActivityListenerHelper.Create(database.ActivitySource, onActivityStopped: activities.Enqueue);
using var connection = database.OpenConnection();
var query = $"SELECT '{Guid.NewGuid():N}'; SELECT '{Guid.NewGuid():N}';";
using (var results = connection.QueryMultiple(query))
{
Assert.DoesNotContain(activities, activity => Equals(activity.GetTagItem("db.query.text"), query));
Assert.NotEmpty(results.ReadSingle<string>());
Assert.DoesNotContain(activities, activity => Equals(activity.GetTagItem("db.query.text"), query));
Assert.NotEmpty(results.ReadSingle<string>());
}
Assert.Single(activities, activity => Equals(activity.GetTagItem("db.query.text"), query));
}
[Fact]
public void CommitTransaction_CreatesActivityWithDatabaseInformation()
{
using var database = new DashboardSqliteDatabase(Path.Combine(_workspace.Path, "dashboard.db"), pooling: false);
var activities = new ConcurrentQueue<Activity>();
using var listener = ActivityListenerHelper.Create(database.ActivitySource, onActivityStopped: activities.Enqueue);
using var connection = database.OpenConnection();
using var transaction = connection.BeginTransaction();
connection.Execute("SELECT 1;", transaction: transaction);
transaction.Commit();
var activity = Assert.Single(activities, activity => Equals(activity.GetTagItem("db.query.text"), "COMMIT;"));
Assert.Equal("COMMIT sqlite", activity.OperationName);
Assert.Equal(ActivityKind.Client, activity.Kind);
Assert.Equal("sqlite", activity.GetTagItem("db.system.name"));
Assert.Equal("dashboard.db", activity.GetTagItem(OtlpSpan.PeerServiceAttributeKey));
Assert.Equal("dashboard.db", activity.GetTagItem("db.namespace"));
Assert.Equal("COMMIT", activity.GetTagItem("db.operation.name"));
}
[Fact]
public void ActivityListener_DoesNotCaptureOtherDatabaseActivities()
{
using var observedDatabase = new DashboardSqliteDatabase(Path.Combine(_workspace.Path, "observed.db"), pooling: false);
using var otherDatabase = new DashboardSqliteDatabase(Path.Combine(_workspace.Path, "other.db"), pooling: false);
var activities = new ConcurrentQueue<Activity>();
using var listener = ActivityListenerHelper.Create(observedDatabase.ActivitySource, onActivityStopped: activities.Enqueue);
using var observedConnection = observedDatabase.OpenConnection();
using var otherConnection = otherDatabase.OpenConnection();
observedConnection.QuerySingle<int>("SELECT 1;");
otherConnection.QuerySingle<int>("SELECT 2;");
Assert.Single(activities, activity => Equals(activity.GetTagItem("db.query.text"), "SELECT 1;"));
Assert.DoesNotContain(activities, activity => Equals(activity.GetTagItem("db.query.text"), "SELECT 2;"));
}
public void Dispose()
{
_workspace.Dispose();
}
}