Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions Lite/Analysis/AnalysisService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -51,15 +51,15 @@ public class AnalysisService
/// </summary>
public string? InsufficientDataMessage { get; private set; }

public AnalysisService(DuckDbInitializer duckDb)
public AnalysisService(DuckDbInitializer duckDb, IPlanFetcher? planFetcher = null)
{
_duckDb = duckDb;
_findingStore = new FindingStore(duckDb);
_collector = new DuckDbFactCollector(duckDb);
_scorer = new FactScorer();
_graph = new RelationshipGraph();
_engine = new InferenceEngine(_graph);
_drillDown = new DrillDownCollector(duckDb);
_drillDown = new DrillDownCollector(duckDb, planFetcher);
_anomalyDetector = new AnomalyDetector(duckDb);
}

Expand Down
148 changes: 73 additions & 75 deletions Lite/Analysis/DrillDownCollector.cs
Original file line number Diff line number Diff line change
Expand Up @@ -21,11 +21,13 @@
public class DrillDownCollector
{
private readonly DuckDbInitializer _duckDb;
private readonly IPlanFetcher? _planFetcher;
private const int TextLimit = 500;

public DrillDownCollector(DuckDbInitializer duckDb)
public DrillDownCollector(DuckDbInitializer duckDb, IPlanFetcher? planFetcher = null)
{
_duckDb = duckDb;
_planFetcher = planFetcher;
}

/// <summary>
Expand Down Expand Up @@ -72,7 +74,7 @@
if (pathKeys.Contains("MEMORY_GRANT_PENDING"))
await CollectPendingGrants(finding, context);

if (pathKeys.Any(k => k.StartsWith("BAD_ACTOR_")))

Check warning on line 77 in Lite/Analysis/DrillDownCollector.cs

View workflow job for this annotation

GitHub Actions / build

The behavior of 'string.StartsWith(string)' could vary based on the current user's locale settings. Replace this call in 'PerformanceMonitorLite.Analysis.DrillDownCollector.EnrichFindingsAsync(System.Collections.Generic.List<PerformanceMonitorLite.Analysis.AnalysisFinding>, PerformanceMonitorLite.Analysis.AnalysisContext)' with a call to 'string.StartsWith(string, System.StringComparison)'. (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1310)
await CollectBadActorDetail(finding, context);

// Plan analysis: for findings with top queries, analyze their cached plans
Expand Down Expand Up @@ -550,106 +552,102 @@
}

