Fase 3: findings sync from agent to hub (queue, retry, data minimization)
Some checks are pending
CI / Build og unit tests (push) Waiting to run
CI / Integration (mcr.microsoft.com/mssql/server:2017-latest) (push) Blocked by required conditions
CI / Integration (mcr.microsoft.com/mssql/server:2019-latest) (push) Blocked by required conditions
CI / Integration (mcr.microsoft.com/mssql/server:2022-latest) (push) Blocked by required conditions
CI / Integration (mcr.microsoft.com/mssql/server:2025-latest) (push) Blocked by required conditions

- Evidence can be marked sensitive (query text, logins, hosts, programs,
  job error messages); rules mark it. Sensitive values and the server
  name are sent as [udeladt] unless the customer allows them.
- Full-state submission contract (all active findings and rule outcomes
  per instance), idempotent by submission id
- Local outbound queue in the repository (migration 0002): newest state
  per instance only, exponential backoff up to an hour, permanent
  rejections (bad license, bad request) are given up, sent items purged
- Hub status (license state, last sent, last error) for the web UI
- Sync runs only with a valid license (or in grace); monitoring never
  depends on it

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E3EExwndy8Z22EK2Qpt6BK
This commit is contained in:
Claude 2026-09-29 08:35:22 +00:00
parent 1ba274d699
commit bfb27778d2
No known key found for this signature in database
21 changed files with 562 additions and 25 deletions

View file

@ -13,6 +13,7 @@
<Project Path="src/SQLInsight.Web/SQLInsight.Web.csproj" />
</Folder>
<Folder Name="/tests/">
<Project Path="tests/SQLInsight.Agent.Tests/SQLInsight.Agent.Tests.csproj" />
<Project Path="tests/SQLInsight.Collectors.Tests/SQLInsight.Collectors.Tests.csproj" />
<Project Path="tests/SQLInsight.Connectivity.Tests/SQLInsight.Connectivity.Tests.csproj" />
<Project Path="tests/SQLInsight.Core.Tests/SQLInsight.Core.Tests.csproj" />

View file

@ -0,0 +1,33 @@
namespace SQLInsight.Agent.Hub;
/// <summary>
/// Findings sync to SQLInsight Hub (Professional and Enterprise). Off by default; nothing leaves the network unless
/// it is switched on.
/// </summary>
public sealed class HubOptions
{
public const string SectionName = "SQLInsight:Hub";
public bool Enabled { get; set; }
/// <summary>Base URL of SQLInsight Hub, e.g. https://hub.seosoft.dk/.</summary>
public Uri? Url { get; set; }
/// <summary>The license text (SQLI1.…).</summary>
public string? License { get; set; }
/// <summary>
/// Also send evidence marked sensitive: query text, logins, host and program names, job error messages.
/// Off by default; such values are sent as "[udeladt]".
/// </summary>
public bool SendSensitiveEvidence { get; set; }
/// <summary>Also send the server's network name. Off by default; the instance is identified by its configured name.</summary>
public bool SendServerName { get; set; }
/// <summary>How often the state is sent when nothing changed. Changes are sent at once.</summary>
public TimeSpan SubmitInterval { get; set; } = TimeSpan.FromMinutes(15);
/// <summary>Additional public keys trusted for licenses (key id → base64 SubjectPublicKeyInfo).</summary>
public IDictionary<string, string> TrustedKeys { get; } = new Dictionary<string, string>();
}

View file

@ -0,0 +1,42 @@
using System.Text.Json;
using Microsoft.Extensions.Options;
using SQLInsight.Core.Instances;
using SQLInsight.Repository;
using SQLInsight.Rules.Engine;
namespace SQLInsight.Agent.Hub;
/// <summary>
/// Queues an instance's state for the hub: at once when findings changed, otherwise at most every SubmitInterval.
/// </summary>
public sealed class HubOutbox(IOptions<HubOptions> options, InsightRepository repository)
{
public static readonly JsonSerializerOptions Json = new(JsonSerializerDefaults.Web);
private readonly Dictionary<string, DateTimeOffset> _lastQueued = new(StringComparer.OrdinalIgnoreCase);
private readonly Lock _lock = new();
public bool Enabled => options.Value.Enabled && options.Value.Url is not null;
public async Task QueueAsync(string instanceName, int instanceId, InstanceInfo info, IReadOnlyList<RuleResult> results, bool changed, DateTimeOffset now, CancellationToken cancellationToken)
{
if (!Enabled)
{
return;
}
lock (_lock)
{
if (!changed && _lastQueued.TryGetValue(instanceName, out var last) && now - last < options.Value.SubmitInterval)
{
return;
}
_lastQueued[instanceName] = now;
}
var active = await repository.GetActiveFindingsAsync(instanceId, cancellationToken).ConfigureAwait(false);
var submission = SubmissionBuilder.Build(instanceName, info, results, active, options.Value, now);
await repository.EnqueueSubmissionAsync(instanceName, submission.SubmissionId, JsonSerializer.Serialize(submission, Json), now, cancellationToken).ConfigureAwait(false);
}
}

