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
19 changes: 17 additions & 2 deletions Dashboard/MainWindow.xaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2273,7 +2273,7 @@ await _emailAlertService.TrySendAlertEmailAsync(
AlertIncidentRenderer.Apply(context, BlockingIncidentGrouper.Group(
serverName,
events.Select(e => new BlockingIncidentGrouper.BlockedEvent(
e.DatabaseName, e.ContentiousObject, e.QueryText, null, e.WaitTimeMs ?? 0)))
e.DatabaseName, e.ContentiousObject, e.QueryText, null, e.WaitTimeMs ?? 0, e.LockMode)))
.Select(g => g.Incident).ToList());

return context;
Expand Down Expand Up @@ -2351,7 +2351,8 @@ deadlock event across ALL events in the window. */
serverName,
deadlocks.GroupBy(d => d.EventDate).Select(g => new DeadlockIncidentGrouper.DeadlockEvent(
DeadlockObjectExtractor.FromGraphXml(
g.Select(x => x.DeadlockGraph).FirstOrDefault(x => !string.IsNullOrEmpty(x))))))
g.Select(x => x.DeadlockGraph).FirstOrDefault(x => !string.IsNullOrEmpty(x))),
DeadlockDetailFields(g))))
.Select(g => g.Incident).ToList());

return context;
Expand All @@ -2368,6 +2369,20 @@ deadlock event across ALL events in the window. */
/// A deadlock is only excluded when ALL process nodes have a currentdbname in the excluded list.
/// Cross-database deadlocks involving any non-excluded database will still be reported.
/// </summary>
/* #1141: forensic detail carried on a deadlock incident so per-event cards keep the query +
wait resource + lock mode (Summary mode shows them via the builder's own items). */
private static List<AlertIncidentField>? DeadlockDetailFields(IEnumerable<DeadlockItem> participants)
{
var rep = participants.FirstOrDefault(x => !string.IsNullOrWhiteSpace(x.Query)) ?? participants.FirstOrDefault();
if (rep is null) return null;
var f = new List<AlertIncidentField>();
if (!string.IsNullOrWhiteSpace(rep.DatabaseName)) f.Add(new AlertIncidentField("Database", rep.DatabaseName));
if (!string.IsNullOrWhiteSpace(rep.Query)) f.Add(new AlertIncidentField("Query", Truncate(rep.Query)));
if (!string.IsNullOrWhiteSpace(rep.WaitResource)) f.Add(new AlertIncidentField("Wait Resource", rep.WaitResource));
if (!string.IsNullOrWhiteSpace(rep.LockMode)) f.Add(new AlertIncidentField("Lock Mode", rep.LockMode));
return f.Count > 0 ? f : null;
}

private static bool IsDeadlockExcluded(DeadlockItem deadlock, List<string> excludedDatabases)
{
if (string.IsNullOrEmpty(deadlock.DeadlockGraph)) return false;
Expand Down
39 changes: 38 additions & 1 deletion Lite.Tests/PerEventNotificationTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,44 @@ public void Split_WithinCap_OneMessagePerIncident()
Assert.Equal(3, messages.Count);
Assert.All(messages, m => Assert.False(m.IsOverflow));
Assert.All(messages, m => Assert.Single(m.Context.Incidents!));
Assert.Equal("db.dbo.T0", messages[0].CurrentValue);
Assert.Equal("1", messages[0].CurrentValue); // Current Value = occurrence count (incident 0 => 1), not the object list
}

[Fact]
public void Split_PerEventCard_CarriesIncidentDetailFields()
{
var ctx = new AlertContext();
var incident = new AlertIncident("k", new[] { "db.dbo.Orders" }, OccurrenceCount: 3,
DetailFields: new[] { new AlertIncidentField("Victim SQL", "UPDATE x"), new AlertIncidentField("Processes", "spids 51,52") });
AlertIncidentRenderer.Apply(ctx, new[] { incident });

var msg = Assert.Single(PerEventNotification.Split(ctx, 10));
var item = Assert.Single(msg.Context.Details);
Assert.Contains(item.Fields, f => f.Label == "Victim SQL" && f.Value == "UPDATE x");
Assert.Contains(item.Fields, f => f.Label == "Processes" && f.Value == "spids 51,52");
Assert.Contains(item.Fields, f => f.Label == "Dedup Key" && f.Value == "k");
}

[Fact]
public void Split_CarriesSourceAttachment()
{
var ctx = WithIncidents(2);
ctx.AttachmentXml = "<deadlock/>";
ctx.AttachmentFileName = "deadlock_graph.xml";
foreach (var m in PerEventNotification.Split(ctx, 10))
{
Assert.Equal("<deadlock/>", m.Context.AttachmentXml);
Assert.Equal("deadlock_graph.xml", m.Context.AttachmentFileName);
}
}

