diff --git a/CHANGELOG.md b/CHANGELOG.md index f7e3b33bf5..61a87edbd2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,6 +33,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- **Long-running query completion trace (opt-in, default OFF) in Lite and the Darling viewer** ([#1572]) - a new `long_query_completions` collector captures the Extended Events equivalent of the classic long-query trace (SSMS's QuickSessionStandard completion trio): `rpc_completed` + `sql_batch_completed` filtered by `duration >= threshold` (default 2,000,000 µs = 2 s, matching the Full Dashboard's SQL Trace default in `install/30_collect_trace_management.sql`), plus `attention` captured UNFILTERED so cancelled/timed-out long queries are still evidenced (the attention event's own duration is the cancellation-handling time, not the query runtime, so a duration predicate would discard exactly those rows - MS Learn-verified). Each completion carries the 9 QuickSessionStandard actions plus duration/CPU/reads/writes/row_count/`result` (Abort = the long query was killed) and the statement text. It reads its own app-managed ring-buffer XE session exactly like the blocked-process/deadlock collectors - server-scoped on on-prem/MI/RDS, database-scoped per monitored database on Azure SQL DB (#1535 pattern; `nt_username` omitted there since Azure uses Entra/SQL auth). **Erik's dedicated switch:** the collector is seeded DISABLED and opted into per fleet (a completion trace is not free on a busy server); enabling it CREATES the session on the monitored servers and disabling it DROPS the session there (the session itself is the cost - the lifecycle reconciles to the flag both ways, mirrored across both SKUs from shared session-DDL builders so the two can't drift). Both apps get a **Long Queries** tab (a grid of completed long queries + attentions, default-sorted DESCENDING by duration, aborted/attention rows tinted) with an explicit empty-state banner naming the exact switch to turn it on when the collector is disabled, plus a `get_long_query_completions` MCP read tool. Retention rides the shared 30-day collector horizon; the Darling store adds it via migration V29. + - **Darling viewer: ships as a Velopack `Setup.exe` for remote seats, like Lite and Dashboard** ([#1571]) - the standalone viewer that connects to a central Darling store from an operator's own machine now has a real install affordance: a per-user `Setup.exe` with Start Menu / desktop shortcuts, Apps & Features registration, and delta auto-updates from the releases feed, on its own `darlingviewer` Velopack channel. This required making the viewer a first-class Velopack app (it never was - it only shipped co-located inside the service zip): a `Program.Main` entry point that calls `VelopackApp.Build().Run()` before any window (wired via ``, exactly as Lite/Dashboard), the `Velopack` package pinned to the suite's `1.2.0`, and Lite's "Check for Updates" affordance ported into the About dialog (Velopack self-update, falling back to opening the releases page). Nightly builds publish the `Setup.exe` UNSIGNED as a plain release asset alongside the existing zips (which are unchanged); the release pipeline is wired to publish self-contained, sign, `vpk pack`, and upload to the channel - but the signed release path is inert until the `DarlingViewer` SignPath artifact-configuration slug is created (the standing #1340 signing prerequisite, Erik's side). The co-located viewer inside `PerformanceMonitorDarling-*.zip` is untouched (zero-config parent-directory `darling.json` discovery), and the Velopack install id (`%LocalAppData%\PerformanceMonitorDarlingViewer`) does not collide with the viewer's per-user state (`%APPDATA%\PerformanceMonitorDarling`), so both can coexist for one user. - **Queries: Top Queries by Duration now attributes each statement to its named module — or "ad hoc" — in Lite and the Darling viewer** ([#1569]) — the Full Dashboard has long tied ad-hoc-looking `dm_exec_query_stats` rows back to the procedure/function/trigger they belong to (its `install/10` collector stamps `object_name`/`schema_name` onto `collect.query_stats` by joining `collect.procedure_stats` on `sql_handle`), but Lite and the Darling viewer never surfaced it — every Top Queries row read as an anonymous statement. Both apps now do the stitch at READ time (Option B — zero collector/schema changes, and it retroactively attributes already-collected data): the Top Queries by Duration reader LEFT JOINs `query_stats.sql_handle` to `procedure_stats.sql_handle` — the SAME normalized `CONVERT(varchar(130), ..., 1)` handle text both collectors already persist — and surfaces the matched module's `database.schema.object` in a new **Module** column, or the literal `ad hoc` when the handle matches no cached module. The join is deduped to ONE module identity per `sql_handle` (latest `collection_time` wins, via `ROW_NUMBER() ... WHERE rn = 1` in both the DuckDB and Postgres readers — Postgres has no `QUALIFY`), so a query row can never fan out across a module's many collected rows. One caveat inherent to read-time attribution: a very new module the procedure-stats collector has not captured yet will read as ad hoc until its next collection cycle. The Darling reader stays pure Postgres dialect (positional params, no T-SQL) and the additive column trips no capability-pin baseline. Same change in both apps (parity), pinned by new reader round-trip tests (Lite real-DuckDB; Darling gated live-Postgres) plus SQL-shape, PG-dialect, and row-model display pins. @@ -411,6 +413,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 [#1567]: https://github.com/erikdarlingdata/PerformanceMonitor/pull/1567 [#1569]: https://github.com/erikdarlingdata/PerformanceMonitor/pull/1569 [#1570]: https://github.com/erikdarlingdata/PerformanceMonitor/pull/1570 +[#1572]: https://github.com/erikdarlingdata/PerformanceMonitor/pull/1572 [#1571]: https://github.com/erikdarlingdata/PerformanceMonitor/pull/1571 [#1536]: https://github.com/erikdarlingdata/PerformanceMonitor/pull/1536 [#1534]: https://github.com/erikdarlingdata/PerformanceMonitor/pull/1534 diff --git a/Darling/Darling.Tests/DarlingObservabilityTests.cs b/Darling/Darling.Tests/DarlingObservabilityTests.cs index 5e970ee6c2..e856838cc7 100644 --- a/Darling/Darling.Tests/DarlingObservabilityTests.cs +++ b/Darling/Darling.Tests/DarlingObservabilityTests.cs @@ -32,9 +32,9 @@ public sealed class DarlingObservabilityTests private const int TestServerId = -424242; [Fact] - public void MigrationScripts_TwentyEightVersions_V27DeadlocksDatabaseName_V28QueryStoreReplicaRole() + public void MigrationScripts_TwentyNineVersions_V28QueryStoreReplicaRole_V29LongQueryCompletions() { - Assert.Equal(28, PgMigrations.Scripts.Count); + Assert.Equal(29, PgMigrations.Scripts.Count); Assert.Equal(1, PgMigrations.Scripts[0].Version); Assert.Equal(2, PgMigrations.Scripts[1].Version); Assert.Equal(3, PgMigrations.Scripts[2].Version); @@ -63,7 +63,8 @@ public void MigrationScripts_TwentyEightVersions_V27DeadlocksDatabaseName_V28Que Assert.Equal(26, PgMigrations.Scripts[25].Version); Assert.Equal(27, PgMigrations.Scripts[26].Version); Assert.Equal(28, PgMigrations.Scripts[27].Version); - Assert.Equal(28, StorageVersion.SchemaVersion); + Assert.Equal(29, PgMigrations.Scripts[28].Version); + Assert.Equal(29, StorageVersion.SchemaVersion); /* V26 (#1506) adds the generic webhook channel's four columns to the V17 control-plane table. Schema-qualified config.* and IF NOT EXISTS, per the file's additive-ALTER idiom. */ @@ -87,6 +88,14 @@ depends on it. */ Assert.Contains("ALTER TABLE query_store_stats ADD COLUMN IF NOT EXISTS replica_role text;", v28, StringComparison.Ordinal); Assert.Contains("CREATE OR REPLACE VIEW v_query_store_stats AS SELECT * FROM query_store_stats;", v28, StringComparison.Ordinal); + /* V29 (#1496) creates the long_query_completions collector table on an upgraded store (a fresh + store gets it from V1's catalog walk). CREATE TABLE IF NOT EXISTS in the collect schema, plus its + retrieval index; no v_* view (a post-V14 collector — the viewer reads the base table). */ + var v29 = PgMigrations.Scripts[28].Sql; + Assert.Contains("CREATE TABLE IF NOT EXISTS collect.long_query_completions (", v29, StringComparison.Ordinal); + Assert.Contains("duration_microseconds bigint", v29, StringComparison.Ordinal); + Assert.Contains("CREATE INDEX IF NOT EXISTS idx_long_query_completions_time ON collect.long_query_completions(server_id, collection_time);", v29, StringComparison.Ordinal); + /* V5 completes the v_* twin of Lite's DuckDB view layer -- the copy-parity tail tabs (Running Jobs, Configuration, Daily Summary, Collection Health) read these five, so their ported SQL stays byte-identical to Lite's. */ diff --git a/Darling/Darling.Tests/PgSchemaGeneratorTests.cs b/Darling/Darling.Tests/PgSchemaGeneratorTests.cs index ce57389fcb..06cc0d7248 100644 --- a/Darling/Darling.Tests/PgSchemaGeneratorTests.cs +++ b/Darling/Darling.Tests/PgSchemaGeneratorTests.cs @@ -26,10 +26,10 @@ public sealed class PgSchemaGeneratorTests [Fact] public void Catalog_CoversAllCollectors_WithUniqueTablesAndNames() { - /* 33 through default_trace_events + job_history + agent_status (#1433 Job History tab) = 35. */ - Assert.Equal(35, CollectorCatalog.All.Count); - Assert.Equal(35, CollectorCatalog.All.Select(s => s.TargetTable).Distinct().Count()); - Assert.Equal(35, CollectorCatalog.All.Select(s => s.Name).Distinct().Count()); + /* 35 through agent_status + long_query_completions (#1496 long-query trace) = 36. */ + Assert.Equal(36, CollectorCatalog.All.Count); + Assert.Equal(36, CollectorCatalog.All.Select(s => s.TargetTable).Distinct().Count()); + Assert.Equal(36, CollectorCatalog.All.Select(s => s.Name).Distinct().Count()); } [Fact] @@ -439,11 +439,11 @@ public void GenerateFullSchema_EmitsEveryTableAndIndex() var script = PgSchemaGenerator.GenerateFullSchema(); var tableCount = CollectorCatalog.All.Count(s => script.Contains($"CREATE TABLE IF NOT EXISTS {s.TargetTable} (", StringComparison.Ordinal)); - Assert.Equal(35, tableCount); + Assert.Equal(36, tableCount); - /* 35 tables minus the two index-less config tables (server_config, database_config) = 33 indexes. */ + /* 36 tables minus the two index-less config tables (server_config, database_config) = 34 indexes. */ var indexCount = script.Split("CREATE INDEX IF NOT EXISTS").Length - 1; - Assert.Equal(33, indexCount); + Assert.Equal(34, indexCount); /* The precision guard can never regress silently. */ Assert.DoesNotContain("numeric(0,0)", script, StringComparison.Ordinal); diff --git a/Darling/Darling.Tests/ViewerDataServiceTests.cs b/Darling/Darling.Tests/ViewerDataServiceTests.cs index cb08907a31..0c42118d6e 100644 --- a/Darling/Darling.Tests/ViewerDataServiceTests.cs +++ b/Darling/Darling.Tests/ViewerDataServiceTests.cs @@ -400,24 +400,25 @@ public void StoreSchemaProbeSql_ProbesInformationSchema_ForTheV17ToV25Sentinels( } [Theory] - [InlineData(true, true, true, true, true, true, true, true, true, true, true, true, 28)] // fully migrated V28 (query_store_stats replica_role, #1546) - [InlineData(true, true, true, true, true, true, true, true, true, true, true, false, 27)] // V27: deadlocks database_name present, replica_role not yet (#1535) - [InlineData(true, true, true, true, true, true, true, true, true, true, false, false, 26)] // V26: generic webhook present, deadlocks column not yet - [InlineData(true, true, true, true, true, true, true, true, true, false, false, false, 25)] // V25: agent_status present, generic webhook not yet - [InlineData(true, true, true, true, true, true, true, true, false, false, false, false, 24)] // V24: job_history present, agent_status not yet - [InlineData(true, true, true, true, true, true, true, false, false, false, false, false, 23)] // V23 (collection_log hypertable, or plain-PG at V23) - [InlineData(true, true, true, true, true, true, false, false, false, false, false, false, 22)] // Timescale store still at V22 (index present, collection_log not yet a hypertable) - [InlineData(true, true, true, true, true, false, false, false, false, false, false, false, 21)] // pre-V22: has default_trace_events, no V22 index - [InlineData(true, true, true, true, false, false, false, false, false, false, false, false, 20)] // pre-V21: no default_trace_events - [InlineData(true, true, true, false, false, false, false, false, false, false, false, false, 19)]// pre-V20: no alert-tuning knobs - [InlineData(true, true, false, false, false, false, false, false, false, false, false, false, 18)]// pre-V19: no analysis_state - [InlineData(true, false, false, false, false, false, false, false, false, false, false, false, 17)]// pre-V18: no delivery-override column - [InlineData(false, false, false, false, false, false, false, false, false, false, false, false, 16)]// pre-V17: no config control plane at all + [InlineData(true, true, true, true, true, true, true, true, true, true, true, true, true, 29)] // fully migrated V29 (long_query_completions, #1496) + [InlineData(true, true, true, true, true, true, true, true, true, true, true, true, false, 28)] // V28: query_store_stats replica_role present, long_query_completions not yet (#1546) + [InlineData(true, true, true, true, true, true, true, true, true, true, true, false, false, 27)] // V27: deadlocks database_name present, replica_role not yet (#1535) + [InlineData(true, true, true, true, true, true, true, true, true, true, false, false, false, 26)] // V26: generic webhook present, deadlocks column not yet + [InlineData(true, true, true, true, true, true, true, true, true, false, false, false, false, 25)] // V25: agent_status present, generic webhook not yet + [InlineData(true, true, true, true, true, true, true, true, false, false, false, false, false, 24)] // V24: job_history present, agent_status not yet + [InlineData(true, true, true, true, true, true, true, false, false, false, false, false, false, 23)] // V23 (collection_log hypertable, or plain-PG at V23) + [InlineData(true, true, true, true, true, true, false, false, false, false, false, false, false, 22)] // Timescale store still at V22 (index present, collection_log not yet a hypertable) + [InlineData(true, true, true, true, true, false, false, false, false, false, false, false, false, 21)] // pre-V22: has default_trace_events, no V22 index + [InlineData(true, true, true, true, false, false, false, false, false, false, false, false, false, 20)] // pre-V21: no default_trace_events + [InlineData(true, true, true, false, false, false, false, false, false, false, false, false, false, 19)]// pre-V20: no alert-tuning knobs + [InlineData(true, true, false, false, false, false, false, false, false, false, false, false, false, 18)]// pre-V19: no analysis_state + [InlineData(true, false, false, false, false, false, false, false, false, false, false, false, false, 17)]// pre-V18: no delivery-override column + [InlineData(false, false, false, false, false, false, false, false, false, false, false, false, false, 16)]// pre-V17: no config control plane at all public void MapProbedSchemaVersion_TakesTheHighestSatisfiedSentinel( - bool hasConfigControlPlane, bool hasAlertDeliveryOverride, bool hasAnalysisState, bool hasAlertTuningKnobs, bool hasDefaultTraceEvents, bool hasIndexObjectStatsLatestIndex, bool hasCollectionLogHypertableOrPlainPg, bool hasJobHistory, bool hasAgentStatus, bool hasGenericWebhook, bool hasDeadlocksDatabaseName, bool hasQueryStoreReplicaRole, int expected) + bool hasConfigControlPlane, bool hasAlertDeliveryOverride, bool hasAnalysisState, bool hasAlertTuningKnobs, bool hasDefaultTraceEvents, bool hasIndexObjectStatsLatestIndex, bool hasCollectionLogHypertableOrPlainPg, bool hasJobHistory, bool hasAgentStatus, bool hasGenericWebhook, bool hasDeadlocksDatabaseName, bool hasQueryStoreReplicaRole, bool hasLongQueryCompletions, int expected) { Assert.Equal(expected, ViewerDataService.MapProbedSchemaVersion( - hasConfigControlPlane, hasAlertDeliveryOverride, hasAnalysisState, hasAlertTuningKnobs, hasDefaultTraceEvents, hasIndexObjectStatsLatestIndex, hasCollectionLogHypertableOrPlainPg, hasJobHistory, hasAgentStatus, hasGenericWebhook, hasDeadlocksDatabaseName, hasQueryStoreReplicaRole)); + hasConfigControlPlane, hasAlertDeliveryOverride, hasAnalysisState, hasAlertTuningKnobs, hasDefaultTraceEvents, hasIndexObjectStatsLatestIndex, hasCollectionLogHypertableOrPlainPg, hasJobHistory, hasAgentStatus, hasGenericWebhook, hasDeadlocksDatabaseName, hasQueryStoreReplicaRole, hasLongQueryCompletions)); } [Fact] @@ -427,7 +428,7 @@ public void MapProbedSchemaVersion_V23CompositeIsGatedBehindV22_NotAStandaloneTo but must NOT be reported as 23 — the composite only counts once the engine-agnostic V22 index is present. Here: pre-V22 (no index) with the composite true still maps to 21 (its real V21 sentinel), proving the composite is gated behind V22 rather than treated as a newest-first arm. */ - Assert.Equal(21, ViewerDataService.MapProbedSchemaVersion(true, true, true, true, true, false, true, false, false, false, false, false)); + Assert.Equal(21, ViewerDataService.MapProbedSchemaVersion(true, true, true, true, true, false, true, false, false, false, false, false, false)); } [Fact] @@ -437,11 +438,11 @@ public void RequiredStoreSchemaVersion_TracksTheBuildSchemaVersion_AndTheProbeCo Assert.Equal(StorageVersion.SchemaVersion, ViewerDataService.RequiredStoreSchemaVersion); /* Pin: a fully-migrated store (all sentinels present) must map to exactly the required version. If a - future migration bumps StorageVersion past 28, this fails until a matching sentinel + map arm is + future migration bumps StorageVersion past 29, this fails until a matching sentinel + map arm is added — the guard against the probe silently under-reporting a newer store as skewed. */ Assert.Equal( ViewerDataService.RequiredStoreSchemaVersion, - ViewerDataService.MapProbedSchemaVersion(true, true, true, true, true, true, true, true, true, true, true, true)); + ViewerDataService.MapProbedSchemaVersion(true, true, true, true, true, true, true, true, true, true, true, true, true)); } } diff --git a/Darling/PerformanceMonitor.Darling.Service/DarlingWorker.cs b/Darling/PerformanceMonitor.Darling.Service/DarlingWorker.cs index 6568850c88..30205b3f66 100644 --- a/Darling/PerformanceMonitor.Darling.Service/DarlingWorker.cs +++ b/Darling/PerformanceMonitor.Darling.Service/DarlingWorker.cs @@ -314,6 +314,16 @@ snapshot waits its turn. Binary (1,1). */ thread after the outer thread's write. A remove+re-add mints a FRESH ServerLoopState, so there is no reset and no ABA. */ public volatile bool Retired; + + /* Last-applied enabled state of the OPT-IN long-query completion XE session (#1496), so the + per-sweep reconcile only opens a connection to run CREATE/DROP DDL when the desired state + actually changes — null = not yet reconciled (next sweep applies it), true = ensured + (enabled), false = confirmed dropped (disabled). Reset to null on every (re)connect so a + stopped Azure database-scoped session is re-ensured; in steady state a default-off collector + is drop-confirmed once per connect and then skipped every sweep. Written by the per-server + sweep body and the connect path (both run on the pool thread, one at a time per server — + INV-2), like the DateTime schedule fields above. */ + public bool? LongQueryTraceApplied { get; set; } } protected override async Task ExecuteAsync(CancellationToken stoppingToken) @@ -919,6 +929,13 @@ on the connect edges in TryConnectAsync. (Uses the _postgres field — the loop- return; } + /* Reconcile the opt-in long-query completion XE session (#1496) to its enabled flag before the + collector sweep. State-tracked (ServerLoopState.LongQueryTraceApplied), so it only opens a + connection when the desired state changes — enabling creates the session, disabling DROPS it. + Runs regardless of whether the collector is due or enabled, because a disabled collector is + never dispatched by RunDueCollectorsAsync and so the DROP-on-disable has nowhere else to run. */ + await ReconcileLongQueryTraceAsync(server, runner, stoppingToken); + await RunDueCollectorsAsync(server, runner, stoppingToken); /* After the server's collector sweep: evaluate alerts against the freshly collected store — on @@ -962,6 +979,40 @@ retries on its next sweep exactly like a failed collector. */ } } + /// + /// Reconciles the OPT-IN long-query completion XE session (#1496) to its resolved enabled flag, once + /// the desired state differs from what was last applied to this server (tracked in + /// so steady state — a default-off collector — + /// opens no connection at all). Enabling creates the server-side session; disabling drops it. A + /// failure leaves the applied state unchanged so the next sweep retries, and never breaks the sweep. + /// + private async Task ReconcileLongQueryTraceAsync(ServerLoopState server, DarlingCollectorRunner runner, CancellationToken cancellationToken) + { + if (server.Runtime is null) + { + return; + } + + var serverId = ServerIdHelper.GetDeterministicHashCode(server.Config.StorageName); + var enabled = StoreConfigProvider.ResolveSchedule("long_query_completions", serverId, _scheduleOverrides).Enabled; + + if (server.LongQueryTraceApplied == enabled) + { + return; + } + + try + { + await DarlingXeSessions.ReconcileLongQueryCompletionsAsync(server.Runtime, runner, enabled, _logger, cancellationToken); + server.LongQueryTraceApplied = enabled; + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + _logger.LogWarning("[{Server}] Failed to reconcile the long-query completion XE session: {Message}", + server.Config.DisplayName, ex.Message); + } + } + /// /// The command plane's poll loop (Stage 2), run concurrently with the collection sweep on its own /// ~5-second tick. Each tick DRAINS every currently-pending command (claim one at a time until the @@ -1775,6 +1826,11 @@ private async Task TryConnectAsync(ServerLoopState server, DarlingCollectorRunne { var runtime = await DarlingServerConnector.ConnectAsync(server.Config, _logger, cancellationToken); server.Runtime = runtime; + /* Force the long-query trace (#1496) to re-reconcile on the next sweep after every (re)connect: + an Azure database-scoped session can stop on reconnect, so a still-"applied" flag would + otherwise skip restarting it. Cheap — the reconcile no-ops unless the desired state differs + from what actually exists (ensure is IF-NOT-running START; drop is IF EXISTS). */ + server.LongQueryTraceApplied = null; /* Capture the id once, while the connection is freshly established and non-null: an on-load RunOneAsync below can drop server.Runtime on a mid-collection connection-level failure, so any later read of server.Runtime.ServerId (the schedule resolve, the connection edge) would NRE. */ @@ -2528,6 +2584,7 @@ await DarlingObservability.LogCollectionAsync( ["query_store"] = (r, s, ct) => r.RunAsync(QueryStoreCollector.Instance, s, ct), ["deadlocks"] = (r, s, ct) => RunXeTolerantAsync(DeadlocksCollector.Instance, r, s, ct), ["blocked_process_report"] = (r, s, ct) => RunXeTolerantAsync(BlockedProcessReportCollector.Instance, r, s, ct), + ["long_query_completions"] = (r, s, ct) => RunXeTolerantAsync(LongQueryCompletionsCollector.Instance, r, s, ct), ["system_health_events"] = (r, s, ct) => r.RunAsync(SystemHealthEventsCollector.Instance, s, ct), ["default_trace_events"] = (r, s, ct) => r.RunAsync(DefaultTraceEventsCollector.Instance, s, ct), ["job_history"] = (r, s, ct) => r.RunAsync(JobHistoryCollector.Instance, s, ct), diff --git a/Darling/PerformanceMonitor.Darling.Service/DarlingXeSessions.cs b/Darling/PerformanceMonitor.Darling.Service/DarlingXeSessions.cs index 0e95b52884..df748caec8 100644 --- a/Darling/PerformanceMonitor.Darling.Service/DarlingXeSessions.cs +++ b/Darling/PerformanceMonitor.Darling.Service/DarlingXeSessions.cs @@ -520,6 +520,175 @@ ADD TARGET package0.ring_buffer logger?.LogInformation("[{Server}] Created and started blocked process XE session (database-scoped)", server.Config.DisplayName); } + /// + /// Reconciles the OPT-IN long-query completion XE session (#1496) to the collector's enabled flag — + /// Erik's dedicated switch, default OFF. ENABLED: create/start the session (server-scoped on + /// on-prem/MI/RDS; database-scoped in every monitored database on Azure SQL DB, #1535). DISABLED: + /// DROP the session on the monitored server(s) — the server-side session is the actual busy-server + /// cost, so merely skipping collection is not enough. Unlike (which is + /// unconditional create-only, run once per connect), this is called from the per-server sweep and + /// its lifecycle FOLLOWS the flag both ways; the DarlingWorker state-tracks the last-applied value so + /// this only opens a connection when the desired state actually changes. Failures propagate to the + /// caller (the worker logs + leaves the applied state unchanged so the next sweep retries). + /// + public static async Task ReconcileLongQueryCompletionsAsync(ServerRuntime server, DarlingCollectorRunner runner, bool enabled, ILogger? logger, CancellationToken cancellationToken) + { + if (server.Target.IsAzureSqlDb) + { + await ReconcileLongQueryCompletionsAzureAsync(server, runner, enabled, logger, cancellationToken); + return; + } + + using var connection = new SqlConnection(server.ConnectionString); + await connection.OpenAsync(cancellationToken); + + if (enabled) + { + await EnsureLongQueryCompletionsOnPremAsync(connection, server, logger, cancellationToken); + } + else + { + await DropLongQueryCompletionsAsync(connection, databaseScoped: false, cancellationToken); + logger?.LogInformation("[{Server}] Long-query completion XE session reconciled OFF (collector disabled)", server.Config.DisplayName); + } + } + + private static async Task EnsureLongQueryCompletionsOnPremAsync(SqlConnection connection, ServerRuntime server, ILogger? logger, CancellationToken cancellationToken) + { + using (var cmd = new SqlCommand(@" +SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED; + +SELECT /* PerformanceMonitorDarling */ + is_running = CASE WHEN dxs.name IS NOT NULL THEN 1 ELSE 0 END +FROM sys.server_event_sessions AS ses +LEFT JOIN sys.dm_xe_sessions AS dxs + ON dxs.name = ses.name +WHERE ses.name = @session_name;", connection)) + { + cmd.CommandTimeout = 60; + cmd.Parameters.Add(new SqlParameter("@session_name", SqlDbType.NVarChar, 128) { Value = LongQueryCompletionsCollector.XeSessionName }); + var result = await cmd.ExecuteScalarAsync(cancellationToken); + + if (result != null) + { + if (result is int isRunning && isRunning == 0) + { + using var startCmd = new SqlCommand(LongQueryCompletionsCollector.BuildStartSessionSql(databaseScoped: false), connection); + startCmd.CommandTimeout = 60; + await startCmd.ExecuteNonQueryAsync(cancellationToken); + logger?.LogInformation("[{Server}] Started long-query completion XE session", server.Config.DisplayName); + } + return; + } + } + + using var createCmd = new SqlCommand( + LongQueryCompletionsCollector.BuildCreateSessionSql(databaseScoped: false, LongQueryCompletionsCollector.DefaultDurationThresholdMicroseconds) + + "\n\n" + LongQueryCompletionsCollector.BuildStartSessionSql(databaseScoped: false), connection); + createCmd.CommandTimeout = 60; + await createCmd.ExecuteNonQueryAsync(cancellationToken); + logger?.LogInformation("[{Server}] Created and started long-query completion XE session", server.Config.DisplayName); + } + + private static async Task ReconcileLongQueryCompletionsAzureAsync(ServerRuntime server, DarlingCollectorRunner runner, bool enabled, ILogger? logger, CancellationToken cancellationToken) + { + List databases; + try + { + databases = await runner.GetAzureDatabaseListAsync(server, cancellationToken); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + logger?.LogWarning("[{Server}] Failed to enumerate databases for the long-query completion XE session: {Message}", server.Config.DisplayName, ex.Message); + return; + } + + foreach (var databaseName in databases) + { + cancellationToken.ThrowIfCancellationRequested(); + + /* Logical master can't host database-scoped event sessions. */ + if (string.Equals(databaseName, "master", StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + try + { + using var connection = await runner.OpenAzureDatabaseConnectionAsync(server, databaseName, cancellationToken); + + if (enabled) + { + try + { + await EnsureLongQueryCompletionsAzureAsync(connection, server, databaseName, logger, cancellationToken); + } + catch (SqlException ex) when (IsBenignXeSessionAlreadyPresent(ex)) + { + /* Already present per the engine — the reader tolerates it (#1251). */ + } + } + else + { + await DropLongQueryCompletionsAsync(connection, databaseScoped: true, cancellationToken); + } + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + logger?.LogWarning("[{Server}] [{Database}] Failed to reconcile the long-query completion XE session: {Message}", + server.Config.DisplayName, databaseName, ex.Message); + } + } + } + + private static async Task EnsureLongQueryCompletionsAzureAsync(SqlConnection connection, ServerRuntime server, string databaseName, ILogger? logger, CancellationToken cancellationToken) + { + using (var cmd = new SqlCommand(@" +SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED; + +SELECT /* PerformanceMonitorDarling */ + session_state = des.name +FROM sys.database_event_sessions AS des +WHERE des.name = @session_name;", connection)) + { + cmd.CommandTimeout = 60; + cmd.Parameters.Add(new SqlParameter("@session_name", SqlDbType.NVarChar, 128) { Value = LongQueryCompletionsCollector.XeSessionName }); + var result = await cmd.ExecuteScalarAsync(cancellationToken); + + if (result != null) + { + using var startCmd = new SqlCommand($@" +IF NOT EXISTS +( + SELECT + 1/0 + FROM sys.dm_xe_database_sessions AS xes + WHERE xes.name = N'{LongQueryCompletionsCollector.XeSessionName}' +) +BEGIN + ALTER EVENT SESSION [{LongQueryCompletionsCollector.XeSessionName}] ON DATABASE STATE = START; +END;", connection); + startCmd.CommandTimeout = 60; + await startCmd.ExecuteNonQueryAsync(cancellationToken); + return; + } + } + + using var createCmd = new SqlCommand( + LongQueryCompletionsCollector.BuildCreateSessionSql(databaseScoped: true, LongQueryCompletionsCollector.DefaultDurationThresholdMicroseconds) + + "\n\n" + LongQueryCompletionsCollector.BuildStartSessionSql(databaseScoped: true), connection); + createCmd.CommandTimeout = 60; + await createCmd.ExecuteNonQueryAsync(cancellationToken); + logger?.LogInformation("[{Server}] [{Database}] Created and started long-query completion XE session (database-scoped)", server.Config.DisplayName, databaseName); + } + + private static async Task DropLongQueryCompletionsAsync(SqlConnection connection, bool databaseScoped, CancellationToken cancellationToken) + { + using var cmd = new SqlCommand(LongQueryCompletionsCollector.BuildDropSessionSql(databaseScoped), connection); + cmd.CommandTimeout = 60; + await cmd.ExecuteNonQueryAsync(cancellationToken); + } + /// /// True when every error is a benign "the session is already there" extended-events error: /// 25631 (already exists) or 25705 (already started) — on Azure SQL DB the XE existence diff --git a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingLongQueryReader.cs b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingLongQueryReader.cs new file mode 100644 index 0000000000..f51c66685a --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingLongQueryReader.cs @@ -0,0 +1,119 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Npgsql; + +namespace PerformanceMonitor.Darling.Service.Mcp; + +/// +/// Service-side read for the long-query completion MCP tool () — the +/// SAME collected data the viewer's ViewerDataService.LongQueries.cs reads, adapted here so the MCP +/// host never references the WPF viewer project (#1496). A STORED read (no live monitored-server hit), +/// keyed by server_id and windowed on the naive-UTC collection_time prefix, over the BASE +/// long_query_completions table (a post-V14 collector has no v_* passthrough view). Ordered by +/// duration DESC (NULLS LAST so attentions, whose duration is NULL, follow the ranked completions). The SQL +/// is a public const so Darling.Tests can pin the dialect + columns without a live Postgres. +/// +internal static class DarlingLongQueryReader +{ + /// One long-query completion row — a completed rpc/batch (runtime, CPU, I/O, rows, result) or an + /// attention (cancel/timeout; duration NULL). + public sealed class LongQueryReadRow + { + public DateTime? EventTime { get; set; } + public string? EventType { get; set; } + public string? DatabaseName { get; set; } + public int? SessionId { get; set; } + public string? ClientAppName { get; set; } + public int? ClientPid { get; set; } + public string? NtUserName { get; set; } + public string? ServerPrincipalName { get; set; } + public string? QueryHash { get; set; } + public long? EventSequence { get; set; } + public long? DurationMicroseconds { get; set; } + public long? CpuTimeMicroseconds { get; set; } + public long? PhysicalReads { get; set; } + public long? LogicalReads { get; set; } + public long? Writes { get; set; } + public long? RowCount { get; set; } + public string? Result { get; set; } + public string? StatementText { get; set; } + public string? ObjectName { get; set; } + } + + public const string LongQueryCompletionsSql = """ + SELECT + event_time, + event_type, + database_name, + session_id, + client_app_name, + client_pid, + nt_username, + server_principal_name, + query_hash, + event_sequence, + duration_microseconds, + cpu_time_microseconds, + physical_reads, + logical_reads, + writes, + row_count, + result, + statement_text, + object_name + FROM long_query_completions + WHERE server_id = $1 + AND collection_time >= $2 + AND collection_time <= $3 + ORDER BY duration_microseconds DESC NULLS LAST, event_time DESC + LIMIT 200 + """; + + public static async Task> GetRecentLongQueryCompletionsAsync( + NpgsqlDataSource postgres, int serverId, DateTime startUtc, DateTime endUtc, CancellationToken cancellationToken = default) + { + var rows = new List(); + + await using var connection = await postgres.OpenConnectionAsync(cancellationToken); + await using var command = new NpgsqlCommand(LongQueryCompletionsSql, connection); + DarlingMcpReadParameters.AddWindow(command, serverId, startUtc, endUtc); + await using var reader = await command.ExecuteReaderAsync(cancellationToken); + while (await reader.ReadAsync(cancellationToken)) + { + rows.Add(new LongQueryReadRow + { + EventTime = reader.IsDBNull(0) ? null : reader.GetDateTime(0), + EventType = reader.IsDBNull(1) ? null : reader.GetString(1), + DatabaseName = reader.IsDBNull(2) ? null : reader.GetString(2), + SessionId = reader.IsDBNull(3) ? null : reader.GetInt32(3), + ClientAppName = reader.IsDBNull(4) ? null : reader.GetString(4), + ClientPid = reader.IsDBNull(5) ? null : reader.GetInt32(5), + NtUserName = reader.IsDBNull(6) ? null : reader.GetString(6), + ServerPrincipalName = reader.IsDBNull(7) ? null : reader.GetString(7), + QueryHash = reader.IsDBNull(8) ? null : reader.GetString(8), + EventSequence = reader.IsDBNull(9) ? null : reader.GetInt64(9), + DurationMicroseconds = reader.IsDBNull(10) ? null : reader.GetInt64(10), + CpuTimeMicroseconds = reader.IsDBNull(11) ? null : reader.GetInt64(11), + PhysicalReads = reader.IsDBNull(12) ? null : reader.GetInt64(12), + LogicalReads = reader.IsDBNull(13) ? null : reader.GetInt64(13), + Writes = reader.IsDBNull(14) ? null : reader.GetInt64(14), + RowCount = reader.IsDBNull(15) ? null : reader.GetInt64(15), + Result = reader.IsDBNull(16) ? null : reader.GetString(16), + StatementText = reader.IsDBNull(17) ? null : reader.GetString(17), + ObjectName = reader.IsDBNull(18) ? null : reader.GetString(18), + }); + } + + return rows; + } +} diff --git a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpHostService.cs b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpHostService.cs index 9ef3da8abc..098e2859f5 100644 --- a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpHostService.cs +++ b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpHostService.cs @@ -426,6 +426,9 @@ These are the tools the analysis findings' next_tools recommendations point at. get_database_sizes — the same names Lite and the Dashboard expose, over Darling's Postgres store (STORED reads, no live hit). Result shapes follow Lite where the two SKUs diverge. */ .WithGeminiCompatibleTools() + /* #1496 get_long_query_completions — the opt-in long-query completion trace (rpc/batch over + the duration threshold + attentions), over Darling's Postgres store (STORED read). */ + .WithGeminiCompatibleTools() .WithGeminiCompatibleTools() .WithGeminiCompatibleTools() .WithGeminiCompatibleTools() diff --git a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpInstructions.cs b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpInstructions.cs index 2bd2fee903..f3691f9a3e 100644 --- a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpInstructions.cs +++ b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpInstructions.cs @@ -41,7 +41,7 @@ internal static class DarlingMcpInstructions ## Tool Reference - This server exposes seventy-three tools (the same names Performance Monitor Lite and the Dashboard expose): six diagnostic-analysis tools, five plan-analysis tools, fifteen core data-read tools, twenty diagnostic-depth data-read tools, eight resource-contention + jobs data-read tools, five trend data-read tools, eight system-health parse-on-read tools, five alert + health-overview tools, and one Default Trace tool. Every data-read tool reads the data the collectors already captured into the store — a stored read, never a live query against the monitored server. + This server exposes seventy-four tools (the same names Performance Monitor Lite and the Dashboard expose): six diagnostic-analysis tools, five plan-analysis tools, fifteen core data-read tools, twenty-one diagnostic-depth data-read tools, eight resource-contention + jobs data-read tools, five trend data-read tools, eight system-health parse-on-read tools, five alert + health-overview tools, and one Default Trace tool. Every data-read tool reads the data the collectors already captured into the store — a stored read, never a live query against the monitored server. ### Diagnostic-analysis tools @@ -98,6 +98,7 @@ internal static class DarlingMcpInstructions | `get_deadlocks` | Recent deadlocks: victim process/SQL + a process summary | `server_name`, `hours_back` (default 24), `limit` (default 20) | | `get_deadlock_detail` | The raw deadlock graph XML for the recent deadlocks | `server_name`, `hours_back` (default 24), `limit` (default 5) | | `get_blocked_process_xml` | The raw blocked-process-report XML | `server_name`, `hours_back` (default 24), `limit` (default 5) | + | `get_long_query_completions` | Longest completed queries (rpc/batch over the trace threshold) + attentions/cancels from the opt-in long-query trace, duration DESC (empty until the collector is enabled) | `server_name`, `hours_back` (default 24), `limit` (default 30) | | `get_blocking_trend` | Per-minute blocking-incident counts over time (XE, DMV-snapshot fallback) | `server_name`, `hours_back` (default 24) | | `get_deadlock_trend` | Per-minute deadlock counts over time | `server_name`, `hours_back` (default 24) | | `get_session_stats` | Latest per-application connection/session counts (running/sleeping/dormant) + resource totals | `server_name` | diff --git a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpLongQueryTools.cs b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpLongQueryTools.cs new file mode 100644 index 0000000000..de65c61432 --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpLongQueryTools.cs @@ -0,0 +1,90 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.ComponentModel; +using System.Linq; +using System.Text.Json; +using System.Threading.Tasks; +using ModelContextProtocol.Server; +using Npgsql; +using PerformanceMonitor.Common; + +#pragma warning disable CA1707 // MCP tools use snake_case naming convention + +namespace PerformanceMonitor.Darling.Service.Mcp; + +/// +/// The long-query completion MCP tool — get_long_query_completions — served over Darling's Postgres store +/// (#1496). Returns the longest completed queries (rpc/batch over the trace's duration threshold) plus +/// attentions (cancels/timeouts) captured by the opt-in PerformanceMonitor_LongQueryCompletions XE session, +/// ordered by duration DESC. Mirrors Lite's McpLongQueryTools field-for-field; reads flow through +/// — a STORED read (no live monitored-server hit). +/// +[McpServerToolType] +public sealed class DarlingMcpLongQueryTools +{ + [McpServerTool(Name = "get_long_query_completions"), Description("Gets long-running query completions captured by the opt-in long-query trace: rpc/batch completions whose duration exceeded the trace threshold, plus attentions (client cancels / query timeouts). Shows duration, CPU, reads/writes, row count, result (OK/Error/Abort — Abort means the long query was cancelled), the statement text, and the calling session/app/login. The collector is OFF by default; if it returns empty, enable the 'long_query_completions' collector in the schedule.")] + public static async Task GetLongQueryCompletions( + NpgsqlDataSource postgres, + [Description("Server name or display name.")] string? server_name = null, + [Description("Hours of history. Default 24.")] int hours_back = 24, + [Description("Maximum rows. Default 30.")] int limit = 30) + { + var (resolved, error) = await DarlingServerResolver.ResolveOrErrorAsync(postgres, server_name); + if (error != null) return error; + + var validation = McpHelpers.ValidateHoursBack(hours_back); + if (validation != null) return validation; + validation = McpHelpers.ValidateTop(limit); + if (validation != null) return validation; + + try + { + var now = DateTime.UtcNow; + var rows = await DarlingLongQueryReader.GetRecentLongQueryCompletionsAsync( + postgres, resolved.Value.ServerId, now.AddHours(-hours_back), now); + if (rows.Count == 0) + return McpHelpers.Status("empty", "No long-running query completions found in the specified time range. The long_query_completions collector is opt-in (default OFF) — enable it in the collector schedule to capture data."); + + var result = rows.Take(limit).Select(r => new + { + event_time = r.EventTime?.ToString("o"), + event_type = r.EventType, + duration_ms = r.DurationMicroseconds.HasValue ? r.DurationMicroseconds.Value / 1000.0 : (double?)null, + cpu_ms = r.CpuTimeMicroseconds.HasValue ? r.CpuTimeMicroseconds.Value / 1000.0 : (double?)null, + logical_reads = r.LogicalReads, + physical_reads = r.PhysicalReads, + writes = r.Writes, + row_count = r.RowCount, + result = r.Result, + database_name = r.DatabaseName, + object_name = r.ObjectName, + statement = McpHelpers.Truncate(r.StatementText, 2000), + session_id = r.SessionId, + client_app_name = r.ClientAppName, + client_pid = r.ClientPid, + nt_username = r.NtUserName, + server_principal_name = r.ServerPrincipalName, + query_hash = r.QueryHash + }); + + return JsonSerializer.Serialize(new + { + server = resolved.Value.ServerName, + hours_back, + total_completions = rows.Count, + completions = result + }, McpHelpers.JsonOptions); + } + catch (Exception ex) + { + return McpHelpers.FormatError("get_long_query_completions", ex); + } + } +} diff --git a/Darling/PerformanceMonitor.Darling.Service/StoreConfigProvider.cs b/Darling/PerformanceMonitor.Darling.Service/StoreConfigProvider.cs index d8daa44ce5..20020fb855 100644 --- a/Darling/PerformanceMonitor.Darling.Service/StoreConfigProvider.cs +++ b/Darling/PerformanceMonitor.Darling.Service/StoreConfigProvider.cs @@ -591,7 +591,11 @@ is treated as "no override" and falls through to the next level. Defense in dept V17 CHECK constraints and the DarlingRetention sink clamp. */ var frequency = ValidFrequency(perServer?.FrequencyMinutes) ?? ValidFrequency(fleet?.FrequencyMinutes) ?? def.FrequencyMinutes; var retention = ValidRetention(perServer?.RetentionDays) ?? ValidRetention(fleet?.RetentionDays) ?? def.RetentionDays; - var enabled = perServer?.Enabled ?? fleet?.Enabled ?? true; + /* No override row falls back to the collector's shared default enabled state — true for nearly + every collector, but false for an opt-in one like long_query_completions (#1496). Falling back + to def.DefaultEnabled (not a bare true) is what makes "reset to defaults" — which DELETES the + override rows — return a default-off collector to OFF instead of silently re-enabling it. */ + var enabled = perServer?.Enabled ?? fleet?.Enabled ?? def.DefaultEnabled; return new EffectiveSchedule(frequency, retention, enabled); } diff --git a/Darling/PerformanceMonitor.Darling.Storage/PgMigrations.cs b/Darling/PerformanceMonitor.Darling.Storage/PgMigrations.cs index 1ead796463..b4eb9bf729 100644 --- a/Darling/PerformanceMonitor.Darling.Storage/PgMigrations.cs +++ b/Darling/PerformanceMonitor.Darling.Storage/PgMigrations.cs @@ -72,6 +72,7 @@ public Migration(int version, string name, string sql) new Migration(26, "generic-webhook-channel", V26Sql), new Migration(27, "deadlocks-database-name", V27Sql), new Migration(28, "query-store-replica-role", V28Sql), + new Migration(29, "long-query-completions-collector", V29Sql), }; /// @@ -302,6 +303,47 @@ workload unless split by this column. Nullable text appended at the end (identic ALTER TABLE query_store_stats ADD COLUMN IF NOT EXISTS replica_role text; CREATE OR REPLACE VIEW v_query_store_stats AS SELECT * FROM query_store_stats;"; + /// + /// V29 — the long_query_completions collector table (#1496): long-running query completions + /// (rpc_completed / sql_batch_completed >= duration threshold, plus attention/cancels) from a + /// dedicated, OPT-IN XE ring-buffer session. Added additively exactly like V21/V24/V25 — a fresh + /// store already has it (V1's walks the collector + /// and V8 moved it to collect), so CREATE TABLE IF NOT EXISTS is a no-op on fresh and + /// the real create on upgrade. Column order/types are exactly 's + /// output for the catalog entry (prefix NOT NULL, payload + /// nullable). No v_* passthrough view (a post-V14 collector); the viewer reads the base table + /// directly. Hypertable / compression / 30-day retention flow from the catalog at runtime. + /// + private const string V29Sql = @" +CREATE TABLE IF NOT EXISTS collect.long_query_completions ( + long_query_completion_id bigint NOT NULL, + collection_time timestamp NOT NULL, + server_id integer NOT NULL, + server_name text NOT NULL, + event_time timestamp, + event_type text, + database_id integer, + database_name text, + session_id integer, + client_app_name text, + client_pid integer, + nt_username text, + server_principal_name text, + query_hash text, + event_sequence bigint, + duration_microseconds bigint, + cpu_time_microseconds bigint, + physical_reads bigint, + logical_reads bigint, + writes bigint, + row_count bigint, + result text, + statement_text text, + object_name text +); + +CREATE INDEX IF NOT EXISTS idx_long_query_completions_time ON collect.long_query_completions(server_id, collection_time);"; + /// /// V9 — the FinOps copy-parity fields that were user-input config or previously live-only: /// server_properties gains the three inventory columns the shared ServerPropertiesCollector now diff --git a/Darling/PerformanceMonitor.Darling.Storage/StorageVersion.cs b/Darling/PerformanceMonitor.Darling.Storage/StorageVersion.cs index c4311d9e18..0a316a2eeb 100644 --- a/Darling/PerformanceMonitor.Darling.Storage/StorageVersion.cs +++ b/Darling/PerformanceMonitor.Darling.Storage/StorageVersion.cs @@ -16,5 +16,5 @@ namespace PerformanceMonitor.Darling.Storage; /// public static class StorageVersion { - public const int SchemaVersion = 28; + public const int SchemaVersion = 29; } diff --git a/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.LongQueries.cs b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.LongQueries.cs new file mode 100644 index 0000000000..b2093e1242 --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.LongQueries.cs @@ -0,0 +1,174 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Npgsql; + +namespace PerformanceMonitor.Darling.Viewer; + +/// +/// One long-query completion grid row (#1496): a completed rpc/batch over the duration threshold (with +/// runtime, CPU, I/O, row count, result) or an attention (a cancelled/timed-out query — duration +/// NULL, because the attention event's own duration is cancellation-handling time, not query runtime). +/// Mirrors Lite's LongQueryCompletionRow; event_time is stored naive-UTC, so +/// converts to viewer-local via . +/// +public sealed class ViewerLongQueryRow +{ + public DateTime CollectionTime { get; set; } + public DateTime? EventTime { get; set; } + public string EventType { get; set; } = ""; + public string DatabaseName { get; set; } = ""; + public int? SessionId { get; set; } + public string ClientAppName { get; set; } = ""; + public int? ClientPid { get; set; } + public string NtUserName { get; set; } = ""; + public string ServerPrincipalName { get; set; } = ""; + public string QueryHash { get; set; } = ""; + public long? EventSequence { get; set; } + public long? DurationMicroseconds { get; set; } + public long? CpuTimeMicroseconds { get; set; } + public long? PhysicalReads { get; set; } + public long? LogicalReads { get; set; } + public long? Writes { get; set; } + public long? RowCountValue { get; set; } + public string Result { get; set; } = ""; + public string StatementText { get; set; } = ""; + public string ObjectName { get; set; } = ""; + + public string EventTimeLocal + => EventTime is { } eventTime ? ViewerTimeHelper.ForDisplay(eventTime).ToString("yyyy-MM-dd HH:mm:ss") : ""; + + public string DurationFormatted => FormatMicroseconds(DurationMicroseconds); + public string CpuFormatted => FormatMicroseconds(CpuTimeMicroseconds); + + /// An attention row is a cancelled/timed-out query — no runtime, secondary evidence. + public bool IsAttention => string.Equals(EventType, "attention", StringComparison.OrdinalIgnoreCase); + + /// A completed event aborted mid-flight (client cancel / query timeout) — the row tints. + public bool IsAborted => string.Equals(Result, "Abort", StringComparison.OrdinalIgnoreCase); + + private static string FormatMicroseconds(long? microseconds) => + microseconds is not long us ? "" : us < 1_000_000 ? $"{us / 1000.0:F0} ms" : $"{us / 1_000_000.0:F1} sec"; +} + +public sealed partial class ViewerDataService +{ + /// + /// Recent long-running query completions (#1496) for one server, from the BASE + /// long_query_completions table (a post-V14 collector has no v_* passthrough view). Same + /// windowed shape as the blocking read (event_time DESC, LIMIT 200); the grid applies a view-only + /// DESCENDING-by-duration sort. $1 server_id, $2 window start, $3 window end (naive UTC), $4 db filter. + /// + public const string LongQueryCompletionsSql = """ + SELECT + collection_time, + event_time, + event_type, + database_name, + session_id, + client_app_name, + client_pid, + nt_username, + server_principal_name, + query_hash, + event_sequence, + duration_microseconds, + cpu_time_microseconds, + physical_reads, + logical_reads, + writes, + row_count, + result, + statement_text, + object_name + FROM long_query_completions + WHERE server_id = $1 + AND collection_time >= $2 + AND collection_time <= $3 + AND ($4::text[] IS NULL OR database_name = ANY($4)) + ORDER BY event_time DESC + LIMIT 200 + """; + + public async Task> GetRecentLongQueryCompletionsAsync( + int serverId, DateTime startUtc, DateTime endUtc, IReadOnlyList? databaseNames = null, CancellationToken cancellationToken = default) + { + var rows = new List(); + + await using var command = _dataSource.CreateCommand(LongQueryCompletionsSql); + AddWindowParameters(command, serverId, startUtc, endUtc); + command.Parameters.Add(DatabaseFilterParameter(databaseNames)); + await using var reader = await command.ExecuteReaderAsync(cancellationToken); + while (await reader.ReadAsync(cancellationToken)) + { + rows.Add(new ViewerLongQueryRow + { + CollectionTime = reader.GetDateTime(0), + EventTime = reader.IsDBNull(1) ? null : reader.GetDateTime(1), + EventType = reader.IsDBNull(2) ? "" : reader.GetString(2), + DatabaseName = reader.IsDBNull(3) ? "" : reader.GetString(3), + SessionId = reader.IsDBNull(4) ? null : reader.GetInt32(4), + ClientAppName = reader.IsDBNull(5) ? "" : reader.GetString(5), + ClientPid = reader.IsDBNull(6) ? null : reader.GetInt32(6), + NtUserName = reader.IsDBNull(7) ? "" : reader.GetString(7), + ServerPrincipalName = reader.IsDBNull(8) ? "" : reader.GetString(8), + QueryHash = reader.IsDBNull(9) ? "" : reader.GetString(9), + EventSequence = reader.IsDBNull(10) ? null : reader.GetInt64(10), + DurationMicroseconds = reader.IsDBNull(11) ? null : reader.GetInt64(11), + CpuTimeMicroseconds = reader.IsDBNull(12) ? null : reader.GetInt64(12), + PhysicalReads = reader.IsDBNull(13) ? null : reader.GetInt64(13), + LogicalReads = reader.IsDBNull(14) ? null : reader.GetInt64(14), + Writes = reader.IsDBNull(15) ? null : reader.GetInt64(15), + RowCountValue = reader.IsDBNull(16) ? null : reader.GetInt64(16), + Result = reader.IsDBNull(17) ? "" : reader.GetString(17), + StatementText = reader.IsDBNull(18) ? "" : reader.GetString(18), + ObjectName = reader.IsDBNull(19) ? "" : reader.GetString(19), + }); + } + + return rows; + } + + /// + /// The effective enabled state of the opt-in long_query_completions collector for one server, + /// resolved from config_collector_schedules (per-server override > fleet override > the + /// shared default), so the Long Queries tab can show its "trace is OFF" empty-state banner. Mirrors the + /// service's StoreConfigProvider.ResolveSchedule enabled precedence; the shared default for this + /// collector is OFF (CollectorScheduleDefaults DefaultEnabled=false — the viewer doesn't reference the + /// collector library, so the false default is inlined here rather than read from it). + /// + public async Task GetLongQueryTraceEnabledAsync(int serverId, CancellationToken cancellationToken = default) + { + var overrides = await GetCollectorSchedulesAsync(cancellationToken); + + bool? perServer = null; + bool? fleet = null; + foreach (var o in overrides) + { + if (!string.Equals(o.CollectorName, "long_query_completions", StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + if (o.ServerId == serverId) + { + perServer = o.Enabled; + } + else if (o.ServerId is null) + { + fleet = o.Enabled; + } + } + + return perServer ?? fleet ?? false; + } +} diff --git a/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.cs b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.cs index 2d0c240198..a2e43da17d 100644 --- a/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.cs +++ b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.cs @@ -388,7 +388,8 @@ OR NOT EXISTS (SELECT 1 FROM pg_extension WHERE extname = 'timescaledb') EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'agent_status'), EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'config_notification' AND column_name = 'generic_url'), EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'deadlocks' AND column_name = 'database_name'), - EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'query_store_stats' AND column_name = 'replica_role')"; + EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'query_store_stats' AND column_name = 'replica_role'), + EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'long_query_completions')"; /// The store schema version this viewer build requires — the highest migration it knows /// (). The connect-time gate blocks a store below this. @@ -409,7 +410,7 @@ OR NOT EXISTS (SELECT 1 FROM pg_extension WHERE extname = 'timescaledb') await using var reader = await command.ExecuteReaderAsync(cancellationToken); if (await reader.ReadAsync(cancellationToken)) { - return MapProbedSchemaVersion(reader.GetBoolean(0), reader.GetBoolean(1), reader.GetBoolean(2), reader.GetBoolean(3), reader.GetBoolean(4), reader.GetBoolean(5), reader.GetBoolean(6), reader.GetBoolean(7), reader.GetBoolean(8), reader.GetBoolean(9), reader.GetBoolean(10), reader.GetBoolean(11)); + return MapProbedSchemaVersion(reader.GetBoolean(0), reader.GetBoolean(1), reader.GetBoolean(2), reader.GetBoolean(3), reader.GetBoolean(4), reader.GetBoolean(5), reader.GetBoolean(6), reader.GetBoolean(7), reader.GetBoolean(8), reader.GetBoolean(9), reader.GetBoolean(10), reader.GetBoolean(11), reader.GetBoolean(12)); } return null; @@ -431,11 +432,18 @@ OR NOT EXISTS (SELECT 1 FROM pg_extension WHERE extname = 'timescaledb') /// 19, else (V18) → 18, else /// (V17) → 17, else 16 — the "older than the V17 config control /// plane" floor (the exact pre-17 version isn't probed, but it is below what the viewer needs). Pure, so it - /// is unit-tested without a live store; a schema bump past 28 trips the pinning test that keeps this in step + /// is unit-tested without a live store; a schema bump past 29 trips the pinning test that keeps this in step /// with . /// - internal static int MapProbedSchemaVersion(bool hasConfigControlPlane, bool hasAlertDeliveryOverride, bool hasAnalysisState, bool hasAlertTuningKnobs, bool hasDefaultTraceEvents, bool hasIndexObjectStatsLatestIndex, bool hasCollectionLogHypertableOrPlainPg, bool hasJobHistory, bool hasAgentStatus, bool hasGenericWebhook, bool hasDeadlocksDatabaseName, bool hasQueryStoreReplicaRole) + internal static int MapProbedSchemaVersion(bool hasConfigControlPlane, bool hasAlertDeliveryOverride, bool hasAnalysisState, bool hasAlertTuningKnobs, bool hasDefaultTraceEvents, bool hasIndexObjectStatsLatestIndex, bool hasCollectionLogHypertableOrPlainPg, bool hasJobHistory, bool hasAgentStatus, bool hasGenericWebhook, bool hasDeadlocksDatabaseName, bool hasQueryStoreReplicaRole, bool hasLongQueryCompletions) { + /* V29 (#1496 long-query completion trace): engine-agnostic table-existence sentinel, newest-first + arm. The long_query_completions table exists only at V29 or later. */ + if (hasLongQueryCompletions) + { + return 29; + } + /* V28 (#1546 Query Store replica attribution): engine-agnostic column-existence sentinel, newest-first arm. query_store_stats.replica_role exists only at V28 or later. */ if (hasQueryStoreReplicaRole) diff --git a/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.Filters.cs b/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.Filters.cs index bb4b641147..aa9f7b4666 100644 --- a/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.Filters.cs +++ b/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.Filters.cs @@ -36,6 +36,7 @@ public partial class ViewerServerTab : UserControl private DataGridFilterManager? _dbScopedConfigFilterMgr; private DataGridFilterManager? _traceFlagsFilterMgr; private DataGridFilterManager? _runningJobsFilterMgr; + private DataGridFilterManager? _longQueryFilterMgr; private DataGridFilterManager? _blockedProcessFilterMgr; private DataGridFilterManager? _deadlockFilterMgr; private DataGridFilterManager? _querySnapshotsFilterMgr; @@ -58,6 +59,7 @@ private void InitializeFilterManagers() _dbScopedConfigFilterMgr = new DataGridFilterManager(DatabaseScopedConfigGrid); _traceFlagsFilterMgr = new DataGridFilterManager(TraceFlagsGrid); _runningJobsFilterMgr = new DataGridFilterManager(RunningJobsGrid); + _longQueryFilterMgr = new DataGridFilterManager(LongQueryCompletionsGrid); _blockedProcessFilterMgr = new DataGridFilterManager(BlockedProcessReportGrid); _deadlockFilterMgr = new DataGridFilterManager(DeadlockGrid); _querySnapshotsFilterMgr = new DataGridFilterManager(QuerySnapshotsGrid); @@ -76,6 +78,7 @@ private void InitializeFilterManagers() _filterManagers[DatabaseScopedConfigGrid] = _dbScopedConfigFilterMgr; _filterManagers[TraceFlagsGrid] = _traceFlagsFilterMgr; _filterManagers[RunningJobsGrid] = _runningJobsFilterMgr; + _filterManagers[LongQueryCompletionsGrid] = _longQueryFilterMgr; _filterManagers[BlockedProcessReportGrid] = _blockedProcessFilterMgr; _filterManagers[DeadlockGrid] = _deadlockFilterMgr; _filterManagers[QuerySnapshotsGrid] = _querySnapshotsFilterMgr; diff --git a/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.LongQueries.cs b/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.LongQueries.cs new file mode 100644 index 0000000000..598bde493d --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.LongQueries.cs @@ -0,0 +1,37 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System.ComponentModel; +using System.Threading.Tasks; +using System.Windows; + +namespace PerformanceMonitor.Darling.Viewer; + +public partial class ViewerServerTab +{ + /// + /// Long Queries inner tab (#1496): completed long-running queries (rpc/batch over the duration + /// threshold) plus attentions (cancels/timeouts) from the opt-in XE session, windowed on the toolbar's + /// range and bound through the filter manager. Because the collector is default OFF, an explicit + /// empty-state banner is shown while it is disabled, pointing the operator at the exact switch + /// (Settings → Collection Schedule → Edit Collector Schedules… → the Enabled box). The grid applies a + /// view-only DESCENDING-by-duration sort, never touching the reader's chronological ORDER BY. + /// + private async Task LoadLongQueriesAsync() + { + LongQueriesDisabledWarning.Visibility = await _dataService.GetLongQueryTraceEnabledAsync(_server.ServerId) + ? Visibility.Collapsed + : Visibility.Visible; + + var (startUtc, endUtc) = GetWindowUtc(); + var rows = await _dataService.GetRecentLongQueryCompletionsAsync(_server.ServerId, startUtc, endUtc, databaseNames: SelectedDatabaseFilter); + _longQueryFilterMgr!.UpdateData(rows); + + SetDefaultSortIfNone(LongQueryCompletionsGrid, "DurationMicroseconds", ListSortDirection.Descending); + } +} diff --git a/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.xaml b/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.xaml index abe9d9163d..afb00a53c6 100644 --- a/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.xaml +++ b/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.xaml @@ -3301,6 +3301,94 @@ + + + + + + + + + + + + + + + + + +