View file

@ -0,0 +1,119 @@
using System.Net;
using System.Net.Http.Headers;
using System.Text;
using Microsoft.Extensions.Options;
using SQLInsight.Licensing;
using SQLInsight.Repository;
namespace SQLInsight.Agent.Hub;
/// <summary>
/// Delivers queued submissions to SQLInsight Hub with retry and exponential backoff. Rejections the hub will keep
/// making (invalid license, bad request) are given up rather than retried forever.
/// </summary>
public sealed partial class HubSyncService(
IOptions<HubOptions> options,
InsightRepository repository,
IHttpClientFactory httpClientFactory,
TimeProvider time,
ILogger<HubSyncService> logger) : BackgroundService
{
public const string AuthorizationScheme = "SQLInsight-License";
public static readonly TimeSpan PollInterval = TimeSpan.FromSeconds(30);
public static readonly TimeSpan MaxBackoff = TimeSpan.FromHours(1);
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
using var timer = new PeriodicTimer(PollInterval, time);
do
{
try
{
await RunOnceAsync(stoppingToken).ConfigureAwait(false);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
LogRoundFailed(logger, ex);
}
}
while (await timer.WaitForNextTickAsync(stoppingToken).ConfigureAwait(false));
}
/// <summary>One delivery round. Returns how many submissions were sent.</summary>
public async Task<int> RunOnceAsync(CancellationToken cancellationToken)
{
var settings = options.Value;
var status = CheckLicense(settings);
await repository.SaveHubStatusAsync(new HubStatusRow
{
Enabled = settings.Enabled,
HubUrl = settings.Url?.ToString(),
LicenseState = status.State.ToString(),
LicenseMessage = status.Message,
Customer = status.License?.Customer,
LicensePlan = status.License?.Plan.ToString(),
LicenseExpires = status.License?.ExpiresAt.UtcDateTime,
SendSensitive = settings.SendSensitiveEvidence,
UpdatedAt = time.GetUtcNow().UtcDateTime,
}, cancellationToken).ConfigureAwait(false);
if (!settings.Enabled || settings.Url is null || !status.AllowsFindingsSync)
{
return 0;
}
var sent = 0;
using var http = httpClientFactory.CreateClient("hub");
foreach (var item in await repository.GetDueSubmissionsAsync(time.GetUtcNow(), cancellationToken: cancellationToken).ConfigureAwait(false))
{
var now = time.GetUtcNow();
try
{
using var request = new HttpRequestMessage(HttpMethod.Post, new Uri(settings.Url, "api/v1/findings"))
{
Content = new StringContent(item.Payload, Encoding.UTF8, "application/json"),
};
request.Headers.Authorization = new AuthenticationHeaderValue(AuthorizationScheme, settings.License);
using var response = await http.SendAsync(request, cancellationToken).ConfigureAwait(false);
if (response.IsSuccessStatusCode)
{
await repository.MarkSubmissionSentAsync(item.OutboundId, now, cancellationToken).ConfigureAwait(false);
sent++;
continue;
}
var body = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
var error = $"{(int)response.StatusCode} {response.ReasonPhrase}: {Truncate(body)}";
var permanent = response.StatusCode is HttpStatusCode.BadRequest or HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden or HttpStatusCode.UnprocessableEntity;
await repository.MarkSubmissionFailedAsync(item.OutboundId, error, now, permanent ? null : now + Backoff(item.Attempts), cancellationToken).ConfigureAwait(false);
LogRejected(logger, item.InstanceName, error);
}
catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException && !cancellationToken.IsCancellationRequested)
{
await repository.MarkSubmissionFailedAsync(item.OutboundId, ex.Message, now, now + Backoff(item.Attempts), cancellationToken).ConfigureAwait(false);
LogUnreachable(logger, ex, item.InstanceName);
}
}
return sent;
}
private LicenseStatus CheckLicense(HubOptions settings) =>
new LicenseVerifier(TrustedKeys.Embedded().With(settings.TrustedKeys), time).Check(settings.License);
/// <summary>1, 2, 4 … minutes, at most an hour.</summary>
public static TimeSpan Backoff(int attempts) =>
TimeSpan.FromMinutes(Math.Min(MaxBackoff.TotalMinutes, Math.Pow(2, Math.Min(attempts, 10))));
private static string Truncate(string text) => text.Length <= 500 ? text : text[..500];
[LoggerMessage(Level = LogLevel.Warning, Message = "Hub rejected the submission for {Instance}: {Error}")]
private static partial void LogRejected(ILogger logger, string instance, string error);
[LoggerMessage(Level = LogLevel.Warning, Message = "Hub unreachable for {Instance}; will retry")]
private static partial void LogUnreachable(ILogger logger, Exception exception, string instance);
[LoggerMessage(Level = LogLevel.Error, Message = "Hub sync round failed")]
private static partial void LogRoundFailed(ILogger logger, Exception exception);
}

