using System.Diagnostics;
using System.Globalization;
namespace MeterVault.Integration.Tests.Performance;
///
/// Counts the SQL commands one async flow sends — through EF Core and through the reader's plain Npgsql commands
/// alike — by listening to Npgsql's own tracing (ActivitySource "Npgsql"). Only commands of the flow that
/// started the counter are counted, so other work in the process does not leak in. While no counter is open, no
/// listener exists and Npgsql creates no activities: the timed runs are not slowed down by it.
///
internal sealed class CommandCounter : IDisposable
{
private static readonly AsyncLocal Current = new();
private readonly ActivityListener _listener;
private readonly List _statements = [];
private readonly CommandCounter? _previous;
private TimeSpan _duration;
private CommandCounter()
{
_previous = Current.Value;
_listener = new ActivityListener
{
ShouldListenTo = source => source.Name.StartsWith("Npgsql", StringComparison.Ordinal),
Sample = (ref ActivityCreationOptions _) => ActivitySamplingResult.AllDataAndRecorded,
ActivityStopped = Stopped,
};
ActivitySource.AddActivityListener(_listener);
}
/// The commands counted so far.
public int Count
{
get
{
lock (_statements)
{
return _statements.Count;
}
}
}
///
/// The summed duration of the counted commands: from execution to the reader's close, so it includes reading the
/// rows (and whatever the caller does per row while reading them).
///
public TimeSpan Duration
{
get
{
lock (_statements)
{
return _duration;
}
}
}
/// Every counted command in order: its duration and the first line of its text.
public IReadOnlyList Statements
{
get
{
lock (_statements)
{
return [.. _statements];
}
}
}
/// Starts counting the commands of the calling async flow.
public static CommandCounter Start()
{
var counter = new CommandCounter();
Current.Value = counter;
return counter;
}
public void Dispose()
{
Current.Value = _previous;
_listener.Dispose();
}
private void Stopped(Activity activity)
{
if (!ReferenceEquals(Current.Value, this))
{
return;
}
// A command activity carries its text (db.query.text since Npgsql 10; db.statement before).
var text = activity.GetTagItem("db.query.text") as string ?? activity.GetTagItem("db.statement") as string;
if (text is null)
{
return;
}
var first = text.TrimStart().Split('\n', 2)[0].Trim();
lock (_statements)
{
_statements.Add(string.Create(
CultureInfo.InvariantCulture, $"{activity.Duration.TotalMilliseconds,8:F1} ms {(first.Length > 140 ? first[..140] + "…" : first)}"));
_duration += activity.Duration;
}
}
}