[Fact]
public void Split_CurrentValueIsOccurrenceCount()
{
var ctx = new AlertContext();
AlertIncidentRenderer.Apply(ctx, new[] { new AlertIncident("k", new[] { "db.dbo.A" }, OccurrenceCount: 7) });
var msg = Assert.Single(PerEventNotification.Split(ctx, 10));
Assert.Equal("7", msg.CurrentValue);
}

[Fact]
Expand Down
15 changes: 13 additions & 2 deletions Lite/MainWindow.xaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2300,7 +2300,7 @@ to database + literal-stripped query pair only when the object did not resolve.
var groups = BlockingIncidentGrouper.Group(
serverName,
events.Select(e => new BlockingIncidentGrouper.BlockedEvent(
e.DatabaseName, e.ContentiousObject, e.BlockedSqlText, e.BlockingSqlText, e.WaitTimeMs)));
e.DatabaseName, e.ContentiousObject, e.BlockedSqlText, e.BlockingSqlText, e.WaitTimeMs, e.LockMode)));

const int maxGroups = 10;
var shown = groups.Take(maxGroups).ToList();
Expand Down Expand Up @@ -2401,7 +2401,8 @@ recurrences over the same objects collapse to one incident with a count. */
var groups = DeadlockIncidentGrouper.Group(
serverName,
deadlocks.Select(d => new DeadlockIncidentGrouper.DeadlockEvent(
DeadlockObjectExtractor.FromGraphXml(d.DeadlockGraphXml))));
DeadlockObjectExtractor.FromGraphXml(d.DeadlockGraphXml),
DeadlockDetailFields(d.VictimSqlText, d.ProcessSummary))));
AlertIncidentRenderer.Apply(context, groups.Select(g => g.Incident).ToList());

return context;
Expand All @@ -2413,6 +2414,16 @@ recurrences over the same objects collapse to one incident with a count. */
}
}

/* #1141: forensic detail carried on a deadlock incident so per-event cards keep the victim SQL
+ process summary (Summary mode shows them via the builder's own items). */
private static List<AlertIncidentField>? DeadlockDetailFields(string? victimSql, string? processes)
{
var f = new List<AlertIncidentField>();
if (!string.IsNullOrWhiteSpace(victimSql)) f.Add(new AlertIncidentField("Victim SQL", TruncateText(victimSql)));
if (!string.IsNullOrWhiteSpace(processes)) f.Add(new AlertIncidentField("Processes", processes!));
return f.Count > 0 ? f : null;
}

private static bool IsDeadlockExcluded(DeadlockRow row, List<string> excludedDatabases)
{
if (string.IsNullOrEmpty(row.DeadlockGraphXml)) return false;
Expand Down
13 changes: 12 additions & 1 deletion PerformanceMonitor.Notifications/AlertContext.cs
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,18 @@ public sealed record AlertIncident(
string DedupKey,
IReadOnlyList<string> InvolvedObjects,
int OccurrenceCount = 1,
string? WaitRange = null);
string? WaitRange = null,
IReadOnlyList<AlertIncidentField>? DetailFields = null);

/// <summary>
/// A forensic label/value pair carried on an <see cref="AlertIncident"/> for #1141 Per-event delivery
/// (e.g. Victim SQL / Processes for a deadlock; Database / Blocked Query / Blocking Query / Lock Mode
/// for a blocking chain). Transient: populated by the incident groupers, rendered into the per-event
/// card by <see cref="PerEventNotification"/>, and deliberately NOT persisted (the Summary card already
/// lists the per-incident detail via the builders, and the per-event card's rendered Details are what
/// get saved). Summary rendering ignores it, so that path is unchanged.
/// </summary>
public sealed record AlertIncidentField(string Label, string Value);

/// <summary>
/// A single detail item (e.g., one blocking chain or one deadlock participant).
Expand Down
42 changes: 27 additions & 15 deletions PerformanceMonitor.Notifications/AlertIncidentRenderer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -37,22 +37,34 @@ public static void Apply(AlertContext context, IReadOnlyList<AlertIncident>? inc

for (int n = 0; n < incidents.Count; n++)
{
var incident = incidents[n];
var item = new AlertDetailItem
{
Heading = incidents.Count == 1 ? "Incident" : $"Incident {n + 1} of {incidents.Count}"
};
item.Fields.Add(("Dedup Key", incident.DedupKey));
item.Fields.Add(("Involved Objects",
incident.InvolvedObjects.Count > 0
? string.Join(", ", incident.InvolvedObjects)
: "(unresolved)"));
if (incident.OccurrenceCount > 1)
item.Fields.Add(("Occurrences", incident.OccurrenceCount.ToString()));
if (!string.IsNullOrEmpty(incident.WaitRange))
item.Fields.Add(("Wait Range", incident.WaitRange));
var heading = incidents.Count == 1 ? "Incident" : $"Incident {n + 1} of {incidents.Count}";
// Summary mode leaves the forensic DetailFields off — the builder already lists the
// per-incident detail in its own items, so including them here would duplicate.
context.Details.Add(BuildItem(incidents[n], heading, includeDetailFields: false));
}
}