View file

@ -0,0 +1,56 @@
using SQLInsight.Contracts;
using SQLInsight.Core.Instances;
using SQLInsight.Repository;
using SQLInsight.Rules.Engine;
namespace SQLInsight.Agent.Hub;
/// <summary>
/// Turns an instance's current state into a hub submission, leaving out what the customer has not allowed.
/// </summary>
public static class SubmissionBuilder
{
public static readonly string AgentVersion =
typeof(SubmissionBuilder).Assembly.GetName().Version?.ToString() ?? "0.0.0";
public static FindingsSubmission Build(
string instanceName, InstanceInfo info, IReadOnlyList<RuleResult> results, IReadOnlyList<StoredFinding> activeFindings,
HubOptions options, DateTimeOffset now)
{
ArgumentNullException.ThrowIfNull(info);
ArgumentNullException.ThrowIfNull(results);
ArgumentNullException.ThrowIfNull(activeFindings);
ArgumentNullException.ThrowIfNull(options);
return new FindingsSubmission
{
SubmissionId = Guid.NewGuid(),
AgentVersion = AgentVersion,
CollectedAt = now,
Instance = new InstanceSnapshot
{
InstanceKey = instanceName,
Release = info.Release.DisplayName(),
ProductVersion = info.Version.ToString(),
ProductLevel = info.ProductLevel,
ProductUpdateLevel = info.ProductUpdateLevel,
Edition = info.Edition,
ServerName = options.SendServerName ? info.ServerName : null,
},
Rules = results.Select(r => new RuleOutcomeDto { RuleId = r.RuleId, Outcome = r.Outcome.ToString(), Reason = r.Reason }).ToList(),
Findings = activeFindings.Select(f => new FindingDto
{
RuleId = f.RuleId,
Subject = f.Subject,
Severity = f.Severity.ToString(),
Title = f.Title,
Explanation = f.Explanation,
Recommendation = f.Recommendation,
FirstSeenAt = new DateTimeOffset(DateTime.SpecifyKind(f.FirstSeenAt, DateTimeKind.Utc)),
Evidence = f.Evidence
.Select(e => new EvidenceDto(e.Name, e.Sensitive && !options.SendSensitiveEvidence ? FindingsSubmission.Redacted : e.Value))
.ToList(),
}).ToList(),
};
}
}

View file

@ -21,7 +21,8 @@ public sealed partial class InstanceMonitor(
InsightRepository repository,
ILogger logger,
NotificationDispatcher? notifications = null,
Uri? webUrl = null)
Uri? webUrl = null,
Hub.HubOutbox? hubOutbox = null)
{
private bool? _wasReachable;
@ -103,6 +104,10 @@ public sealed partial class InstanceMonitor(
events.AddRange(changes.Opened.Select(o => NotificationFactory.Opened(instance.Name, o.FindingId, o.Finding, webUrl, now)));
events.AddRange(changes.Resolved.Select(r => NotificationFactory.Resolved(instance.Name, r.FindingId, r.RuleId, r.Title, (Core.Findings.Severity)r.Severity, webUrl, now)));
await NotifyAsync(events, cancellationToken).ConfigureAwait(false);
if (hubOutbox is not null)
{
await hubOutbox.QueueAsync(instance.Name, instanceId, info, results, changes.Opened.Count + changes.Resolved.Count > 0, now, cancellationToken).ConfigureAwait(false);
}
var findings = 0;
var notMeasurable = 0;
foreach (var result in results)

View file

@ -18,7 +18,8 @@ public sealed partial class MonitoringService(
TimeProvider timeProvider,
ILoggerFactory loggerFactory,
NotificationDispatcher notifications,
IOptions<NotificationOptions> notificationOptions) : BackgroundService
IOptions<NotificationOptions> notificationOptions,
Hub.HubOutbox hubOutbox) : BackgroundService
{
private readonly ILogger _logger = loggerFactory.CreateLogger<MonitoringService>();
@ -30,7 +31,7 @@ public sealed partial class MonitoringService(
var intervals = new Dictionary<string, TimeSpan>(settings.CollectorIntervals, StringComparer.OrdinalIgnoreCase);
var monitors = settings.Instances
.Select(i => new InstanceMonitor(i, BuiltInCollectors.Create(), intervals, connectionFactory, ruleEngine, repository,
loggerFactory.CreateLogger($"SQLInsight.Instance.{i.Name}"), notifications, notificationOptions.Value.WebUrl))
loggerFactory.CreateLogger($"SQLInsight.Instance.{i.Name}"), notifications, notificationOptions.Value.WebUrl, hubOutbox))
.ToList();
if (monitors.Count == 0)
{

View file

@ -51,6 +51,10 @@ builder.Services.AddSingleton(sp =>
return new NotificationDispatcher(channels, options, sp.GetRequiredService<TimeProvider>(), sp.GetRequiredService<ILogger<NotificationDispatcher>>());
});
builder.Services.Configure<SQLInsight.Agent.Hub.HubOptions>(builder.Configuration.GetSection(SQLInsight.Agent.Hub.HubOptions.SectionName));
builder.Services.AddHttpClient("hub", c => c.Timeout = TimeSpan.FromSeconds(30));
builder.Services.AddSingleton<SQLInsight.Agent.Hub.HubOutbox>();
builder.Services.AddHostedService<SQLInsight.Agent.Hub.HubSyncService>();
builder.Services.AddHostedService<MonitoringService>();
var host = builder.Build();

View file

@ -10,6 +10,8 @@
<ProjectReference Include="..\SQLInsight.Collectors\SQLInsight.Collectors.csproj" />
<ProjectReference Include="..\SQLInsight.Repository\SQLInsight.Repository.csproj" />
<ProjectReference Include="..\SQLInsight.Notifications\SQLInsight.Notifications.csproj" />
<ProjectReference Include="..\SQLInsight.Contracts\SQLInsight.Contracts.csproj" />
<ProjectReference Include="..\SQLInsight.Licensing\SQLInsight.Licensing.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Hosting" />

View file

@ -13,6 +13,14 @@
"MaxParallelInstances": 4,
"CollectorIntervals": {
},
"Hub": {
"Enabled": false,
"Url": "https://hub.seosoft.dk/",
"License": null,
"SendSensitiveEvidence": false,
"SendServerName": false,
"SubmitInterval": "00:15:00"
},
"Notifications": {
"WebUrl": "https://sqlinsight.eksempel.local/",
"NotifyResolved": true,

View file

@ -1,38 +1,50 @@
namespace SQLInsight.Contracts;
/// <summary>
/// What an agent sends to SQLInsight Hub when findings sync is enabled. This is a wire contract between
/// independently released agents and the hub: add optional members only, and bump
/// <see cref="CurrentSchemaVersion"/> for anything else.
/// What an agent sends to SQLInsight Hub: the complete current state of one instance (all active findings and every
/// rule's outcome). Full state rather than changes, so a lost or repeated submission never leaves the hub wrong, and
/// only the newest submission per instance has to be delivered.
/// </summary>
/// <remarks>
/// Only findings and instance metadata are sent. Table data is never sent, and query text only when the customer
/// has allowed it.
/// A wire contract between independently released agents and the hub: add optional members only, and bump
/// <see cref="CurrentSchemaVersion"/> for anything else. Sensitive evidence (query text, logins, hosts, messages) is
/// replaced by <see cref="Redacted"/> unless the customer allows sending it.
/// </remarks>
public sealed record FindingsSubmission
{
public const int CurrentSchemaVersion = 1;
public const string Redacted = "[udeladt]";
public int SchemaVersion { get; init; } = CurrentSchemaVersion;
/// <summary>Unique per submission; the hub ignores a submission it has already processed.</summary>
public required Guid SubmissionId { get; init; }
public required string AgentVersion { get; init; }
public required string RulePackVersion { get; init; }
public required DateTimeOffset CollectedAt { get; init; }
public required InstanceSnapshot Instance { get; init; }
public required IReadOnlyList<RuleResultDto> Results { get; init; }
public required IReadOnlyList<RuleOutcomeDto> Rules { get; init; }
public required IReadOnlyList<FindingDto> Findings { get; init; }
}
public sealed record InstanceSnapshot
{
/// <summary>A stable, customer-chosen name for the instance. The hub keys history on it.</summary>
/// <summary>The customer's name for the instance (SQLInsight:Instances:Name). The hub keys history on it.</summary>
public required string InstanceKey { get; init; }
/// <summary>SQL Server release, e.g. "SQL Server 2019".</summary>
public required string Release { get; init; }
public required string ProductVersion { get; init; }
public string? ProductLevel { get; init; }
public string? ProductUpdateLevel { get; init; }
public required string Edition { get; init; }
public string? Edition { get; init; }
/// <summary>The server's network name; only sent when the customer allows it.</summary>
public string? ServerName { get; init; }
}
public sealed record RuleResultDto
public sealed record RuleOutcomeDto
{
public required string RuleId { get; init; }
@ -40,18 +52,26 @@ public sealed record RuleResultDto
public required string Outcome { get; init; }
public string? Reason { get; init; }
public IReadOnlyList<FindingDto> Findings { get; init; } = [];
}
public sealed record FindingDto
{
public required string RuleId { get; init; }
/// <summary>Identifies the finding within the rule (e.g. "database: Sales"); empty for instance-wide findings.</summary>
public required string Subject { get; init; }
/// <summary>Info, Low, Medium, High or Critical.</summary>
public required string Severity { get; init; }
public required string Title { get; init; }
public required string Explanation { get; init; }
public required string Recommendation { get; init; }
public required DateTimeOffset FirstSeenAt { get; init; }
public IReadOnlyList<EvidenceDto> Evidence { get; init; } = [];
}
public sealed record EvidenceDto(string Name, string Value);
/// <summary>The hub's answer.</summary>
public sealed record SubmissionReceipt(Guid SubmissionId, bool Duplicate, int ActiveFindings);

View file

@ -31,6 +31,10 @@ public sealed record Finding
public IReadOnlyList<Reference> References { get; init; } = [];
}
public sealed record Evidence(string Name, string Value);
/// <param name="Sensitive">
/// The value can contain customer data: query text, logins, host or program names, error messages. Sensitive values
/// stay on-prem unless the customer allows sending them to SQLInsight Hub.
/// </param>
public sealed record Evidence(string Name, string Value, bool Sensitive = false);
public sealed record Reference(string Title, Uri Url);

View file

@ -0,0 +1,106 @@
using Dapper;
namespace SQLInsight.Repository;
public sealed record OutboundSubmission(long OutboundId, Guid SubmissionId, string InstanceName, string Payload, int Attempts);
public sealed record HubStatusRow
{
public bool Enabled { get; init; }
public string? HubUrl { get; init; }
public string LicenseState { get; init; } = "None";
public string LicenseMessage { get; init; } = string.Empty;
public string? Customer { get; init; }
public string? LicensePlan { get; init; }
public DateTime? LicenseExpires { get; init; }
public bool SendSensitive { get; init; }
public DateTime? LastSentAt { get; init; }
public string? LastError { get; init; }
public DateTime? LastErrorAt { get; init; }
public DateTime UpdatedAt { get; init; }
public int Pending { get; init; }
}
public sealed partial class InsightRepository
{
/// <summary>Queues the newest full state for an instance, replacing any older unsent state for it.</summary>
public async Task EnqueueSubmissionAsync(string instanceName, Guid submissionId, string payload, DateTimeOffset now, CancellationToken cancellationToken = default)
{
await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false);
await connection.ExecuteAsync("""
DELETE insight.OutboundSubmissions WHERE InstanceName = @instanceName AND SentAt IS NULL AND GaveUpAt IS NULL;
INSERT insight.OutboundSubmissions (SubmissionId, InstanceName, CreatedAt, Payload, NextAttemptAt)
VALUES (@submissionId, @instanceName, @now, @payload, @now);
""", new { instanceName, submissionId, payload, now = Utc(now) }).ConfigureAwait(false);
}
public async Task<IReadOnlyList<OutboundSubmission>> GetDueSubmissionsAsync(DateTimeOffset now, int max = 20, CancellationToken cancellationToken = default)
{
await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false);
var rows = await connection.QueryAsync<OutboundSubmission>("""
SELECT TOP (@max) OutboundId, SubmissionId, InstanceName, Payload, Attempts
FROM insight.OutboundSubmissions
WHERE SentAt IS NULL AND GaveUpAt IS NULL AND NextAttemptAt <= @now
ORDER BY NextAttemptAt;
""", new { max, now = Utc(now) }).ConfigureAwait(false);
return rows.ToList();
}
public async Task MarkSubmissionSentAsync(long outboundId, DateTimeOffset now, CancellationToken cancellationToken = default)
{
await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false);
await connection.ExecuteAsync("""
UPDATE insight.OutboundSubmissions SET SentAt = @now, Attempts = Attempts + 1, LastError = NULL WHERE OutboundId = @outboundId;
UPDATE insight.HubStatus SET LastSentAt = @now, LastError = NULL, LastErrorAt = NULL WHERE Id = 1;
""", new { outboundId, now = Utc(now) }).ConfigureAwait(false);
}
/// <summary>Records a failed attempt. With <paramref name="nextAttemptAt"/> null the submission is given up.</summary>
public async Task MarkSubmissionFailedAsync(long outboundId, string error, DateTimeOffset now, DateTimeOffset? nextAttemptAt, CancellationToken cancellationToken = default)
{
await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false);
await connection.ExecuteAsync("""
UPDATE insight.OutboundSubmissions
SET Attempts = Attempts + 1, LastError = @error,
NextAttemptAt = COALESCE(@next, NextAttemptAt), GaveUpAt = CASE WHEN @next IS NULL THEN @now END
WHERE OutboundId = @outboundId;
UPDATE insight.HubStatus SET LastError = @error, LastErrorAt = @now WHERE Id = 1;
""", new { outboundId, error, now = Utc(now), next = nextAttemptAt is { } n ? Utc(n) : (DateTime?)null }).ConfigureAwait(false);
}
public async Task SaveHubStatusAsync(HubStatusRow status, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(status);
await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false);
await connection.ExecuteAsync("""
MERGE insight.HubStatus AS t
USING (SELECT 1 AS Id) AS s ON t.Id = s.Id
WHEN MATCHED THEN UPDATE SET Enabled = @Enabled, HubUrl = @HubUrl, LicenseState = @LicenseState, LicenseMessage = @LicenseMessage,
Customer = @Customer, LicensePlan = @LicensePlan, LicenseExpires = @LicenseExpires, SendSensitive = @SendSensitive, UpdatedAt = @UpdatedAt
WHEN NOT MATCHED THEN INSERT (Id, Enabled, HubUrl, LicenseState, LicenseMessage, Customer, LicensePlan, LicenseExpires, SendSensitive, UpdatedAt)
VALUES (1, @Enabled, @HubUrl, @LicenseState, @LicenseMessage, @Customer, @LicensePlan, @LicenseExpires, @SendSensitive, @UpdatedAt);
""", status).ConfigureAwait(false);
}
public async Task<HubStatusRow?> GetHubStatusAsync(CancellationToken cancellationToken = default)
{
await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false);
return await connection.QuerySingleOrDefaultAsync<HubStatusRow>("""
SELECT h.*, (SELECT COUNT(*) FROM insight.OutboundSubmissions WHERE SentAt IS NULL AND GaveUpAt IS NULL) AS Pending
FROM insight.HubStatus h WHERE h.Id = 1;
""").ConfigureAwait(false);
}
/// <summary>The last submission sent (or queued) per instance, so the UI can show exactly what leaves the network.</summary>
public async Task<IReadOnlyList<(string InstanceName, DateTime CreatedAt, DateTime? SentAt, string Payload)>> GetLatestSubmissionsAsync(CancellationToken cancellationToken = default)
{
await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false);
var rows = await connection.QueryAsync<(string, DateTime, DateTime?, string)>("""
SELECT o.InstanceName, o.CreatedAt, o.SentAt, o.Payload
FROM insight.OutboundSubmissions o
WHERE o.OutboundId = (SELECT MAX(x.OutboundId) FROM insight.OutboundSubmissions x WHERE x.InstanceName = o.InstanceName)
ORDER BY o.InstanceName;
""").ConfigureAwait(false);
return rows.ToList();
}
}

