diff --git a/Lite/Services/RemoteCollectorService.QuerySnapshots.cs b/Lite/Services/RemoteCollectorService.QuerySnapshots.cs
index 7ab3765..5148af4 100644
--- a/Lite/Services/RemoteCollectorService.QuerySnapshots.cs
+++ b/Lite/Services/RemoteCollectorService.QuerySnapshots.cs
@@ -21,7 +21,7 @@ namespace PerformanceMonitorLite.Services;
public partial class RemoteCollectorService
{
private const string QuerySnapshotsBase = """
-
+
SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
SET LOCK_TIMEOUT 1000;
@@ -81,17 +81,124 @@ WHERE der.session_id <> @@SPID
AND der.session_id >= 50
AND dest.text IS NOT NULL
AND der.database_id <> ISNULL(DB_ID(N'PerformanceMonitor'), 0)
-{2}
ORDER BY der.cpu_time DESC, der.parallel_worker_count DESC
OPTION(MAXDOP 1, RECOMPILE);
""";
private readonly static CompositeFormat QuerySnapshotsBaseFormat = CompositeFormat.Parse(QuerySnapshotsBase);
+ /*
+ * On Azure SQL Database with a contained / DB-scoped login (e.g. D365FO),
+ * the OUTER APPLY to sys.dm_exec_sql_text / sys.dm_exec_text_query_plan
+ * will be evaluated against master-scoped rows from sys.dm_exec_requests
+ * before the WHERE predicate applies, tripping error 300 (VIEW SERVER
+ * PERFORMANCE STATE denied). Materialising the filtered request rows
+ * into a #temp table first guarantees the DMFs only see handles from
+ * the current database. See #857.
+ */
+ private const string QuerySnapshotsAzureBase = """
+
+SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
+SET LOCK_TIMEOUT 1000;
+
+IF OBJECT_ID(N'tempdb..#req') IS NOT NULL DROP TABLE #req;
+
+SELECT
+ der.session_id,
+ der.database_id,
+ der.sql_handle,
+ der.plan_handle,
+ der.statement_start_offset,
+ der.statement_end_offset,
+ der.status,
+ der.blocking_session_id,
+ der.wait_type,
+ der.wait_time,
+ der.wait_resource,
+ der.cpu_time,
+ der.total_elapsed_time,
+ der.reads,
+ der.writes,
+ der.logical_reads,
+ der.granted_query_memory,
+ der.transaction_isolation_level,
+ der.dop,
+ der.parallel_worker_count,
+ der.percent_complete
+INTO #req
+FROM sys.dm_exec_requests AS der
+WHERE der.session_id <> @@SPID
+AND der.session_id >= 50
+AND der.database_id = DB_ID()
+AND der.database_id <> ISNULL(DB_ID(N'PerformanceMonitor'), 0);
+
+SELECT /* PerformanceMonitorLite */
+ der.session_id,
+ database_name = d.name,
+ elapsed_time_formatted =
+ CASE
+ WHEN der.total_elapsed_time < 0
+ THEN '00 00:00:00.000'
+ ELSE RIGHT(REPLICATE('0', 2) + CONVERT(varchar(10), der.total_elapsed_time / 86400000), 2) +
+ ' ' + RIGHT(CONVERT(varchar(30), DATEADD(second, der.total_elapsed_time / 1000, 0), 120), 9) +
+ '.' + RIGHT('000' + CONVERT(varchar(3), der.total_elapsed_time % 1000), 3)
+ END,
+ query_text = SUBSTRING(dest.text, (der.statement_start_offset / 2) + 1,
+ ((CASE der.statement_end_offset WHEN -1 THEN DATALENGTH(dest.text)
+ ELSE der.statement_end_offset END - der.statement_start_offset) / 2) + 1),
+ query_plan = TRY_CAST(deqp.query_plan AS nvarchar(max)),
+ {0}
+ der.status,
+ der.blocking_session_id,
+ der.wait_type,
+ wait_time_ms = CONVERT(bigint, der.wait_time),
+ der.wait_resource,
+ cpu_time_ms = CONVERT(bigint, der.cpu_time),
+ total_elapsed_time_ms = CONVERT(bigint, der.total_elapsed_time),
+ der.reads,
+ der.writes,
+ der.logical_reads,
+ granted_query_memory_gb = CONVERT(decimal(38, 2), (der.granted_query_memory / 128. / 1024.)),
+ transaction_isolation_level =
+ CASE der.transaction_isolation_level
+ WHEN 0 THEN 'Unspecified'
+ WHEN 1 THEN 'Read Uncommitted'
+ WHEN 2 THEN 'Read Committed'
+ WHEN 3 THEN 'Repeatable Read'
+ WHEN 4 THEN 'Serializable'
+ WHEN 5 THEN 'Snapshot'
+ ELSE '???'
+ END,
+ der.dop,
+ der.parallel_worker_count,
+ des.login_name,
+ des.host_name,
+ des.program_name,
+ des.open_transaction_count,
+ der.percent_complete
+FROM #req AS der
+JOIN sys.dm_exec_sessions AS des
+ ON des.session_id = der.session_id
+OUTER APPLY sys.dm_exec_sql_text(COALESCE(der.sql_handle, der.plan_handle)) AS dest
+OUTER APPLY sys.dm_exec_text_query_plan(der.plan_handle, der.statement_start_offset, der.statement_end_offset) AS deqp
+LEFT JOIN sys.databases AS d
+ ON d.database_id = der.database_id
+{1}
+WHERE dest.text IS NOT NULL
+ORDER BY der.cpu_time DESC, der.parallel_worker_count DESC
+OPTION(MAXDOP 1, RECOMPILE);
+
+DROP TABLE #req;
+""";
+ private readonly static CompositeFormat QuerySnapshotsAzureBaseFormat = CompositeFormat.Parse(QuerySnapshotsAzureBase);
+
///
/// Builds the query snapshots SQL with or without live query plan support.
/// Used by both the collector and the live snapshot button.
- /// On Azure SQL Database the result set is scoped to the current database only,
- /// because logins without access to master can't resolve cross-database requests (see #857).
+ /// On Azure SQL Database the request rows are first materialised into a
+ /// #temp table scoped to the current database, so the downstream OUTER APPLYs
+ /// to sys.dm_exec_sql_text / sys.dm_exec_text_query_plan only ever see
+ /// current-DB handles — avoiding the VIEW SERVER PERFORMANCE STATE error
+ /// that D365FO-style DB-scoped logins hit (see #857).
///
internal static string BuildQuerySnapshotsQuery(bool supportsLiveQueryPlan, bool isAzureSqlDatabase)
{
@@ -101,10 +208,8 @@ internal static string BuildQuerySnapshotsQuery(bool supportsLiveQueryPlan, bool
var liveQueryPlanApply = supportsLiveQueryPlan
? "OUTER APPLY sys.dm_exec_query_statistics_xml(der.session_id) AS deqs"
: "";
- var azureScopePredicate = isAzureSqlDatabase
- ? "AND der.database_id = DB_ID()"
- : "";
- return string.Format(null, QuerySnapshotsBaseFormat, liveQueryPlanColumn, liveQueryPlanApply, azureScopePredicate);
+ var template = isAzureSqlDatabase ? QuerySnapshotsAzureBaseFormat : QuerySnapshotsBaseFormat;
+ return string.Format(null, template, liveQueryPlanColumn, liveQueryPlanApply);
}
///