context.Details.Add(item);
/// <summary>
/// Builds one detail item for an incident. When <paramref name="includeDetailFields"/> is true the
/// incident's forensic <see cref="AlertIncident.DetailFields"/> are emitted first — used by #1141
/// Per-event delivery, where each card carries a single incident and has room for its full detail
/// (Victim SQL / Processes / queries) rather than only the dedup metadata.
/// </summary>
public static AlertDetailItem BuildItem(AlertIncident incident, string heading, bool includeDetailFields)
{
var item = new AlertDetailItem { Heading = heading };
if (includeDetailFields && incident.DetailFields is { Count: > 0 })
{
foreach (var f in incident.DetailFields)
item.Fields.Add((f.Label, f.Value));
}
item.Fields.Add(("Dedup Key", incident.DedupKey));
item.Fields.Add(("Involved Objects",
incident.InvolvedObjects.Count > 0 ? string.Join(", ", incident.InvolvedObjects) : "(unresolved)"));
if (incident.OccurrenceCount > 1)
item.Fields.Add(("Occurrences", incident.OccurrenceCount.ToString()));
if (!string.IsNullOrEmpty(incident.WaitRange))
item.Fields.Add(("Wait Range", incident.WaitRange));
return item;
}
}
37 changes: 31 additions & 6 deletions PerformanceMonitor.Notifications/IncidentGrouping.cs
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,8 @@ public readonly record struct BlockedEvent(
string? ContentiousObject,
string? BlockedQuery,
string? BlockingQuery,
long WaitTimeMs);
long WaitTimeMs,
string? LockMode = null);

/// <summary>One distinct blocking incident: a representative chain, its true occurrence count and
/// wait range, and the dedup <see cref="AlertIncident"/>.</summary>
Expand Down Expand Up @@ -98,10 +99,14 @@ public static List<BlockingGroup> Group(string serverName, IEnumerable<BlockedEv
if (incident is null)
continue;

// #1141: carry the chain's forensic detail on the incident so per-event cards keep it
// (Summary already shows it via the builder's items; this travels for the per-event split).
var enriched = incident with { DetailFields = BlockingDetail(representative) };

groups.Add(new BlockingGroup(
representative.Database, representative.ContentiousObject,
representative.BlockedQuery, representative.BlockingQuery,
rows.Count, minWait, maxWait, incident));
rows.Count, minWait, maxWait, enriched));
}

groups.Sort((a, b) => b.OccurrenceCount.CompareTo(a.OccurrenceCount));
Expand Down Expand Up @@ -138,6 +143,21 @@ private static string FormatWaitRange(long minMs, long maxMs)
string Sec(long ms) => (ms / 1000.0).ToString("F1", CultureInfo.InvariantCulture) + "s";
return minMs == maxMs ? Sec(maxMs) : Sec(minMs) + "-" + Sec(maxMs);
}

// Forensic detail for a blocking incident's per-event card (#1141): the representative chain's
// database, contentious object, the blocked/blocking query pair (truncated), and lock mode.
private static List<AlertIncidentField> BlockingDetail(BlockedEvent e)
{
var f = new List<AlertIncidentField>();
if (!string.IsNullOrWhiteSpace(e.Database)) f.Add(new AlertIncidentField("Database", e.Database!));
if (!string.IsNullOrWhiteSpace(e.ContentiousObject)) f.Add(new AlertIncidentField("Contentious Object", e.ContentiousObject!));
if (!string.IsNullOrWhiteSpace(e.BlockedQuery)) f.Add(new AlertIncidentField("Blocked Query", Truncate(e.BlockedQuery!)));
if (!string.IsNullOrWhiteSpace(e.BlockingQuery)) f.Add(new AlertIncidentField("Blocking Query", Truncate(e.BlockingQuery!)));
if (!string.IsNullOrWhiteSpace(e.LockMode)) f.Add(new AlertIncidentField("Lock Mode", e.LockMode!));
return f;
}

private static string Truncate(string s) => s.Length <= 300 ? s : s.Substring(0, 300) + "…";
}