View file

@ -68,6 +68,7 @@ public sealed partial class InsightRepository
await DeleteInBatchesAsync(connection, "insight.BlockingEvents", "SampledAt < @cutoff", events).ConfigureAwait(false);
await DeleteInBatchesAsync(connection, "insight.Deadlocks", "OccurredAt < @cutoff", events).ConfigureAwait(false);
await DeleteInBatchesAsync(connection, "insight.Findings", "ResolvedAt < @cutoff", resolved).ConfigureAwait(false);
await DeleteInBatchesAsync(connection, "insight.OutboundSubmissions", "COALESCE(SentAt, GaveUpAt) < @cutoff", utc.AddDays(-7)).ConfigureAwait(false);
}
private static async Task DeleteInBatchesAsync(Microsoft.Data.SqlClient.SqlConnection connection, string table, string predicate, DateTime cutoff)

View file

@ -0,0 +1,34 @@
/* Outbound queue to SQLInsight Hub. Each row is the full state of one instance, so only the newest unsent row per
instance matters; older unsent rows are removed when a new one is queued. */
CREATE TABLE insight.OutboundSubmissions
(
OutboundId bigint IDENTITY(1, 1) NOT NULL CONSTRAINT PK_OutboundSubmissions PRIMARY KEY,
SubmissionId uniqueidentifier NOT NULL CONSTRAINT UQ_OutboundSubmissions_SubmissionId UNIQUE,
InstanceName nvarchar(128) NOT NULL,
CreatedAt datetime2(0) NOT NULL,
Payload nvarchar(max) NOT NULL,
Attempts int NOT NULL CONSTRAINT DF_OutboundSubmissions_Attempts DEFAULT (0),
NextAttemptAt datetime2(0) NOT NULL,
LastError nvarchar(max) NULL,
SentAt datetime2(0) NULL,
GaveUpAt datetime2(0) NULL
);
CREATE INDEX IX_OutboundSubmissions_Due ON insight.OutboundSubmissions (NextAttemptAt) WHERE SentAt IS NULL AND GaveUpAt IS NULL;
/* What the agent last knew about the license and the hub, for the web UI. One row. */
CREATE TABLE insight.HubStatus
(
Id tinyint NOT NULL CONSTRAINT PK_HubStatus PRIMARY KEY CONSTRAINT CK_HubStatus_Single CHECK (Id = 1),
Enabled bit NOT NULL,
HubUrl nvarchar(400) NULL,
LicenseState varchar(30) NOT NULL,
LicenseMessage nvarchar(1000) NOT NULL,
Customer nvarchar(200) NULL,
LicensePlan varchar(30) NULL,
LicenseExpires datetime2(0) NULL,
SendSensitive bit NOT NULL,
LastSentAt datetime2(0) NULL,
LastError nvarchar(max) NULL,
LastErrorAt datetime2(0) NULL,
UpdatedAt datetime2(0) NOT NULL
);

View file

@ -138,7 +138,7 @@ public sealed class FailedJobRule : Rule
+ "Sørg for, at fejlende jobs sender en notifikation til en operator.",
[
new Evidence("Fejlende trin", j.LastFailedStepName ?? "–"),
new Evidence("Fejl", j.LastFailedStepMessage ?? "–"),
new Evidence("Fejl", j.LastFailedStepMessage ?? "–", Sensitive: true),
new Evidence("Fejl det seneste døgn", j.FailuresLastDay.ToString(System.Globalization.CultureInfo.InvariantCulture)),
],
subject: $"job: {j.Name}",

View file

@ -33,14 +33,14 @@ public sealed class BlockingRule : Rule
var idleInTransaction = head.Status == "sleeping" && head.OpenTransactionCount > 0;
var evidence = new List<Evidence>
{
new("Head blocker", $"session {head.SessionId} ({head.Status}), {head.LoginName} fra {head.HostName} / {head.ProgramName}"),
new("Head blocker", $"session {head.SessionId} ({head.Status}), {head.LoginName} fra {head.HostName} / {head.ProgramName}", Sensitive: true),
new("Åbne transaktioner", head.OpenTransactionCount.ToString(CultureInfo.InvariantCulture)),
new("Blokerede sessioner", victims.Count.ToString(CultureInfo.InvariantCulture)),
new("Længste ventetid", $"{Format.Number(longest / 1000.0)} s"),
new("Head blockers SQL", head.SqlText ?? "(ukendt)"),
new("Head blockers SQL", head.SqlText ?? "(ukendt)", Sensitive: true),
};
evidence.AddRange(victims.OrderByDescending(v => v.WaitTimeMs).Take(3).Select(v =>
new Evidence($"Blokeret session {v.SessionId}", $"{v.WaitType} på {v.WaitResource}: {v.SqlText}")));
new Evidence($"Blokeret session {v.SessionId}", $"{v.WaitType} på {v.WaitResource}: {v.SqlText}", Sensitive: true)));
findings.Add(CreateFinding(
longest >= 300_000 || victims.Count >= 10 ? Severity.High : Severity.Medium,
@ -112,7 +112,7 @@ public sealed class DeadlockRule : Rule
{
evidence.Add(new Evidence(
$"{deadlock.Time:yyyy-MM-dd HH:mm:ss} {(p.IsVictim ? "offer" : "vinder")} session {p.SessionId}",
$"{p.LockMode} på {p.WaitResource}, {p.ClientApp}: {p.InputBuffer}"));
$"{p.LockMode} på {p.WaitResource}, {p.ClientApp}: {p.InputBuffer}", Sensitive: true));
}
}