/// <summary>
/// For findings that have query hashes (from top_cpu_queries, bad_actor_query, or queries_at_spike),
/// fetch cached plan XML and run PlanAnalyzer to surface warnings and missing indexes.
/// For findings that have query hashes (bad actors), fetch the execution plan
/// live from SQL Server via IPlanFetcher, then run PlanAnalyzer to surface
/// warnings and missing indexes. No plan storage needed — fetch on demand
/// only for queries that make it into high-impact findings.
/// </summary>
private async Task CollectPlanAnalysis(AnalysisFinding finding, AnalysisContext context)
{
if (finding.DrillDown == null) return;
if (finding.DrillDown == null || _planFetcher == null) return;

// Collect query hashes from drill-down data
var queryHashes = new List<string>();
// Only analyze plans for bad actor findings (1 plan each).
// Skip top_cpu_queries (5 plans would be too heavy).
if (!finding.RootFactKey.StartsWith("BAD_ACTOR_")) return;

Check warning on line 566 in Lite/Analysis/DrillDownCollector.cs

View workflow job for this annotation

GitHub Actions / build

The behavior of 'string.StartsWith(string)' could vary based on the current user's locale settings. Replace this call in 'PerformanceMonitorLite.Analysis.DrillDownCollector.CollectPlanAnalysis(PerformanceMonitorLite.Analysis.AnalysisFinding, PerformanceMonitorLite.Analysis.AnalysisContext)' with a call to 'string.StartsWith(string, System.StringComparison)'. (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1310)

if (finding.DrillDown.TryGetValue("bad_actor_query", out var badActor) && badActor != null)
{
// bad_actor_query is a single anonymous object — extract hash from finding key
var hash = finding.RootFactKey.Replace("BAD_ACTOR_", "");
if (!string.IsNullOrEmpty(hash)) queryHashes.Add(hash);
}

// Only analyze plans for findings with a small number of query hashes (bad actors).
// Skip top_cpu_queries (5 plans would be too heavy) — those have next_tools for manual follow-up.
if (queryHashes.Count == 0) return;

using var readLock = _duckDb.AcquireReadLock();
using var connection = _duckDb.CreateConnection();
await connection.OpenAsync();

var planFindings = new List<object>();
var queryHash = finding.RootFactKey.Replace("BAD_ACTOR_", "");
if (string.IsNullOrEmpty(queryHash)) return;

foreach (var hash in queryHashes.Take(3)) // Max 3 plans to analyze
// Look up plan_handle from DuckDB for this query_hash
string? planHandle = null;
try
{
using var readLock = _duckDb.AcquireReadLock();
using var connection = _duckDb.CreateConnection();
await connection.OpenAsync();

using var cmd = connection.CreateCommand();
cmd.CommandText = @"
SELECT query_plan_xml
SELECT plan_handle
FROM v_query_stats
WHERE server_id = $1
AND query_hash = $2
AND query_plan_xml IS NOT NULL
AND LENGTH(query_plan_xml) > 100
AND plan_handle IS NOT NULL AND plan_handle != ''
ORDER BY collection_time DESC
LIMIT 1";

cmd.Parameters.Add(new DuckDBParameter { Value = context.ServerId });
cmd.Parameters.Add(new DuckDBParameter { Value = hash });
cmd.Parameters.Add(new DuckDBParameter { Value = queryHash });

using var reader = await cmd.ExecuteReaderAsync();
if (!await reader.ReadAsync()) continue;
if (await reader.ReadAsync() && !reader.IsDBNull(0))
planHandle = reader.GetString(0);
}
catch { return; }

var planXml = reader.IsDBNull(0) ? null : reader.GetString(0);
if (string.IsNullOrEmpty(planXml)) continue;
if (string.IsNullOrEmpty(planHandle)) return;

try
{
var plan = ShowPlanParser.Parse(planXml);
PlanAnalyzer.Analyze(plan);
// Fetch plan XML live from SQL Server
var planXml = await _planFetcher.FetchPlanXmlAsync(context.ServerId, planHandle);
if (string.IsNullOrEmpty(planXml)) return;

var allWarnings = plan.Batches
.SelectMany(b => b.Statements)
.Where(s => s.RootNode != null)
.SelectMany(s =>
{
var nodeWarnings = new List<PlanNode>();
CollectPlanNodes(s.RootNode!, nodeWarnings);
return s.PlanWarnings
.Concat(nodeWarnings.SelectMany(n => n.Warnings));
})
.ToList();
try
{
var plan = ShowPlanParser.Parse(planXml);
PlanAnalyzer.Analyze(plan);

var missingIndexes = plan.AllMissingIndexes;
var allWarnings = plan.Batches
.SelectMany(b => b.Statements)
.Where(s => s.RootNode != null)
.SelectMany(s =>
{
var nodeWarnings = new List<PlanNode>();
CollectPlanNodes(s.RootNode!, nodeWarnings);
return s.PlanWarnings
.Concat(nodeWarnings.SelectMany(n => n.Warnings));
})
.ToList();

if (allWarnings.Count == 0 && missingIndexes.Count == 0) continue;
var missingIndexes = plan.AllMissingIndexes;

planFindings.Add(new
{
query_hash = hash,
warning_count = allWarnings.Count,
critical_count = allWarnings.Count(w => w.Severity == PlanWarningSeverity.Critical),
warnings = allWarnings
.OrderByDescending(w => w.Severity)
.Take(10)
.Select(w => new
{
severity = w.Severity.ToString(),
type = w.WarningType,
message = McpHelpers.Truncate(w.Message, 300)
}),
missing_indexes = missingIndexes.Take(5).Select(idx => new
{
table = $"{idx.Schema}.{idx.Table}",
impact = idx.Impact,
create_statement = idx.CreateStatement
})
});
}
catch
if (allWarnings.Count == 0 && missingIndexes.Count == 0) return;

finding.DrillDown["plan_analysis"] = new
{
// Plan parsing can fail on malformed XML — skip silently
}
query_hash = queryHash,
warning_count = allWarnings.Count,
critical_count = allWarnings.Count(w => w.Severity == PlanWarningSeverity.Critical),
warnings = allWarnings
.OrderByDescending(w => w.Severity)
.Take(10)
.Select(w => new
{
severity = w.Severity.ToString(),
type = w.WarningType,
message = McpHelpers.Truncate(w.Message, 300)
}),
missing_indexes = missingIndexes.Take(5).Select(idx => new
{
table = $"{idx.Schema}.{idx.Table}",
impact = idx.Impact,
create_statement = idx.CreateStatement
})
};
}
catch
{
// Plan parsing can fail on malformed XML — skip silently
}

if (planFindings.Count > 0)
finding.DrillDown["plan_analysis"] = planFindings;
}

private static void CollectPlanNodes(PlanNode node, List<PlanNode> nodes)
Expand Down
19 changes: 19 additions & 0 deletions Lite/Analysis/IPlanFetcher.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
using System.Threading.Tasks;

namespace PerformanceMonitorLite.Analysis;

/// <summary>
/// Fetches execution plan XML from SQL Server on demand.
/// Platform-agnostic interface — Lite implements via RemoteCollectorService's
/// SQL connection, Dashboard implements via DatabaseService's connection.
/// Used by DrillDownCollector to analyze plans for high-impact findings
/// without storing plan XML in DuckDB or SQL Server tables.
/// </summary>
public interface IPlanFetcher
{
/// <summary>
/// Fetches the execution plan XML for a given plan_handle.
/// Returns null if the plan is no longer in cache.
/// </summary>
Task<string?> FetchPlanXmlAsync(int serverId, string planHandle);
}
63 changes: 63 additions & 0 deletions Lite/Analysis/SqlPlanFetcher.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
using System;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.Data.SqlClient;
using PerformanceMonitorLite.Services;

namespace PerformanceMonitorLite.Analysis;

/// <summary>
/// Lite implementation of IPlanFetcher — fetches plans from SQL Server
/// using the server connection managed by ServerManager.
/// </summary>
public class SqlPlanFetcher : IPlanFetcher
{
private readonly ServerManager _serverManager;

public SqlPlanFetcher(ServerManager serverManager)
{
_serverManager = serverManager;
}

public async Task<string?> FetchPlanXmlAsync(int serverId, string planHandle)
{
if (string.IsNullOrEmpty(planHandle)) return null;

// serverId is a hash — find the server by matching the hash
var server = _serverManager.GetAllServers()
.FirstOrDefault(s => s.ServerName.GetHashCode() == serverId);
if (server == null) return null;

try
{
var connectionString = server.GetConnectionString(_serverManager.CredentialService);
var builder = new SqlConnectionStringBuilder(connectionString)
{
ConnectTimeout = 10,
CommandTimeout = 15
};

await using var connection = new SqlConnection(builder.ConnectionString);
await connection.OpenAsync();

await using var cmd = new SqlCommand(@"
SET NOCOUNT ON;
SELECT query_plan
FROM sys.dm_exec_query_plan(CONVERT(varbinary(64), @plan_handle, 1))", connection);

cmd.CommandTimeout = 15;
cmd.Parameters.AddWithValue("@plan_handle", planHandle);

var result = await cmd.ExecuteScalarAsync();
if (result == null || result is DBNull) return null;

return result.ToString();
}
catch (Exception ex)
{
AppLogger.Error("SqlPlanFetcher",
$"Failed to fetch plan for handle {planHandle}: {ex.Message}");
return null;
}
}
}
3 changes: 2 additions & 1 deletion Lite/Mcp/McpHostService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,8 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken)
builder.Services.AddSingleton(_dataService);
builder.Services.AddSingleton(_serverManager);
builder.Services.AddSingleton(_muteRuleService);
builder.Services.AddSingleton(new AnalysisService(_duckDb));
var planFetcher = new SqlPlanFetcher(_serverManager);
builder.Services.AddSingleton(new AnalysisService(_duckDb, planFetcher));

/* Register MCP server with all tool classes */
builder.Services
Expand Down
Loading