/// <summary>
Expand All @@ -149,8 +169,11 @@ private static string FormatWaitRange(long minMs, long maxMs)
/// </summary>
public static class DeadlockIncidentGrouper
{
/// <summary>One deadlock event projected to the distinct fully-qualified objects it involved.</summary>
public readonly record struct DeadlockEvent(IReadOnlyList<string> Objects);
/// <summary>One deadlock event projected to the distinct fully-qualified objects it involved, plus
/// optional forensic detail (Victim SQL / Processes) carried onto the incident for per-event cards.</summary>
public readonly record struct DeadlockEvent(
IReadOnlyList<string> Objects,
IReadOnlyList<AlertIncidentField>? DetailFields = null);

/// <summary>One distinct deadlock incident: the involved object set, occurrence count, and fingerprint.</summary>
public sealed record DeadlockGroup(IReadOnlyList<string> Objects, int OccurrenceCount, AlertIncident Incident);
Expand All @@ -165,6 +188,7 @@ public static List<DeadlockGroup> Group(string serverName, IEnumerable<DeadlockE
var order = new List<string>();
var counts = new Dictionary<string, int>(StringComparer.Ordinal);
var incidents = new Dictionary<string, AlertIncident>(StringComparer.Ordinal);
var details = new Dictionary<string, IReadOnlyList<AlertIncidentField>?>(StringComparer.Ordinal);

foreach (var e in events ?? Enumerable.Empty<DeadlockEvent>())
{
Expand All @@ -178,6 +202,7 @@ public static List<DeadlockGroup> Group(string serverName, IEnumerable<DeadlockE
{
order.Add(key);
incidents[key] = probe;
details[key] = e.DetailFields; // first event of the group is the representative
current = 0;
}
counts[key] = current + 1;
Expand All @@ -188,8 +213,8 @@ public static List<DeadlockGroup> Group(string serverName, IEnumerable<DeadlockE
{
var count = counts[key];
var baseIncident = incidents[key];
// Re-stamp the occurrence count onto the incident (the probe used count 1).
var incident = baseIncident with { OccurrenceCount = count };
// Re-stamp the occurrence count + carry the representative's forensic detail (#1141).
var incident = baseIncident with { OccurrenceCount = count, DetailFields = details[key] };
groups.Add(new DeadlockGroup(incident.InvolvedObjects, count, incident));
}

Expand Down
32 changes: 21 additions & 11 deletions PerformanceMonitor.Notifications/PerEventNotification.cs
Original file line number Diff line number Diff line change
Expand Up @@ -53,28 +53,38 @@ public static List<Message> Split(AlertContext source, int maxPerCycle)

foreach (var incident in incidents.Take(cap))
{
var ctx = new AlertContext { SeverityOverride = source.SeverityOverride };
AlertIncidentRenderer.Apply(ctx, new[] { incident });
var ctx = NewContext(source);
ctx.Incidents = new List<AlertIncident> { incident };
// includeDetailFields: true — the per-event card has room for this one incident's full
// forensic detail (Victim SQL / Processes / queries), which Summary's batched card splits
// across the builder's own items.
ctx.Details.Add(AlertIncidentRenderer.BuildItem(incident, "Incident", includeDetailFields: true));
messages.Add(new Message(ctx, DescribeIncident(incident), IsOverflow: false));
}

var overflow = incidents.Skip(cap).ToList();
if (overflow.Count > 0)
{
var ctx = new AlertContext { SeverityOverride = source.SeverityOverride };
AlertIncidentRenderer.Apply(ctx, overflow);
var ctx = NewContext(source);
ctx.Incidents = new List<AlertIncident>(overflow);
for (int n = 0; n < overflow.Count; n++)
ctx.Details.Add(AlertIncidentRenderer.BuildItem(overflow[n], $"Incident {n + 1} of {overflow.Count}", includeDetailFields: true));
messages.Add(new Message(ctx, $"+{overflow.Count} more incident(s) this cycle", IsOverflow: true));
}

return messages;
}

// The alert's "current value" string for a single-incident message: the involved objects, or the
// dedup key when no objects resolved, so the notification headline names what the incident is about.
private static string DescribeIncident(AlertIncident incident)
// A fresh per-incident context that carries over the source's severity override AND the attachment
// (deadlock_graph.xml / blocked_process_report.xml) so per-event email keeps the forensic file.
private static AlertContext NewContext(AlertContext source) => new()
{
if (incident.InvolvedObjects.Count > 0)
return string.Join(", ", incident.InvolvedObjects);
return incident.DedupKey;
}
SeverityOverride = source.SeverityOverride,
AttachmentXml = source.AttachmentXml,
AttachmentFileName = source.AttachmentFileName
};

// The alert's "current value" for a single-incident card: the occurrence count (a number, matching
// the Summary card's count), not the involved-objects string (which already shows as its own fact).
private static string DescribeIncident(AlertIncident incident) => incident.OccurrenceCount.ToString();
}
Loading