View file

@ -28,7 +28,7 @@ public abstract class PlanRule : Rule
protected static List<Evidence> Basics(AnalyzedStatement s) =>
[
new("Statement", QueryStoreText.Shorten(s.StatementText)),
new("Statement", QueryStoreText.Shorten(s.StatementText), Sensitive: true),
new("Database", s.DatabaseName ?? "?"),
new("Udførelser", Format.Number(s.Executions)),
new("CPU i alt / gns.", $"{Format.Number(s.TotalWorkerTimeUs / 1_000_000.0)} s / {QueryStoreText.Ms(s.AverageWorkerTimeUs)}"),
@ -50,7 +50,7 @@ public sealed class ImplicitConversionRule : PlanRule
}
var evidence = Basics(s);
evidence.AddRange(s.Warnings.SeekPreventingConversions.Take(5).Select(c => new Evidence("Konvertering", c)));
evidence.AddRange(s.Warnings.SeekPreventingConversions.Take(5).Select(c => new Evidence("Konvertering", c, Sensitive: true)));
return CreateFinding(
Severity.Medium,
$"Implicit konvertering forhindrer index seek i en tung forespørgsel ({s.DatabaseName})",

View file

@ -83,7 +83,7 @@ public sealed class PlanRegressionRule : Rule
+ "forceres som akut løsning, og årsagen findes bagefter. Følg op på, om forceringen lykkes (se SQLI-QS-002). "
+ "På Enterprise Edition kan automatic tuning (FORCE_LAST_GOOD_PLAN) gøre dette automatisk.",
[
new Evidence("Forespørgsel", QueryStoreText.Describe(slow.QueryText is null ? best : slow)),
new Evidence("Forespørgsel", QueryStoreText.Describe(slow.QueryText is null ? best : slow), Sensitive: true),
new Evidence($"Ny plan {slow.PlanId} (seneste døgn)", $"{Format.Number(slow.Executions)} udførelser, gns. {QueryStoreText.Ms(slow.AverageDurationUs)}, {Format.Number(slow.AverageLogicalReads)} læsninger"),
new Evidence($"Bedste plan {best.PlanId} (ugen før)", $"{Format.Number(best.Executions)} udførelser, gns. {QueryStoreText.Ms(best.AverageDurationUs)}, {Format.Number(best.AverageLogicalReads)} læsninger"),
new Evidence("Ekstra tid seneste døgn", QueryStoreText.Seconds(worst.Extra)),
@ -120,7 +120,7 @@ public sealed class FailedForcedPlanRule : Rule
? "Genopret det index, planen bruger (se planens XML), eller fjern forceringen og find en ny god plan."
: "Fjern forceringen, og vurdér forespørgslen igen. Se last_force_failure_reason_desc i sys.query_store_plan.",
[
new Evidence("Forespørgsel", QueryStoreText.Shorten(f.QueryText)),
new Evidence("Forespørgsel", QueryStoreText.Shorten(f.QueryText), Sensitive: true),
new Evidence("Fejl", $"{Format.Number(f.FailureCount)} gange, senest {f.Reason}"),
],
subject: $"query: {db.Name}/{f.QueryId}",
@ -170,7 +170,7 @@ public sealed class ParameterSensitivityRule : Rule
continue;
}
var evidence = new List<Evidence> { new("Forespørgsel", QueryStoreText.Describe(busiest)) };
var evidence = new List<Evidence> { new("Forespørgsel", QueryStoreText.Describe(busiest), Sensitive: true) };
evidence.AddRange(plans.OrderBy(p => p.AverageDurationUs).Select(p => new Evidence(
$"Plan {p.PlanId}",
$"{Format.Number(p.Executions)} udførelser, gns. {QueryStoreText.Ms(p.AverageDurationUs)}, min. {QueryStoreText.Ms(p.MinDurationUs)}, maks. {QueryStoreText.Ms(p.MaxDurationUs)}")));

View file

@ -0,0 +1,14 @@
<Project Sdk="Microsoft.NET.Sdk">
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" />
<PackageReference Include="xunit" />
<PackageReference Include="xunit.runner.visualstudio" />
<PackageReference Include="coverlet.collector" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\SQLInsight.Agent\SQLInsight.Agent.csproj" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
</Project>

View file

@ -0,0 +1,87 @@
using SQLInsight.Agent.Hub;
using SQLInsight.Contracts;
using SQLInsight.Core.Findings;
using SQLInsight.Core.Instances;
using SQLInsight.Repository;
using SQLInsight.Rules.Engine;
namespace SQLInsight.Agent.Tests;
public class SubmissionBuilderTests
{
private static readonly DateTimeOffset Now = new(2026, 9, 29, 12, 0, 0, TimeSpan.Zero);
private static readonly InstanceInfo Info = new()
{
ServerName = "SQL01.corp.local",
Version = SqlServerVersion.Parse("15.0.4490.9"),
ProductLevel = "RTM",
ProductUpdateLevel = "CU32",
Edition = "Enterprise Edition",
EngineEdition = EngineEdition.Enterprise,
};
private static readonly StoredFinding Blocking = new()
{
FindingId = 1,
RuleId = "SQLI-BLK-001",
Subject = "session: 51",
Severity = Severity.High,
Title = "Session 51 blokerer",
Explanation = "Forklaring",
Recommendation = "Anbefaling",
FirstSeenAt = Now.UtcDateTime.AddMinutes(-5),
Evidence =
[
new Evidence("Blokerede sessioner", "3"),
new Evidence("Head blockers SQL", "UPDATE dbo.Salaries SET Amount = 100000 WHERE Name = 'Hansen'", Sensitive: true),
],
};
private static RuleResult[] Results => [new RuleResult { RuleId = "SQLI-BLK-001", RuleTitle = "t", Outcome = RuleOutcome.Findings }];
[Fact]
public void Sensitive_evidence_and_server_name_stay_on_prem_by_default()
{
var submission = SubmissionBuilder.Build("prod-sql01", Info, Results, [Blocking], new HubOptions(), Now);
var evidence = Assert.Single(submission.Findings).Evidence;
Assert.Equal("3", evidence[0].Value);
Assert.Equal(FindingsSubmission.Redacted, evidence[1].Value);
Assert.Null(submission.Instance.ServerName);
Assert.Equal("prod-sql01", submission.Instance.InstanceKey);
Assert.Equal("SQL Server 2019", submission.Instance.Release);
}
[Fact]
public void Customer_can_allow_sensitive_evidence_and_server_name()
{
var options = new HubOptions { SendSensitiveEvidence = true, SendServerName = true };
var submission = SubmissionBuilder.Build("prod-sql01", Info, Results, [Blocking], options, Now);
Assert.StartsWith("UPDATE dbo.Salaries", submission.Findings[0].Evidence[1].Value, StringComparison.Ordinal);
Assert.Equal("SQL01.corp.local", submission.Instance.ServerName);
}
[Fact]
public void Every_submission_has_a_new_id_and_carries_rule_outcomes()
{
var a = SubmissionBuilder.Build("x", Info, Results, [], new HubOptions(), Now);
var b = SubmissionBuilder.Build("x", Info, Results, [], new HubOptions(), Now);
Assert.NotEqual(a.SubmissionId, b.SubmissionId);
Assert.Equal("Findings", Assert.Single(a.Rules).Outcome);
}
[Theory]
[InlineData(0, 1)]
[InlineData(1, 2)]
[InlineData(3, 8)]
[InlineData(10, 60)]
[InlineData(50, 60)]
public void Backoff_doubles_up_to_an_hour(int attempts, int minutes)
{
Assert.Equal(TimeSpan.FromMinutes(minutes), HubSyncService.Backoff(attempts));
}
}