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>
113 lines
5.8 KiB
C#
113 lines
5.8 KiB
C#
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}];");
|
|
}
|
|
}
|
|
}
|