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; } } }