Long and large transactions and long-running statements (8b, 8d)
A one-minute collector reads open user transactions (age, log bytes used
and reserved, database, session, login, program, statement) and user
requests running longer than the threshold, leaving out the agent's own
and system sessions, BACKUP/RESTORE/DBCC (long operations) and requests
waiting on purpose. Transactions open a minute or holding 100 MB are kept
in insight.LongTransactions (migration 0014, RawDays).
Rules SQLI-TRAN-001 (open > 10 min, High at 6x), SQLI-TRAN-002 (> 1 GB
log) and SQLI-REQ-001 (statement > 15 min), configurable under
SQLInsight:Activity. Shown "right now" on the instance page and in the
time window analysis and PDF; GET /api/v1/instances/{name}/activity and
the analysis JSON leave logins and statements out.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
e6840e40d7
commit
6f7839d011
22 changed files with 714 additions and 5 deletions
|
|
@ -4,7 +4,7 @@
|
||||||
|
|
||||||
SQLInsight Pro er et overvågningsværktøj til Microsoft SQL Server, skrevet i C# på .NET. Det kører hos kunden og forlader ikke netværket, medmindre kunden selv vælger det. Værktøjet nøjes ikke med at vise tal. Det forklarer, hvad der er galt, hvorfor det er et problem, og hvordan det løses. Rådene har samme niveau som dem, man får fra en erfaren DBA.
|
SQLInsight Pro er et overvågningsværktøj til Microsoft SQL Server, skrevet i C# på .NET. Det kører hos kunden og forlader ikke netværket, medmindre kunden selv vælger det. Værktøjet nøjes ikke med at vise tal. Det forklarer, hvad der er galt, hvorfor det er et problem, og hvordan det løses. Rådene har samme niveau som dem, man får fra en erfaren DBA.
|
||||||
|
|
||||||
> **Status:** Fase 1–4 er implementeret: agent, 72 indbyggede regler plus community-regler, notifikationer, lokalt repository og web-UI, licenser, signerede opdateringer, SQLInsight Hub med portal, erfaring på tværs af kunder, PDF-rapporter og integration med Prometheus, Grafana og SCOM, plus MSI-installer, community-websted og Discourse-forum. Se [installation](docs/installation.md), [opdateringer](docs/opdatering.md), [integration](docs/integration.md), [rapporter](docs/rapporter.md), [egne regler](docs/regler.md), [databehandling](docs/databehandling.md), [community](docs/community.md), [Roadmap](#roadmap) og [CONTRIBUTING.md](CONTRIBUTING.md).
|
> **Status:** Fase 1–4 er implementeret: agent, 75 indbyggede regler plus community-regler, notifikationer, lokalt repository og web-UI, licenser, signerede opdateringer, SQLInsight Hub med portal, erfaring på tværs af kunder, PDF-rapporter og integration med Prometheus, Grafana og SCOM, plus MSI-installer, community-websted og Discourse-forum. Se [installation](docs/installation.md), [opdateringer](docs/opdatering.md), [integration](docs/integration.md), [rapporter](docs/rapporter.md), [egne regler](docs/regler.md), [databehandling](docs/databehandling.md), [community](docs/community.md), [Roadmap](#roadmap) og [CONTRIBUTING.md](CONTRIBUTING.md).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -105,3 +105,17 @@ Agenten husker varigheden af de seneste 10 kørsler af samme kommando på samme
|
||||||
Kolonnen **Aktivitet** under tungeste forespørgsler er sætningens CPU-tid som andel af tidsrummet: 100 % betyder, at den har holdt én CPU-kerne beskæftiget hele tiden (over 100 % er parallelt).
|
Kolonnen **Aktivitet** under tungeste forespørgsler er sætningens CPU-tid som andel af tidsrummet: 100 % betyder, at den har holdt én CPU-kerne beskæftiget hele tiden (over 100 % er parallelt).
|
||||||
|
|
||||||
Reglen **SQLI-CPU-002** (Middel) ser på sætninger, der har ligget i plan cache i mindst en time og stadig kører: er deres samlede CPU-tid over 50 % af tiden i cache, bruger de i praksis en CPU-kerne konstant. Grænsen sættes med `SQLInsight:TopSql:ActivityThresholdPercent` (standard 50). SQLInsight tømmer aldrig plan cache eller ændrer noget; fundet peger kun på sætningen.
|
Reglen **SQLI-CPU-002** (Middel) ser på sætninger, der har ligget i plan cache i mindst en time og stadig kører: er deres samlede CPU-tid over 50 % af tiden i cache, bruger de i praksis en CPU-kerne konstant. Grænsen sættes med `SQLInsight:TopSql:ActivityThresholdPercent` (standard 50). SQLInsight tømmer aldrig plan cache eller ændrer noget; fundet peger kun på sætningen.
|
||||||
|
|
||||||
|
## Åbne transaktioner og lange sætninger
|
||||||
|
|
||||||
|
Hvert minut ser agenten på brugertransaktioner (`sys.dm_tran_*`) og kørende sætninger (`sys.dm_exec_requests`). Agentens egne sessioner og systemsessioner tages ikke med.
|
||||||
|
|
||||||
|
- **Lige nu** på instanssiden viser åbne transaktioner (alder, database, log brugt og reserveret, session, ventetype og sætning) og sætninger, der har kørt over grænsen.
|
||||||
|
- **Lange og store transaktioner** i analysen af et tidsrum (og PDF'en) viser transaktioner, der var åbne mindst et minut eller holdt over 100 MB log. De gemmes i `RawDays`.
|
||||||
|
- Login, vært, program og sætning er kundens data: de vises lokalt og er `[udeladt]` i API'et (`GET /api/v1/instances/{navn}/activity` og analysen).
|
||||||
|
|
||||||
|
| Regel | Alvor | Hvornår |
|
||||||
|
|---|---|---|
|
||||||
|
| SQLI-TRAN-001 | Middel, Høj ved 6 × grænsen | En transaktion har været åben over `SQLInsight:Activity:TransactionMinutes` (standard 10 minutter) |
|
||||||
|
| SQLI-TRAN-002 | Middel | En transaktion holder over `TransactionLogMegabytes` (standard 1.024 MB) log |
|
||||||
|
| SQLI-REQ-001 | Middel | En brugersætning har kørt over `RequestMinutes` (standard 15 minutter). BACKUP, RESTORE og DBCC (langvarige operationer) og sætninger, der venter med vilje (WAITFOR, Service Broker), tælles ikke |
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,8 @@ public sealed class AgentOptions
|
||||||
|
|
||||||
public ErrorLogOptions ErrorLog { get; set; } = new();
|
public ErrorLogOptions ErrorLog { get; set; } = new();
|
||||||
|
|
||||||
|
public ActivityOptions Activity { get; set; } = new();
|
||||||
|
|
||||||
public IList<MonitoredInstance> Instances { get; } = [];
|
public IList<MonitoredInstance> Instances { get; } = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -95,3 +97,16 @@ public sealed class ErrorLogOptions
|
||||||
/// <summary>Failed logins within one collection (5 minutes) before it is a finding (SQLI-LOG-004).</summary>
|
/// <summary>Failed logins within one collection (5 minutes) before it is a finding (SQLI-LOG-004).</summary>
|
||||||
public int LoginFailureBurst { get; set; } = 20;
|
public int LoginFailureBurst { get; set; } = 20;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Open transactions and long-running statements.</summary>
|
||||||
|
public sealed class ActivityOptions
|
||||||
|
{
|
||||||
|
/// <summary>A transaction open longer than this (minutes) is a finding (SQLI-TRAN-001); six times as long is High.</summary>
|
||||||
|
public int TransactionMinutes { get; set; } = 10;
|
||||||
|
|
||||||
|
/// <summary>A transaction holding more transaction log than this (MB) is a finding (SQLI-TRAN-002).</summary>
|
||||||
|
public int TransactionLogMegabytes { get; set; } = 1024;
|
||||||
|
|
||||||
|
/// <summary>A user statement running longer than this (minutes) is a finding (SQLI-REQ-001).</summary>
|
||||||
|
public int RequestMinutes { get; set; } = 15;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -32,7 +32,8 @@ public sealed partial class MonitoringService(
|
||||||
|
|
||||||
var intervals = new Dictionary<string, TimeSpan>(settings.CollectorIntervals, StringComparer.OrdinalIgnoreCase);
|
var intervals = new Dictionary<string, TimeSpan>(settings.CollectorIntervals, StringComparer.OrdinalIgnoreCase);
|
||||||
var monitors = settings.Instances
|
var monitors = settings.Instances
|
||||||
.Select(i => new InstanceMonitor(i, BuiltInCollectors.Create(customRules, new CollectorSettings(groupResolver, settings.Privileges.MaxSysadminAccounts, settings.TopSql.ActivityThresholdPercent, settings.ErrorLog.LoginFailureBurst)), intervals, connectionFactory, ruleEngine, repository,
|
.Select(i => new InstanceMonitor(i, BuiltInCollectors.Create(customRules, new CollectorSettings(groupResolver, settings.Privileges.MaxSysadminAccounts, settings.TopSql.ActivityThresholdPercent, settings.ErrorLog.LoginFailureBurst,
|
||||||
|
settings.Activity.TransactionMinutes, settings.Activity.TransactionLogMegabytes, settings.Activity.RequestMinutes)), intervals, connectionFactory, ruleEngine, repository,
|
||||||
loggerFactory.CreateLogger($"SQLInsight.Instance.{i.Name}"), notifications, notificationOptions.Value.WebUrl, hubOutbox))
|
loggerFactory.CreateLogger($"SQLInsight.Instance.{i.Name}"), notifications, notificationOptions.Value.WebUrl, hubOutbox))
|
||||||
.ToList();
|
.ToList();
|
||||||
if (monitors.Count == 0)
|
if (monitors.Count == 0)
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,7 @@ public static class BuiltInCollectors
|
||||||
new PerformanceCounterCollector(),
|
new PerformanceCounterCollector(),
|
||||||
new CpuCollector(),
|
new CpuCollector(),
|
||||||
new BlockingCollector(),
|
new BlockingCollector(),
|
||||||
|
new ActivityCollector(settings.TransactionMinutes, settings.TransactionLogMegabytes, settings.RequestMinutes),
|
||||||
new DeadlockCollector(),
|
new DeadlockCollector(),
|
||||||
new QueryStoreCollector(),
|
new QueryStoreCollector(),
|
||||||
new PlanAnalysisCollector(),
|
new PlanAnalysisCollector(),
|
||||||
|
|
@ -47,8 +48,14 @@ public static class BuiltInCollectors
|
||||||
/// <param name="MaxSysadminAccounts">More people and accounts with sysadmin than this is a finding.</param>
|
/// <param name="MaxSysadminAccounts">More people and accounts with sysadmin than this is a finding.</param>
|
||||||
/// <param name="ActivityThresholdPercent">A statement using more CPU time than this share of its lifetime is a finding.</param>
|
/// <param name="ActivityThresholdPercent">A statement using more CPU time than this share of its lifetime is a finding.</param>
|
||||||
/// <param name="LoginFailureBurst">Failed logins within one collection (5 minutes) before it is a finding.</param>
|
/// <param name="LoginFailureBurst">Failed logins within one collection (5 minutes) before it is a finding.</param>
|
||||||
|
/// <param name="TransactionMinutes">An open transaction older than this is a finding.</param>
|
||||||
|
/// <param name="TransactionLogMegabytes">A transaction holding more log than this is a finding.</param>
|
||||||
|
/// <param name="RequestMinutes">A statement running longer than this is a finding.</param>
|
||||||
public sealed record CollectorSettings(
|
public sealed record CollectorSettings(
|
||||||
Core.Data.IGroupMemberResolver? Groups = null,
|
Core.Data.IGroupMemberResolver? Groups = null,
|
||||||
int MaxSysadminAccounts = 3,
|
int MaxSysadminAccounts = 3,
|
||||||
double ActivityThresholdPercent = 50,
|
double ActivityThresholdPercent = 50,
|
||||||
int LoginFailureBurst = 20);
|
int LoginFailureBurst = 20,
|
||||||
|
int TransactionMinutes = 10,
|
||||||
|
int TransactionLogMegabytes = 1024,
|
||||||
|
int RequestMinutes = 15);
|
||||||
|
|
|
||||||
80
src/SQLInsight.Collectors/Concurrency/ActivityCollector.cs
Normal file
80
src/SQLInsight.Collectors/Concurrency/ActivityCollector.cs
Normal file
|
|
@ -0,0 +1,80 @@
|
||||||
|
using System.Globalization;
|
||||||
|
using SQLInsight.Core.Data;
|
||||||
|
using SQLInsight.Core.Instances;
|
||||||
|
|
||||||
|
namespace SQLInsight.Collectors.Concurrency;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Open user transactions (age, transaction log held, session, statement) and user requests that have run longer
|
||||||
|
/// than the threshold, every minute. The agent's own sessions and system sessions are left out. VIEW SERVER STATE;
|
||||||
|
/// SQL Server 2005+.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class ActivityCollector(int transactionMinutes = 10, int transactionLogMegabytes = 1024, int requestMinutes = 15) : Collector<ActivitySnapshot>
|
||||||
|
{
|
||||||
|
private const string NotAgent = "s.is_user_process = 1 AND s.session_id <> @@SPID AND (s.program_name IS NULL OR s.program_name NOT LIKE N'SQLInsight Pro%')";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// One row per open transaction of a user session. The statement is the running one, or else the session's last.
|
||||||
|
/// Times are converted to UTC on the server.
|
||||||
|
/// </summary>
|
||||||
|
internal const string TransactionsSql = """
|
||||||
|
SELECT t.session_id, t.transaction_id, t.name, DATEADD(minute, DATEDIFF(minute, GETDATE(), GETUTCDATE()), t.begin_time),
|
||||||
|
t.used, t.reserved, t.db, t.dbs, s.login_name, s.host_name, s.program_name, s.status, r.wait_type,
|
||||||
|
CAST(CASE WHEN r.sql_handle IS NOT NULL
|
||||||
|
THEN SUBSTRING(q.text, r.statement_start_offset / 2 + 1,
|
||||||
|
CASE WHEN r.statement_end_offset = -1 THEN 1000 ELSE (r.statement_end_offset - r.statement_start_offset) / 2 + 1 END)
|
||||||
|
ELSE LEFT(q.text, 1000) END AS nvarchar(1000))
|
||||||
|
FROM (SELECT st.session_id, at.transaction_id, MAX(at.name) AS name, MIN(at.transaction_begin_time) AS begin_time,
|
||||||
|
ISNULL(SUM(dt.database_transaction_log_bytes_used), 0) AS used, ISNULL(SUM(dt.database_transaction_log_bytes_reserved), 0) AS reserved,
|
||||||
|
MIN(DB_NAME(dt.database_id)) AS db, COUNT(DISTINCT dt.database_id) AS dbs
|
||||||
|
FROM sys.dm_tran_session_transactions st
|
||||||
|
JOIN sys.dm_tran_active_transactions at ON at.transaction_id = st.transaction_id
|
||||||
|
LEFT JOIN sys.dm_tran_database_transactions dt ON dt.transaction_id = st.transaction_id AND dt.database_id <> 32767
|
||||||
|
GROUP BY st.session_id, at.transaction_id) t
|
||||||
|
JOIN sys.dm_exec_sessions s ON s.session_id = t.session_id
|
||||||
|
LEFT JOIN sys.dm_exec_requests r ON r.session_id = t.session_id
|
||||||
|
OUTER APPLY (SELECT TOP 1 c.most_recent_sql_handle FROM sys.dm_exec_connections c WHERE c.session_id = t.session_id) c
|
||||||
|
OUTER APPLY sys.dm_exec_sql_text(COALESCE(r.sql_handle, c.most_recent_sql_handle)) q
|
||||||
|
WHERE {notAgent} AND t.begin_time IS NOT NULL
|
||||||
|
""";
|
||||||
|
|
||||||
|
internal const string LongRequestsSql = """
|
||||||
|
SELECT r.session_id, DATEADD(minute, DATEDIFF(minute, GETDATE(), GETUTCDATE()), r.start_time), r.total_elapsed_time, r.command, r.status,
|
||||||
|
r.wait_type, r.wait_time, DB_NAME(r.database_id), s.login_name, s.host_name, s.program_name, r.blocking_session_id,
|
||||||
|
CAST(SUBSTRING(q.text, r.statement_start_offset / 2 + 1,
|
||||||
|
CASE WHEN r.statement_end_offset = -1 THEN 1000 ELSE (r.statement_end_offset - r.statement_start_offset) / 2 + 1 END) AS nvarchar(1000))
|
||||||
|
FROM sys.dm_exec_requests r
|
||||||
|
JOIN sys.dm_exec_sessions s ON s.session_id = r.session_id
|
||||||
|
OUTER APPLY sys.dm_exec_sql_text(r.sql_handle) q
|
||||||
|
WHERE {notAgent} AND r.total_elapsed_time > {thresholdMs} AND r.percent_complete = 0
|
||||||
|
""";
|
||||||
|
|
||||||
|
public override string Name => "activity";
|
||||||
|
public override TimeSpan DefaultInterval => TimeSpan.FromMinutes(1);
|
||||||
|
public override IReadOnlyCollection<Capability> RequiredCapabilities => [Capability.DynamicManagementViews];
|
||||||
|
|
||||||
|
protected override async Task<ActivitySnapshot> CollectAsync(CollectorContext context, ActivitySnapshot? previous)
|
||||||
|
{
|
||||||
|
var ct = context.CancellationToken;
|
||||||
|
var transactions = await Db.QueryAsync(context.Connection, TransactionsSql.Replace("{notAgent}", NotAgent, StringComparison.Ordinal), r => new OpenTransaction(
|
||||||
|
r.Int(0), r.Long(1), r.NullableString(2), Utc(r.DateTime(3)), r.Long(4), r.Long(5), r.NullableString(6), r.Int(7),
|
||||||
|
r.NullableString(8), r.NullableString(9), r.NullableString(10), r.NullableString(11), r.NullableString(12), r.NullableString(13)), ct).ConfigureAwait(false);
|
||||||
|
var thresholdMs = (long)requestMinutes * 60_000;
|
||||||
|
var sql = LongRequestsSql.Replace("{notAgent}", NotAgent, StringComparison.Ordinal)
|
||||||
|
.Replace("{thresholdMs}", thresholdMs.ToString(CultureInfo.InvariantCulture), StringComparison.Ordinal);
|
||||||
|
var requests = await Db.QueryAsync(context.Connection, sql, r => new LongRunningRequest(
|
||||||
|
r.Int(0), Utc(r.DateTime(1)), r.Long(2), r.String(3).Trim(), r.NullableString(4), r.NullableString(5), r.Long(6), r.NullableString(7),
|
||||||
|
r.NullableString(8), r.NullableString(9), r.NullableString(10), r.NullableInt(11) ?? 0, r.NullableString(12)), ct).ConfigureAwait(false);
|
||||||
|
return new ActivitySnapshot
|
||||||
|
{
|
||||||
|
CapturedAt = context.Now,
|
||||||
|
Transactions = transactions,
|
||||||
|
LongRequests = requests.Where(ActivityAnalysis.IsStatement).ToList(),
|
||||||
|
TransactionMinutes = transactionMinutes,
|
||||||
|
TransactionLogMegabytes = transactionLogMegabytes,
|
||||||
|
RequestMinutes = requestMinutes,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static DateTimeOffset Utc(DateTime value) => new(DateTime.SpecifyKind(value, DateTimeKind.Utc));
|
||||||
|
}
|
||||||
86
src/SQLInsight.Core/Data/Activity.cs
Normal file
86
src/SQLInsight.Core/Data/Activity.cs
Normal file
|
|
@ -0,0 +1,86 @@
|
||||||
|
namespace SQLInsight.Core.Data;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// An open user transaction: how long it has been open and how much transaction log it holds. Login, host, program
|
||||||
|
/// and statement are sensitive. Times are UTC.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record OpenTransaction(
|
||||||
|
int SessionId,
|
||||||
|
long TransactionId,
|
||||||
|
string? Name,
|
||||||
|
DateTimeOffset BeganAt,
|
||||||
|
long LogBytesUsed,
|
||||||
|
long LogBytesReserved,
|
||||||
|
string? DatabaseName,
|
||||||
|
int Databases,
|
||||||
|
string? LoginName,
|
||||||
|
string? HostName,
|
||||||
|
string? ProgramName,
|
||||||
|
string? SessionStatus,
|
||||||
|
string? WaitType,
|
||||||
|
string? Statement)
|
||||||
|
{
|
||||||
|
public TimeSpan Age(DateTimeOffset now) => now - BeganAt;
|
||||||
|
|
||||||
|
public double LogMegabytes => (LogBytesUsed + LogBytesReserved) / 1024.0 / 1024.0;
|
||||||
|
|
||||||
|
/// <summary>Identifies the transaction across samples.</summary>
|
||||||
|
public string Key => $"{SessionId}|{TransactionId}|{BeganAt.UtcTicks}";
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>A user request that has been running for a long time right now. Login, host, program and statement are sensitive.</summary>
|
||||||
|
public sealed record LongRunningRequest(
|
||||||
|
int SessionId,
|
||||||
|
DateTimeOffset StartedAt,
|
||||||
|
long ElapsedMs,
|
||||||
|
string Command,
|
||||||
|
string? Status,
|
||||||
|
string? WaitType,
|
||||||
|
long WaitTimeMs,
|
||||||
|
string? DatabaseName,
|
||||||
|
string? LoginName,
|
||||||
|
string? HostName,
|
||||||
|
string? ProgramName,
|
||||||
|
int BlockingSessionId,
|
||||||
|
string? Statement);
|
||||||
|
|
||||||
|
/// <summary>Open transactions and long-running requests, every minute.</summary>
|
||||||
|
public sealed record ActivitySnapshot
|
||||||
|
{
|
||||||
|
public required DateTimeOffset CapturedAt { get; init; }
|
||||||
|
public IReadOnlyList<OpenTransaction> Transactions { get; init; } = [];
|
||||||
|
public IReadOnlyList<LongRunningRequest> LongRequests { get; init; } = [];
|
||||||
|
|
||||||
|
/// <summary>A transaction open longer than this is a finding (SQLInsight:Activity:TransactionMinutes).</summary>
|
||||||
|
public int TransactionMinutes { get; init; } = 10;
|
||||||
|
|
||||||
|
/// <summary>A transaction holding more log than this is a finding (SQLInsight:Activity:TransactionLogMegabytes).</summary>
|
||||||
|
public int TransactionLogMegabytes { get; init; } = 1024;
|
||||||
|
|
||||||
|
/// <summary>A statement running longer than this is a finding (SQLInsight:Activity:RequestMinutes).</summary>
|
||||||
|
public int RequestMinutes { get; init; } = 15;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class ActivityAnalysis
|
||||||
|
{
|
||||||
|
/// <summary>Transactions worth keeping in the history: open a minute or more, or holding 100 MB of log.</summary>
|
||||||
|
public static bool IsWorthKeeping(OpenTransaction transaction, DateTimeOffset now)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(transaction);
|
||||||
|
return transaction.Age(now) >= TimeSpan.FromMinutes(1) || transaction.LogMegabytes >= 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Requests that wait on purpose (WAITFOR, Service Broker queues) or are maintenance with its own progress
|
||||||
|
/// (BACKUP, RESTORE, DBCC: see long operations) are not long-running statements.
|
||||||
|
/// </summary>
|
||||||
|
public static bool IsStatement(LongRunningRequest request)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(request);
|
||||||
|
var command = request.Command.Trim();
|
||||||
|
return !command.StartsWith("BACKUP", StringComparison.OrdinalIgnoreCase)
|
||||||
|
&& !command.StartsWith("RESTORE", StringComparison.OrdinalIgnoreCase)
|
||||||
|
&& !command.StartsWith("DBCC", StringComparison.OrdinalIgnoreCase)
|
||||||
|
&& !(request.WaitType is { } wait && WaitCatalog.IsBenign(wait));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -26,6 +26,9 @@ public sealed record TimeWindowAnalysis
|
||||||
/// <summary>Backup, restore, DBCC and other operations with progress that ran in the window.</summary>
|
/// <summary>Backup, restore, DBCC and other operations with progress that ran in the window.</summary>
|
||||||
public IReadOnlyList<StoredLongOperation> LongOperations { get; init; } = [];
|
public IReadOnlyList<StoredLongOperation> LongOperations { get; init; } = [];
|
||||||
|
|
||||||
|
/// <summary>User transactions open a minute or more (or holding much log) in the window. Statements and logins are customer data.</summary>
|
||||||
|
public IReadOnlyList<StoredTransaction> LongTransactions { get; init; } = [];
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// A statement's CPU time as a share of the window, in percent of one CPU core: 100 means one core all the time.
|
/// A statement's CPU time as a share of the window, in percent of one CPU core: 100 means one core all the time.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|
@ -55,7 +58,7 @@ public sealed record TimeWindowAnalysis
|
||||||
metrics.FirstOrDefault(m => m.Metric == metric)?.Points ?? [];
|
metrics.FirstOrDefault(m => m.Metric == metric)?.Points ?? [];
|
||||||
|
|
||||||
public bool HasData => Metrics.Any(m => m.Points.Count > 0) || Waits.Count > 0 || Files.Count > 0 || Disks.Count > 0 || Blocking.Count > 0
|
public bool HasData => Metrics.Any(m => m.Points.Count > 0) || Waits.Count > 0 || Files.Count > 0 || Disks.Count > 0 || Blocking.Count > 0
|
||||||
|| Deadlocks.Count > 0 || TopQueries.Count > 0 || LongOperations.Count > 0;
|
|| Deadlocks.Count > 0 || TopQueries.Count > 0 || LongOperations.Count > 0 || LongTransactions.Count > 0;
|
||||||
|
|
||||||
/// <summary>The metrics shown, in order: key, label, unit, decimals, lower-is-worse, fixed maximum.</summary>
|
/// <summary>The metrics shown, in order: key, label, unit, decimals, lower-is-worse, fixed maximum.</summary>
|
||||||
public static readonly IReadOnlyList<(string Metric, string Label, string Unit, int Decimals, bool LowerIsWorse, double? Max)> MetricDefinitions =
|
public static readonly IReadOnlyList<(string Metric, string Label, string Unit, int Decimals, bool LowerIsWorse, double? Max)> MetricDefinitions =
|
||||||
|
|
@ -139,6 +142,7 @@ public sealed record TimeWindowAnalysis
|
||||||
Blocking = await repository.GetBlockingAsync(id, from, to, cancellationToken).ConfigureAwait(false),
|
Blocking = await repository.GetBlockingAsync(id, from, to, cancellationToken).ConfigureAwait(false),
|
||||||
Deadlocks = await repository.GetDeadlocksAsync(id, from, to, cancellationToken).ConfigureAwait(false),
|
Deadlocks = await repository.GetDeadlocksAsync(id, from, to, cancellationToken).ConfigureAwait(false),
|
||||||
LongOperations = await repository.GetLongOperationsAsync(id, from, to, 100, cancellationToken).ConfigureAwait(false),
|
LongOperations = await repository.GetLongOperationsAsync(id, from, to, 100, cancellationToken).ConfigureAwait(false),
|
||||||
|
LongTransactions = await repository.GetLongTransactionsAsync(id, from, to, 100, cancellationToken).ConfigureAwait(false),
|
||||||
Findings = await repository.GetFindingsActiveDuringAsync(id, from, to, cancellationToken).ConfigureAwait(false),
|
Findings = await repository.GetFindingsActiveDuringAsync(id, from, to, cancellationToken).ConfigureAwait(false),
|
||||||
QueryOrder = order,
|
QueryOrder = order,
|
||||||
TopQueries = await repository.GetTopQueriesAsync(id, from, to, order, 25, cancellationToken).ConfigureAwait(false),
|
TopQueries = await repository.GetTopQueriesAsync(id, from, to, order, 25, cancellationToken).ConfigureAwait(false),
|
||||||
|
|
|
||||||
|
|
@ -68,6 +68,7 @@ public static class TimeWindowReport
|
||||||
Disks(section, analysis);
|
Disks(section, analysis);
|
||||||
Files(section, analysis);
|
Files(section, analysis);
|
||||||
LongOperations(section, analysis, zone);
|
LongOperations(section, analysis, zone);
|
||||||
|
LongTransactions(section, analysis, zone);
|
||||||
Blocking(section, analysis, zone);
|
Blocking(section, analysis, zone);
|
||||||
Deadlocks(section, analysis, zone);
|
Deadlocks(section, analysis, zone);
|
||||||
Findings(section, analysis);
|
Findings(section, analysis);
|
||||||
|
|
@ -221,6 +222,23 @@ public static class TimeWindowReport
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static void LongTransactions(Section section, TimeWindowAnalysis analysis, TimeZoneInfo zone)
|
||||||
|
{
|
||||||
|
if (analysis.LongTransactions.Count == 0)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
section.AddParagraph("Lange og store transaktioner", StyleNames.Heading1);
|
||||||
|
var table = Table(section, [3.4, 2.6, 4.8, 2.6, 2.4]);
|
||||||
|
Header(table, "Åben siden", "Varighed", "Database", "Log", "Session");
|
||||||
|
foreach (var t in analysis.LongTransactions.Take(20))
|
||||||
|
{
|
||||||
|
Row(table, false, Time(new DateTimeOffset(t.BeganAt, TimeSpan.Zero), zone), Number(t.Duration.TotalMinutes, 0) + " min", t.DatabaseName ?? "–",
|
||||||
|
Number(t.MaxLogBytes / 1024.0 / 1024.0, 0) + " MB", t.SessionId.ToString(Danish));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private static void Blocking(Section section, TimeWindowAnalysis analysis, TimeZoneInfo zone)
|
private static void Blocking(Section section, TimeWindowAnalysis analysis, TimeZoneInfo zone)
|
||||||
{
|
{
|
||||||
section.AddParagraph("Blocking", StyleNames.Heading1);
|
section.AddParagraph("Blocking", StyleNames.Heading1);
|
||||||
|
|
|
||||||
67
src/SQLInsight.Repository/InsightRepository.Activity.cs
Normal file
67
src/SQLInsight.Repository/InsightRepository.Activity.cs
Normal file
|
|
@ -0,0 +1,67 @@
|
||||||
|
using System.Globalization;
|
||||||
|
using System.Text.Json;
|
||||||
|
using Dapper;
|
||||||
|
using Microsoft.Data.SqlClient;
|
||||||
|
using SQLInsight.Core.Data;
|
||||||
|
|
||||||
|
namespace SQLInsight.Repository;
|
||||||
|
|
||||||
|
/// <summary>A long or large transaction as stored. Login, host, program and statement are sensitive (local UI only).</summary>
|
||||||
|
public sealed record StoredTransaction(int SessionId, long TransactionId, DateTime BeganAt, string? DatabaseName, string? LoginName, string? HostName,
|
||||||
|
string? ProgramName, long MaxLogBytes, string? Statement, DateTime LastSeenAt)
|
||||||
|
{
|
||||||
|
public TimeSpan Duration => LastSeenAt - BeganAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed partial class InsightRepository
|
||||||
|
{
|
||||||
|
private static async Task SaveActivityAsync(SqlConnection connection, int instanceId, ActivitySnapshot snapshot)
|
||||||
|
{
|
||||||
|
var kept = snapshot.Transactions.Where(t => ActivityAnalysis.IsWorthKeeping(t, snapshot.CapturedAt)).ToList();
|
||||||
|
if (kept.Count == 0)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await connection.ExecuteAsync("""
|
||||||
|
MERGE insight.LongTransactions AS t
|
||||||
|
USING (SELECT * FROM OPENJSON(@json) WITH (SessionId int, TransactionId bigint, BeganAt datetime2(0), DatabaseName nvarchar(128),
|
||||||
|
LoginName nvarchar(128), HostName nvarchar(128), ProgramName nvarchar(128), LogBytes bigint, Statement nvarchar(1000))) AS s
|
||||||
|
ON t.InstanceId = @instanceId AND t.BeganAt = s.BeganAt AND t.SessionId = s.SessionId AND t.TransactionId = s.TransactionId
|
||||||
|
WHEN MATCHED THEN UPDATE SET LastSeenAt = @at, MaxLogBytes = CASE WHEN s.LogBytes > t.MaxLogBytes THEN s.LogBytes ELSE t.MaxLogBytes END,
|
||||||
|
Statement = COALESCE(s.Statement, t.Statement)
|
||||||
|
WHEN NOT MATCHED THEN INSERT (InstanceId, SessionId, TransactionId, BeganAt, DatabaseName, LoginName, HostName, ProgramName, MaxLogBytes, Statement, LastSeenAt)
|
||||||
|
VALUES (@instanceId, s.SessionId, s.TransactionId, s.BeganAt, s.DatabaseName, s.LoginName, s.HostName, s.ProgramName, s.LogBytes, s.Statement, @at);
|
||||||
|
""", new
|
||||||
|
{
|
||||||
|
instanceId,
|
||||||
|
at = Utc(snapshot.CapturedAt),
|
||||||
|
json = JsonSerializer.Serialize(kept.Select(t => new
|
||||||
|
{
|
||||||
|
t.SessionId,
|
||||||
|
t.TransactionId,
|
||||||
|
BeganAt = Utc(t.BeganAt).ToString("yyyy-MM-ddTHH:mm:ss", CultureInfo.InvariantCulture),
|
||||||
|
t.DatabaseName,
|
||||||
|
t.LoginName,
|
||||||
|
t.HostName,
|
||||||
|
t.ProgramName,
|
||||||
|
LogBytes = t.LogBytesUsed + t.LogBytesReserved,
|
||||||
|
t.Statement,
|
||||||
|
}), Json),
|
||||||
|
}).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Long or large transactions that were open at some point in [from, to], longest first.</summary>
|
||||||
|
public async Task<IReadOnlyList<StoredTransaction>> GetLongTransactionsAsync(int instanceId, DateTimeOffset from, DateTimeOffset to, int top = 100,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
var rows = await connection.QueryAsync<StoredTransaction>("""
|
||||||
|
SELECT TOP (@top) SessionId, TransactionId, BeganAt, DatabaseName, LoginName, HostName, ProgramName, MaxLogBytes, Statement, LastSeenAt
|
||||||
|
FROM insight.LongTransactions
|
||||||
|
WHERE InstanceId = @instanceId AND BeganAt <= @to AND LastSeenAt >= @from
|
||||||
|
ORDER BY DATEDIFF(second, BeganAt, LastSeenAt) DESC;
|
||||||
|
""", new { instanceId, from = from.UtcDateTime, to = to.UtcDateTime, top }).ConfigureAwait(false);
|
||||||
|
return rows.ToList();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -84,6 +84,7 @@ public sealed partial class InsightRepository
|
||||||
await DeleteInBatchesAsync(connection, "insight.QueryStatSamples", "SampledAt < @cutoff", raw).ConfigureAwait(false);
|
await DeleteInBatchesAsync(connection, "insight.QueryStatSamples", "SampledAt < @cutoff", raw).ConfigureAwait(false);
|
||||||
await DeleteInBatchesAsync(connection, "insight.ActiveWaitSamples", "SampledAt < @cutoff", raw).ConfigureAwait(false);
|
await DeleteInBatchesAsync(connection, "insight.ActiveWaitSamples", "SampledAt < @cutoff", raw).ConfigureAwait(false);
|
||||||
await DeleteInBatchesAsync(connection, "insight.LongOperations", "LastSeenAt < @cutoff", raw).ConfigureAwait(false);
|
await DeleteInBatchesAsync(connection, "insight.LongOperations", "LastSeenAt < @cutoff", raw).ConfigureAwait(false);
|
||||||
|
await DeleteInBatchesAsync(connection, "insight.LongTransactions", "LastSeenAt < @cutoff", raw).ConfigureAwait(false);
|
||||||
await DeleteInBatchesAsync(connection, "insight.QueryTexts", "LastSeenAt < @cutoff", raw).ConfigureAwait(false);
|
await DeleteInBatchesAsync(connection, "insight.QueryTexts", "LastSeenAt < @cutoff", raw).ConfigureAwait(false);
|
||||||
await DeleteInBatchesAsync(connection, "insight.MetricHourly", "[Hour] < @cutoff", hourly).ConfigureAwait(false);
|
await DeleteInBatchesAsync(connection, "insight.MetricHourly", "[Hour] < @cutoff", hourly).ConfigureAwait(false);
|
||||||
await DeleteInBatchesAsync(connection, "insight.WaitHourly", "[Hour] < @cutoff", hourly).ConfigureAwait(false);
|
await DeleteInBatchesAsync(connection, "insight.WaitHourly", "[Hour] < @cutoff", hourly).ConfigureAwait(false);
|
||||||
|
|
|
||||||
|
|
@ -268,6 +268,10 @@ public sealed partial class InsightRepository(string connectionString)
|
||||||
await SaveErrorLogAsync(connection, instanceId, errorLog).ConfigureAwait(false);
|
await SaveErrorLogAsync(connection, instanceId, errorLog).ConfigureAwait(false);
|
||||||
await SaveSnapshotJsonAsync(connection, instanceId, kind, data).ConfigureAwait(false);
|
await SaveSnapshotJsonAsync(connection, instanceId, kind, data).ConfigureAwait(false);
|
||||||
return;
|
return;
|
||||||
|
case ActivitySnapshot activity:
|
||||||
|
await SaveActivityAsync(connection, instanceId, activity).ConfigureAwait(false);
|
||||||
|
await SaveSnapshotJsonAsync(connection, instanceId, kind, data).ConfigureAwait(false);
|
||||||
|
return;
|
||||||
case PrivilegeSnapshot privileges:
|
case PrivilegeSnapshot privileges:
|
||||||
await SavePrivilegeChangesAsync(connection, instanceId, privileges.Changes).ConfigureAwait(false);
|
await SavePrivilegeChangesAsync(connection, instanceId, privileges.Changes).ConfigureAwait(false);
|
||||||
await SaveLatePrivilegeAuthorsAsync(connection, instanceId, privileges.RecentChanges).ConfigureAwait(false);
|
await SaveLatePrivilegeAuthorsAsync(connection, instanceId, privileges.RecentChanges).ConfigureAwait(false);
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,17 @@
|
||||||
|
/* User transactions open a minute or more (or holding 100 MB of log): one row per transaction, updated while it is
|
||||||
|
open. Login, host, program and statement are sensitive (local only). Kept as long as the other raw samples. */
|
||||||
|
CREATE TABLE insight.LongTransactions
|
||||||
|
(
|
||||||
|
InstanceId int NOT NULL,
|
||||||
|
SessionId int NOT NULL,
|
||||||
|
TransactionId bigint NOT NULL,
|
||||||
|
BeganAt datetime2(0) NOT NULL,
|
||||||
|
DatabaseName nvarchar(128) NULL,
|
||||||
|
LoginName nvarchar(128) NULL,
|
||||||
|
HostName nvarchar(128) NULL,
|
||||||
|
ProgramName nvarchar(128) NULL,
|
||||||
|
MaxLogBytes bigint NOT NULL,
|
||||||
|
Statement nvarchar(1000) NULL,
|
||||||
|
LastSeenAt datetime2(0) NOT NULL,
|
||||||
|
CONSTRAINT PK_LongTransactions PRIMARY KEY (InstanceId, BeganAt, SessionId, TransactionId)
|
||||||
|
);
|
||||||
|
|
@ -63,6 +63,9 @@ public static class BuiltInRules
|
||||||
new LoginFailureBurstRule(),
|
new LoginFailureBurstRule(),
|
||||||
new StackDumpRule(),
|
new StackDumpRule(),
|
||||||
new AvailabilityGroupErrorRule(),
|
new AvailabilityGroupErrorRule(),
|
||||||
|
new LongTransactionRule(),
|
||||||
|
new LargeTransactionRule(),
|
||||||
|
new LongRunningStatementRule(),
|
||||||
new MemoryGrantsPendingRule(),
|
new MemoryGrantsPendingRule(),
|
||||||
new AnomalyRule(),
|
new AnomalyRule(),
|
||||||
new PlanRegressionRule(),
|
new PlanRegressionRule(),
|
||||||
|
|
|
||||||
127
src/SQLInsight.Rules/Concurrency/ActivityRules.cs
Normal file
127
src/SQLInsight.Rules/Concurrency/ActivityRules.cs
Normal file
|
|
@ -0,0 +1,127 @@
|
||||||
|
using System.Globalization;
|
||||||
|
using SQLInsight.Core.Data;
|
||||||
|
using SQLInsight.Core.Findings;
|
||||||
|
using SQLInsight.Core.Instances;
|
||||||
|
using SQLInsight.Rules.Engine;
|
||||||
|
|
||||||
|
namespace SQLInsight.Rules.Concurrency;
|
||||||
|
|
||||||
|
internal static class ActivityEvidence
|
||||||
|
{
|
||||||
|
public static string Utc(DateTimeOffset value) => value.UtcDateTime.ToString("yyyy-MM-dd HH:mm 'UTC'", CultureInfo.InvariantCulture);
|
||||||
|
|
||||||
|
public static string Minutes(TimeSpan value) => value.TotalHours >= 2
|
||||||
|
? $"{Format.Number(value.TotalHours, 1)} timer"
|
||||||
|
: $"{Format.Number(value.TotalMinutes)} minutter";
|
||||||
|
|
||||||
|
public static IEnumerable<Evidence> Session(int sessionId, string? login, string? host, string? program, string? statement) =>
|
||||||
|
[
|
||||||
|
new Evidence("Session", sessionId.ToString(CultureInfo.InvariantCulture)),
|
||||||
|
new Evidence("Login", login ?? "–", Sensitive: true),
|
||||||
|
new Evidence("Vært", host ?? "–", Sensitive: true),
|
||||||
|
new Evidence("Program", program ?? "–", Sensitive: true),
|
||||||
|
new Evidence("Sætning", statement is { Length: > 500 } s ? s[..500] + " …" : statement ?? "–", Sensitive: true),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A user transaction that has been open a long time: it holds its locks and keeps the log (and the version store)
|
||||||
|
/// from being truncated. Often a user who left a dialog open, or a program that forgot to commit.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class LongTransactionRule : Rule
|
||||||
|
{
|
||||||
|
public override string Id => "SQLI-TRAN-001";
|
||||||
|
public override string Title => "Transaktion har været åben længe";
|
||||||
|
public override IReadOnlyCollection<Capability> RequiredCapabilities => [Capability.DynamicManagementViews];
|
||||||
|
|
||||||
|
public override RuleEvaluation Evaluate(RuleContext context)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(context);
|
||||||
|
var snapshot = context.Data.Get<ActivitySnapshot>();
|
||||||
|
var limit = TimeSpan.FromMinutes(snapshot.TransactionMinutes);
|
||||||
|
return Result(snapshot.Transactions
|
||||||
|
.Where(t => t.Age(snapshot.CapturedAt) >= limit)
|
||||||
|
.OrderByDescending(t => t.Age(snapshot.CapturedAt))
|
||||||
|
.Take(10)
|
||||||
|
.Select(t => CreateFinding(
|
||||||
|
t.Age(snapshot.CapturedAt) >= limit * 6 ? Severity.High : Severity.Medium,
|
||||||
|
$"Transaktion i {t.DatabaseName ?? "en database"} har været åben i {ActivityEvidence.Minutes(t.Age(snapshot.CapturedAt))}",
|
||||||
|
"En åben transaktion holder sine låse, så andre kan blive blokeret, og den forhindrer, at transaktionsloggen (og tempdb's version store) "
|
||||||
|
+ "kan genbruges, så de vokser. Det skyldes typisk en bruger, der har en dialog åben midt i en ændring, eller et program, der har glemt COMMIT.",
|
||||||
|
"Find sessionen (sp_who2 eller Aktivitetsmonitor) og programmet bag. Afslut transaktionen i programmet, eller luk sessionen med KILL, "
|
||||||
|
+ "hvis ejeren er enig (det ruller ændringerne tilbage, hvilket kan tage lige så lang tid). Ret programmet, så transaktioner holdes korte.",
|
||||||
|
[
|
||||||
|
new Evidence("Åben siden", ActivityEvidence.Utc(t.BeganAt)),
|
||||||
|
new Evidence("Database", t.DatabaseName ?? "–"),
|
||||||
|
new Evidence("Log brugt og reserveret", $"{Format.Number(t.LogMegabytes)} MB"),
|
||||||
|
new Evidence("Status", t.SessionStatus ?? "–"),
|
||||||
|
.. ActivityEvidence.Session(t.SessionId, t.LoginName, t.HostName, t.ProgramName, t.Statement),
|
||||||
|
],
|
||||||
|
subject: $"transaction: {t.SessionId}/{t.BeganAt.UtcDateTime:yyyyMMddHHmmss}")));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>A transaction holding a lot of transaction log: it cannot be truncated until the transaction ends.</summary>
|
||||||
|
public sealed class LargeTransactionRule : Rule
|
||||||
|
{
|
||||||
|
public override string Id => "SQLI-TRAN-002";
|
||||||
|
public override string Title => "Transaktion bruger meget transaktionslog";
|
||||||
|
public override IReadOnlyCollection<Capability> RequiredCapabilities => [Capability.DynamicManagementViews];
|
||||||
|
|
||||||
|
public override RuleEvaluation Evaluate(RuleContext context)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(context);
|
||||||
|
var snapshot = context.Data.Get<ActivitySnapshot>();
|
||||||
|
return Result(snapshot.Transactions
|
||||||
|
.Where(t => t.LogMegabytes >= snapshot.TransactionLogMegabytes)
|
||||||
|
.OrderByDescending(t => t.LogMegabytes)
|
||||||
|
.Take(10)
|
||||||
|
.Select(t => CreateFinding(
|
||||||
|
Severity.Medium,
|
||||||
|
$"Transaktion i {t.DatabaseName ?? "en database"} bruger {Format.Number(t.LogMegabytes / 1024, 1)} GB transaktionslog",
|
||||||
|
"Al log, en transaktion har skrevet, skal blive i loggen, til den er afsluttet (og en eventuel rollback vil tage tilsvarende lang tid). "
|
||||||
|
+ "Store transaktioner får loggen til at vokse og kan fylde disken.",
|
||||||
|
"Find hvad sessionen gør. Store opdateringer og sletninger bør deles op i mindre batches med COMMIT imellem, "
|
||||||
|
+ "og indexvedligehold bør ikke køre som én transaktion over mange tabeller.",
|
||||||
|
[
|
||||||
|
new Evidence("Åben siden", ActivityEvidence.Utc(t.BeganAt)),
|
||||||
|
new Evidence("Database", t.DatabaseName ?? "–"),
|
||||||
|
new Evidence("Log brugt og reserveret", $"{Format.Number(t.LogMegabytes)} MB"),
|
||||||
|
.. ActivityEvidence.Session(t.SessionId, t.LoginName, t.HostName, t.ProgramName, t.Statement),
|
||||||
|
],
|
||||||
|
subject: $"transaction log: {t.SessionId}/{t.BeganAt.UtcDateTime:yyyyMMddHHmmss}")));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>A user statement that is still running after a long time (BACKUP, RESTORE and DBCC are long operations instead).</summary>
|
||||||
|
public sealed class LongRunningStatementRule : Rule
|
||||||
|
{
|
||||||
|
public override string Id => "SQLI-REQ-001";
|
||||||
|
public override string Title => "Sætning har kørt længe";
|
||||||
|
public override IReadOnlyCollection<Capability> RequiredCapabilities => [Capability.DynamicManagementViews];
|
||||||
|
|
||||||
|
public override RuleEvaluation Evaluate(RuleContext context)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(context);
|
||||||
|
var snapshot = context.Data.Get<ActivitySnapshot>();
|
||||||
|
return Result(snapshot.LongRequests
|
||||||
|
.OrderByDescending(r => r.ElapsedMs)
|
||||||
|
.Take(10)
|
||||||
|
.Select(r => CreateFinding(
|
||||||
|
Severity.Medium,
|
||||||
|
$"Sætning i {r.DatabaseName ?? "en database"} har kørt i {ActivityEvidence.Minutes(TimeSpan.FromMilliseconds(r.ElapsedMs))}",
|
||||||
|
$"En brugersætning har kørt i over {snapshot.RequestMinutes} minutter. Den bruger ressourcer og holder måske låse, som andre venter på. "
|
||||||
|
+ "Ventetypen fortæller, hvad den venter på lige nu.",
|
||||||
|
"Se sætningen og dens plan (sys.dm_exec_query_plan for sessionen). Er den blokeret (Blokeret af), så find head blocker. "
|
||||||
|
+ "Er det en rapport eller et job, der plejer at tage så lang tid, så kan grænsen hæves med SQLInsight:Activity:RequestMinutes.",
|
||||||
|
[
|
||||||
|
new Evidence("Startet", ActivityEvidence.Utc(r.StartedAt)),
|
||||||
|
new Evidence("Kommando", r.Command),
|
||||||
|
new Evidence("Database", r.DatabaseName ?? "–"),
|
||||||
|
new Evidence("Ventetype", r.WaitType ?? "kører (ingen ventetid)"),
|
||||||
|
new Evidence("Blokeret af", r.BlockingSessionId > 0 ? r.BlockingSessionId.ToString(CultureInfo.InvariantCulture) : "–"),
|
||||||
|
.. ActivityEvidence.Session(r.SessionId, r.LoginName, r.HostName, r.ProgramName, r.Statement),
|
||||||
|
],
|
||||||
|
subject: $"request: {r.SessionId}/{r.StartedAt.UtcDateTime:yyyyMMddHHmmss}")));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -46,6 +46,7 @@ public static class ApiV1Endpoints
|
||||||
api.MapGet("/instances/{name}/configuration", ConfigurationAsync).WithSummary("Konfiguration og ændringslog / Configuration and change log");
|
api.MapGet("/instances/{name}/configuration", ConfigurationAsync).WithSummary("Konfiguration og ændringslog / Configuration and change log");
|
||||||
api.MapGet("/instances/{name}/privileges", InstancePrivilegesAsync).WithSummary("Rettigheder og ændringer på en instans / Privileges on an instance");
|
api.MapGet("/instances/{name}/privileges", InstancePrivilegesAsync).WithSummary("Rettigheder og ændringer på en instans / Privileges on an instance");
|
||||||
api.MapGet("/privileges", AllPrivilegesAsync).WithSummary("Rettigheder og ændringer på alle instanser / Privileges on all instances");
|
api.MapGet("/privileges", AllPrivilegesAsync).WithSummary("Rettigheder og ændringer på alle instanser / Privileges on all instances");
|
||||||
|
api.MapGet("/instances/{name}/activity", ActivityAsync).WithSummary("Åbne transaktioner og lange sætninger lige nu / Open transactions and long-running statements");
|
||||||
api.MapGet("/instances/{name}/error-log", ErrorLogAsync).WithSummary("Vigtige hændelser i fejlloggen / Important error log events (days)");
|
api.MapGet("/instances/{name}/error-log", ErrorLogAsync).WithSummary("Vigtige hændelser i fejlloggen / Important error log events (days)");
|
||||||
api.MapGet("/instances/{name}/wait-types/{waitType}", WaitTypeAsync).WithSummary("En ventetype: forklaring, udvikling og sætninger / One wait type");
|
api.MapGet("/instances/{name}/wait-types/{waitType}", WaitTypeAsync).WithSummary("En ventetype: forklaring, udvikling og sætninger / One wait type");
|
||||||
}
|
}
|
||||||
|
|
@ -326,6 +327,11 @@ public static class ApiV1Endpoints
|
||||||
}),
|
}),
|
||||||
Deadlocks = a.Deadlocks.Select(d => new { d.DeadlockId, d.OccurredAt, Processes = d.Processes.Count, Graph = $"/deadlocks/{d.DeadlockId}.xdl" }),
|
Deadlocks = a.Deadlocks.Select(d => new { d.DeadlockId, d.OccurredAt, Processes = d.Processes.Count, Graph = $"/deadlocks/{d.DeadlockId}.xdl" }),
|
||||||
LongOperations = a.LongOperations.Select(o => new { o.Command, o.DatabaseName, LoginName = redact.Value(o.LoginName), o.StartedAt, o.LastSeenAt, o.PercentComplete, o.EstimatedEnd }),
|
LongOperations = a.LongOperations.Select(o => new { o.Command, o.DatabaseName, LoginName = redact.Value(o.LoginName), o.StartedAt, o.LastSeenAt, o.PercentComplete, o.EstimatedEnd }),
|
||||||
|
LongTransactions = a.LongTransactions.Select(t => new
|
||||||
|
{
|
||||||
|
t.SessionId, t.BeganAt, t.LastSeenAt, t.DatabaseName, LogMegabytes = t.MaxLogBytes / 1024.0 / 1024.0,
|
||||||
|
LoginName = redact.Value(t.LoginName), HostName = redact.Value(t.HostName), ProgramName = redact.Value(t.ProgramName), Statement = redact.Value(t.Statement),
|
||||||
|
}),
|
||||||
Findings = a.Findings.Select(f => FindingSummary(f, redact)),
|
Findings = a.Findings.Select(f => FindingSummary(f, redact)),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -494,6 +500,33 @@ public static class ApiV1Endpoints
|
||||||
return Results.Ok(AuditJson(audit, RedactorFor(options)));
|
return Results.Ok(AuditJson(audit, RedactorFor(options)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> ActivityAsync(string name, InsightRepository repository, IOptions<WebOptions> options, CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (await repository.GetInstanceAsync(name, ct).ConfigureAwait(false) is not { } instance)
|
||||||
|
{
|
||||||
|
return Results.NotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
var redact = RedactorFor(options);
|
||||||
|
var a = await repository.GetSnapshotAsync<ActivitySnapshot>(instance.InstanceId, "activity", ct).ConfigureAwait(false);
|
||||||
|
return Results.Ok(new
|
||||||
|
{
|
||||||
|
Instance = instance.Name,
|
||||||
|
MeasuredAt = a?.CapturedAt,
|
||||||
|
Thresholds = a is null ? null : new { a.TransactionMinutes, a.TransactionLogMegabytes, a.RequestMinutes },
|
||||||
|
Transactions = (a?.Transactions ?? []).Select(t => new
|
||||||
|
{
|
||||||
|
t.SessionId, t.BeganAt, AgeMinutes = t.Age(a!.CapturedAt).TotalMinutes, t.DatabaseName, t.Databases, t.LogMegabytes, t.SessionStatus, t.WaitType,
|
||||||
|
LoginName = redact.Value(t.LoginName), HostName = redact.Value(t.HostName), ProgramName = redact.Value(t.ProgramName), Statement = redact.Value(t.Statement),
|
||||||
|
}),
|
||||||
|
LongRequests = (a?.LongRequests ?? []).Select(r => new
|
||||||
|
{
|
||||||
|
r.SessionId, r.StartedAt, ElapsedMinutes = r.ElapsedMs / 60000.0, r.Command, r.Status, r.WaitType, r.DatabaseName, r.BlockingSessionId,
|
||||||
|
LoginName = redact.Value(r.LoginName), HostName = redact.Value(r.HostName), ProgramName = redact.Value(r.ProgramName), Statement = redact.Value(r.Statement),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
private static async Task<IResult> ErrorLogAsync(string name, InsightRepository repository, IOptions<WebOptions> options, CancellationToken ct, int? days = null)
|
private static async Task<IResult> ErrorLogAsync(string name, InsightRepository repository, IOptions<WebOptions> options, CancellationToken ct, int? days = null)
|
||||||
{
|
{
|
||||||
if (await repository.GetInstanceAsync(name, ct).ConfigureAwait(false) is not { } instance)
|
if (await repository.GetInstanceAsync(name, ct).ConfigureAwait(false) is not { } instance)
|
||||||
|
|
|
||||||
|
|
@ -155,6 +155,11 @@
|
||||||
<LongOperationTable Operations="_analysis.LongOperations" Empty="Ingen operationer med fremskridt i tidsrummet." />
|
<LongOperationTable Operations="_analysis.LongOperations" Empty="Ingen operationer med fremskridt i tidsrummet." />
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section class="card">
|
||||||
|
<div class="card-head"><h2>Lange og store transaktioner</h2><span class="muted">åbne mindst et minut eller med over 100 MB log</span></div>
|
||||||
|
<TransactionTable Transactions="_analysis.LongTransactions" Empty="Ingen lange eller store transaktioner i tidsrummet." />
|
||||||
|
</section>
|
||||||
|
|
||||||
<div class="grid2">
|
<div class="grid2">
|
||||||
<section class="card">
|
<section class="card">
|
||||||
<div class="card-head" id="blocking"><h2>Blocking</h2></div>
|
<div class="card-head" id="blocking"><h2>Blocking</h2></div>
|
||||||
|
|
|
||||||
|
|
@ -136,6 +136,31 @@ else
|
||||||
<LongOperationTable Operations="_operations" Empty="Ingen backup, restore, DBCC eller lignende med fremskridt de seneste 24 timer." />
|
<LongOperationTable Operations="_operations" Empty="Ingen backup, restore, DBCC eller lignende med fremskridt de seneste 24 timer." />
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section class="card" id="aktivitet">
|
||||||
|
<div class="card-head"><h2>Lige nu: åbne transaktioner og lange sætninger</h2><span class="muted">@(_activity is null ? "endnu ikke målt" : "målt " + Display.Ago(_activity.CapturedAt.UtcDateTime))</span></div>
|
||||||
|
@if (_activity is not { } activity || activity.Transactions.Count == 0 && activity.LongRequests.Count == 0)
|
||||||
|
{
|
||||||
|
<p class="muted">Ingen åbne brugertransaktioner eller sætninger over grænsen.</p>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<table class="queries">
|
||||||
|
<thead><tr><th>Type</th><th class="num">Varighed</th><th>Database</th><th class="num">Log</th><th>Session</th><th>Ventetype</th><th>Sætning</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
@foreach (var t in activity.Transactions.OrderByDescending(t => t.Age(activity.CapturedAt)).Take(20))
|
||||||
|
{
|
||||||
|
<tr class="@(t.Age(activity.CapturedAt).TotalMinutes >= activity.TransactionMinutes ? "bad" : "")"><td>Transaktion</td><td class="num nowrap">@TransactionTable.Duration(t.Age(activity.CapturedAt))</td><td>@(t.DatabaseName ?? "–")</td><td class="num nowrap">@Display.Number(t.LogMegabytes) MB</td><td class="small">@t.SessionId <span class="muted">@t.LoginName @t.ProgramName</span></td><td>@(t.WaitType ?? "–")</td><td><pre class="sql">@(t.Statement ?? "–")</pre></td></tr>
|
||||||
|
}
|
||||||
|
@foreach (var r in activity.LongRequests)
|
||||||
|
{
|
||||||
|
<tr class="bad"><td>Sætning</td><td class="num nowrap">@TransactionTable.Duration(TimeSpan.FromMilliseconds(r.ElapsedMs))</td><td>@(r.DatabaseName ?? "–")</td><td class="num">–</td><td class="small">@r.SessionId <span class="muted">@r.LoginName @r.ProgramName</span></td><td>@(r.WaitType ?? "kører")</td><td><pre class="sql">@(r.Statement ?? "–")</pre></td></tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
}
|
||||||
|
<details><summary>Hvad betyder det?</summary><p>En åben transaktion holder sine låse og forhindrer, at transaktionsloggen og tempdb's version store kan genbruges. Over @(_activity?.TransactionMinutes ?? 10) minutter er det et fund, og over @(_activity?.TransactionLogMegabytes ?? 1024) MB log også. Sætninger, der har kørt over @(_activity?.RequestMinutes ?? 15) minutter (undtagen backup, restore og DBCC, som står under langvarige operationer), er også et fund. Grænserne sættes under SQLInsight:Activity.</p></details>
|
||||||
|
</section>
|
||||||
|
|
||||||
<div class="grid2">
|
<div class="grid2">
|
||||||
<section class="card">
|
<section class="card">
|
||||||
<div class="card-head"><h2>Blocking, seneste 24 timer</h2></div>
|
<div class="card-head"><h2>Blocking, seneste 24 timer</h2></div>
|
||||||
|
|
@ -277,6 +302,7 @@ else
|
||||||
private IReadOnlyList<DiskSeries> _diskSeries = [];
|
private IReadOnlyList<DiskSeries> _diskSeries = [];
|
||||||
private IReadOnlyList<StoredBlocking> _blocking = [];
|
private IReadOnlyList<StoredBlocking> _blocking = [];
|
||||||
private IReadOnlyList<StoredLongOperation> _operations = [];
|
private IReadOnlyList<StoredLongOperation> _operations = [];
|
||||||
|
private ActivitySnapshot? _activity;
|
||||||
private IReadOnlyList<StoredDeadlock> _deadlocks = [];
|
private IReadOnlyList<StoredDeadlock> _deadlocks = [];
|
||||||
|
|
||||||
protected override async Task OnInitializedAsync()
|
protected override async Task OnInitializedAsync()
|
||||||
|
|
@ -310,6 +336,7 @@ else
|
||||||
_diskSeries = await Repository.GetDiskSeriesAsync(id, day);
|
_diskSeries = await Repository.GetDiskSeriesAsync(id, day);
|
||||||
_blocking = await Repository.GetBlockingAsync(id, day);
|
_blocking = await Repository.GetBlockingAsync(id, day);
|
||||||
_operations = await Repository.GetLongOperationsAsync(id, day, DateTimeOffset.UtcNow, 50);
|
_operations = await Repository.GetLongOperationsAsync(id, day, DateTimeOffset.UtcNow, 50);
|
||||||
|
_activity = await Repository.GetSnapshotAsync<ActivitySnapshot>(id, "activity");
|
||||||
_deadlocks = await Repository.GetDeadlocksAsync(id, DateTimeOffset.UtcNow.AddDays(-7));
|
_deadlocks = await Repository.GetDeadlocksAsync(id, DateTimeOffset.UtcNow.AddDays(-7));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
33
src/SQLInsight.Web/Components/Shared/TransactionTable.razor
Normal file
33
src/SQLInsight.Web/Components/Shared/TransactionTable.razor
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
@inject Display Display
|
||||||
|
|
||||||
|
@* Long or large user transactions. Logins and statements are customer data: shown locally only. *@
|
||||||
|
@if (Transactions.Count == 0)
|
||||||
|
{
|
||||||
|
<p class="muted">@Empty</p>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<table class="queries">
|
||||||
|
<thead><tr><th>Åben siden</th><th class="num">Varighed</th><th>Database</th><th class="num">Log</th><th>Session</th><th>Sætning</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
@foreach (var t in Transactions)
|
||||||
|
{
|
||||||
|
<tr>
|
||||||
|
<td class="nowrap">@Display.Time(t.BeganAt)</td>
|
||||||
|
<td class="num nowrap">@Duration(t.Duration)</td>
|
||||||
|
<td>@(t.DatabaseName ?? "–")</td>
|
||||||
|
<td class="num nowrap">@Display.Number(t.MaxLogBytes / 1024.0 / 1024.0) MB</td>
|
||||||
|
<td class="small">@t.SessionId <span class="muted">@t.LoginName @t.ProgramName</span></td>
|
||||||
|
<td><pre class="sql">@(t.Statement ?? "–")</pre></td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
}
|
||||||
|
|
||||||
|
@code {
|
||||||
|
[Parameter, EditorRequired] public IReadOnlyList<StoredTransaction> Transactions { get; set; } = [];
|
||||||
|
[Parameter] public string Empty { get; set; } = "Ingen lange transaktioner.";
|
||||||
|
|
||||||
|
public static string Duration(TimeSpan value) => value.TotalHours >= 1 ? $"{(int)value.TotalHours} t {value.Minutes} min" : $"{Math.Max(0, (int)value.TotalMinutes)} min";
|
||||||
|
}
|
||||||
113
tests/SQLInsight.IntegrationTests/ActivityTests.cs
Normal file
113
tests/SQLInsight.IntegrationTests/ActivityTests.cs
Normal file
|
|
@ -0,0 +1,113 @@
|
||||||
|
using Microsoft.AspNetCore.Hosting;
|
||||||
|
using Microsoft.AspNetCore.Mvc.Testing;
|
||||||
|
using Microsoft.Data.SqlClient;
|
||||||
|
using SQLInsight.Collectors.Concurrency;
|
||||||
|
using SQLInsight.Core.Data;
|
||||||
|
using SQLInsight.Core.Instances;
|
||||||
|
using SQLInsight.Repository;
|
||||||
|
|
||||||
|
namespace SQLInsight.IntegrationTests;
|
||||||
|
|
||||||
|
/// <summary>Open transactions and long-running statements, as the least-privilege agent sees them.</summary>
|
||||||
|
[Collection(SqlServerTestGroup.Name)]
|
||||||
|
[Trait("Category", "Integration")]
|
||||||
|
public class ActivityTests(SqlServerFixture sql)
|
||||||
|
{
|
||||||
|
private SqlConnection Open(string application) => new(new SqlConnectionStringBuilder(sql.DatabaseConnectionString(SqlServerFixture.TestDatabase))
|
||||||
|
{
|
||||||
|
ApplicationName = application, CommandTimeout = 120,
|
||||||
|
}.ConnectionString);
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task An_open_transaction_and_a_statement_blocked_by_it_are_seen_but_not_the_agent()
|
||||||
|
{
|
||||||
|
var table = "Activity_" + Guid.NewGuid().ToString("N")[..6];
|
||||||
|
await sql.ExecuteAsSaAsync($"USE [{SqlServerFixture.TestDatabase}]; CREATE TABLE dbo.{table} (Id int PRIMARY KEY, Name nvarchar(20)); INSERT dbo.{table} VALUES (1, N'a');");
|
||||||
|
await using var holder = Open("Lager-job");
|
||||||
|
await holder.OpenAsync();
|
||||||
|
await using var transaction = holder.BeginTransaction();
|
||||||
|
await using (var update = new SqlCommand($"UPDATE dbo.{table} SET Name = N'b' WHERE Id = 1;", holder, transaction))
|
||||||
|
{
|
||||||
|
await update.ExecuteNonQueryAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
await using var reader = Open("Rapport");
|
||||||
|
await reader.OpenAsync();
|
||||||
|
var blocked = new SqlCommand($"SELECT Name FROM dbo.{table} WHERE Id = 1;", reader).ExecuteScalarAsync();
|
||||||
|
await using var agentLike = Open("SQLInsight Pro (test)");
|
||||||
|
await agentLike.OpenAsync();
|
||||||
|
await using var agentTransaction = agentLike.BeginTransaction();
|
||||||
|
await using (var touch = new SqlCommand("SELECT 1;", agentLike, agentTransaction))
|
||||||
|
{
|
||||||
|
await touch.ExecuteScalarAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await Task.Delay(1500);
|
||||||
|
await using var session = await AgentSession.OpenAsync(sql);
|
||||||
|
var snapshot = await session.CollectAsync<ActivitySnapshot>(new ActivityCollector(transactionMinutes: 0, requestMinutes: 0));
|
||||||
|
|
||||||
|
var open = Assert.Single(snapshot.Transactions, t => t.ProgramName == "Lager-job");
|
||||||
|
Assert.Equal(SqlServerFixture.TestDatabase, open.DatabaseName);
|
||||||
|
Assert.True(open.LogBytesUsed > 0);
|
||||||
|
Assert.Contains(table, open.Statement, StringComparison.Ordinal);
|
||||||
|
Assert.DoesNotContain(snapshot.Transactions, t => t.ProgramName?.StartsWith("SQLInsight Pro", StringComparison.Ordinal) == true);
|
||||||
|
var waiting = Assert.Single(snapshot.LongRequests, r => r.ProgramName == "Rapport");
|
||||||
|
Assert.Equal("LCK_M_S", waiting.WaitType);
|
||||||
|
Assert.Equal(open.SessionId, waiting.BlockingSessionId);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
await transaction.RollbackAsync();
|
||||||
|
await agentTransaction.RollbackAsync();
|
||||||
|
await blocked;
|
||||||
|
await sql.ExecuteAsSaAsync($"USE [{SqlServerFixture.TestDatabase}]; DROP TABLE dbo.{table};");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Page_analysis_and_api_show_transactions_with_sensitive_values_left_out()
|
||||||
|
{
|
||||||
|
var database = "SQLInsightAct_" + Guid.NewGuid().ToString("N")[..8];
|
||||||
|
await sql.ExecuteAsSaAsync($"CREATE DATABASE [{database}];");
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var repository = new InsightRepository(sql.DatabaseConnectionString(database));
|
||||||
|
await repository.MigrateAsync();
|
||||||
|
var now = DateTimeOffset.UtcNow;
|
||||||
|
var id = await repository.UpsertInstanceAsync("prod-sql01", new InstanceInfo
|
||||||
|
{
|
||||||
|
ServerName = "SQL01", Version = SqlServerVersion.Parse("16.0.4135.4"), ProductLevel = "RTM", Edition = "Standard Edition", EngineEdition = EngineEdition.Standard,
|
||||||
|
}, "SqlClient", false, now);
|
||||||
|
await repository.SaveCollectedAsync(id, "activity", new ActivitySnapshot
|
||||||
|
{
|
||||||
|
CapturedAt = now,
|
||||||
|
Transactions = [new OpenTransaction(61, 4711, "user_transaction", now.AddMinutes(-25), 300L * 1024 * 1024, 0, "AXDB", 1, @"KUNDE\anne", "PC01", "Ax32.exe", "sleeping", null, "UPDATE INVENTSUM SET POSTEDQTY = 0")],
|
||||||
|
});
|
||||||
|
|
||||||
|
await using var factory = new WebApplicationFactory<Program>().WithWebHostBuilder(b =>
|
||||||
|
{
|
||||||
|
b.UseEnvironment("Development");
|
||||||
|
b.UseSetting("SQLInsight:Repository:ConnectionString", sql.DatabaseConnectionString(database));
|
||||||
|
b.UseSetting("SQLInsight:Web:Authentication", "None");
|
||||||
|
});
|
||||||
|
var client = factory.CreateClient();
|
||||||
|
var page = await client.GetStringAsync("/instanser/prod-sql01");
|
||||||
|
var api = await client.GetStringAsync("/api/v1/instances/prod-sql01/activity");
|
||||||
|
var analysis = await client.GetStringAsync("/api/v1/instances/prod-sql01/analysis?from=" + Uri.EscapeDataString(now.AddHours(-1).ToString("o")));
|
||||||
|
|
||||||
|
Assert.Contains("UPDATE INVENTSUM", page, StringComparison.Ordinal);
|
||||||
|
Assert.Contains("AXDB", api, StringComparison.Ordinal);
|
||||||
|
Assert.DoesNotContain("INVENTSUM", api, StringComparison.Ordinal);
|
||||||
|
Assert.DoesNotContain("anne", api, StringComparison.Ordinal);
|
||||||
|
Assert.Contains("\"longTransactions\":[{", analysis, StringComparison.Ordinal);
|
||||||
|
Assert.DoesNotContain("INVENTSUM", analysis, StringComparison.Ordinal);
|
||||||
|
Assert.Single(await repository.GetLongTransactionsAsync(id, now.AddHours(-1), now.AddMinutes(1)));
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
await sql.ExecuteAsSaAsync($"ALTER DATABASE [{database}] SET SINGLE_USER WITH ROLLBACK IMMEDIATE; DROP DATABASE [{database}];");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
54
tests/SQLInsight.Rules.Tests/ActivityRuleTests.cs
Normal file
54
tests/SQLInsight.Rules.Tests/ActivityRuleTests.cs
Normal file
|
|
@ -0,0 +1,54 @@
|
||||||
|
using SQLInsight.Core.Data;
|
||||||
|
using SQLInsight.Core.Findings;
|
||||||
|
using SQLInsight.Rules.Concurrency;
|
||||||
|
using SQLInsight.Rules.Engine;
|
||||||
|
using static SQLInsight.Rules.Tests.TestInstances;
|
||||||
|
|
||||||
|
namespace SQLInsight.Rules.Tests;
|
||||||
|
|
||||||
|
public class ActivityRuleTests
|
||||||
|
{
|
||||||
|
private static RuleResult Run(IRule rule, RuleContext context) => Assert.Single(new RuleEngine([rule]).Evaluate(context));
|
||||||
|
|
||||||
|
private static OpenTransaction Tran(double minutes, long logMb = 1) =>
|
||||||
|
new(61, 4711, "user_transaction", Now.AddMinutes(-minutes), logMb * 1024 * 1024, 0, "AX", 1, @"KUNDE\anne", "PC01", "Microsoft Dynamics AX", "sleeping", null, "UPDATE INVENTSUM SET …");
|
||||||
|
|
||||||
|
private static ActivitySnapshot Snapshot(IReadOnlyList<OpenTransaction>? transactions = null, IReadOnlyList<LongRunningRequest>? requests = null) =>
|
||||||
|
new() { CapturedAt = Now, Transactions = transactions ?? [], LongRequests = requests ?? [] };
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(9, null)]
|
||||||
|
[InlineData(12, Severity.Medium)]
|
||||||
|
[InlineData(61, Severity.High)]
|
||||||
|
public void Open_transactions_by_age(double minutes, Severity? expected)
|
||||||
|
{
|
||||||
|
var result = Run(new LongTransactionRule(), With(Snapshot([Tran(minutes)])));
|
||||||
|
|
||||||
|
Assert.Equal(expected, result.Findings.SingleOrDefault()?.Severity);
|
||||||
|
if (expected is not null)
|
||||||
|
{
|
||||||
|
var finding = result.Findings.Single();
|
||||||
|
Assert.True(finding.Evidence.Single(e => e.Name == "Login").Sensitive);
|
||||||
|
Assert.True(finding.Evidence.Single(e => e.Name == "Sætning").Sensitive);
|
||||||
|
Assert.DoesNotContain("anne", finding.Title + finding.Subject, StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(500, RuleOutcome.Passed)]
|
||||||
|
[InlineData(2048, RuleOutcome.Findings)]
|
||||||
|
public void Transactions_holding_much_log(long megabytes, RuleOutcome expected) =>
|
||||||
|
Assert.Equal(expected, Run(new LargeTransactionRule(), With(Snapshot([Tran(1, megabytes)]))).Outcome);
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Long_running_statements_are_found_but_not_backups_or_waitfor()
|
||||||
|
{
|
||||||
|
var select = new LongRunningRequest(70, Now.AddMinutes(-20), 20 * 60_000, "SELECT", "suspended", "LCK_M_S", 1000, "AX", "anne", "PC01", "AX", 61, "SELECT …");
|
||||||
|
|
||||||
|
Assert.True(ActivityAnalysis.IsStatement(select));
|
||||||
|
Assert.False(ActivityAnalysis.IsStatement(select with { Command = "BACKUP DATABASE" }));
|
||||||
|
Assert.False(ActivityAnalysis.IsStatement(select with { WaitType = "WAITFOR" }));
|
||||||
|
var finding = Assert.Single(Run(new LongRunningStatementRule(), With(Snapshot(requests: [select]))).Findings);
|
||||||
|
Assert.Equal("61", finding.Evidence.Single(e => e.Name == "Blokeret af").Value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -18,6 +18,6 @@ public class RuleCatalogTests
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Rule_count()
|
public void Rule_count()
|
||||||
{
|
{
|
||||||
Assert.Equal(72, BuiltInRules.Create().Count);
|
Assert.Equal(75, BuiltInRules.Create().Count);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue