diff --git a/SQLInsight.slnx b/SQLInsight.slnx
index e6114fe..02b0e57 100644
--- a/SQLInsight.slnx
+++ b/SQLInsight.slnx
@@ -13,6 +13,7 @@
+
diff --git a/src/SQLInsight.Agent/Hub/HubOptions.cs b/src/SQLInsight.Agent/Hub/HubOptions.cs
new file mode 100644
index 0000000..174bad3
--- /dev/null
+++ b/src/SQLInsight.Agent/Hub/HubOptions.cs
@@ -0,0 +1,33 @@
+namespace SQLInsight.Agent.Hub;
+
+///
+/// Findings sync to SQLInsight Hub (Professional and Enterprise). Off by default; nothing leaves the network unless
+/// it is switched on.
+///
+public sealed class HubOptions
+{
+ public const string SectionName = "SQLInsight:Hub";
+
+ public bool Enabled { get; set; }
+
+ /// Base URL of SQLInsight Hub, e.g. https://hub.seosoft.dk/.
+ public Uri? Url { get; set; }
+
+ /// The license text (SQLI1.…).
+ public string? License { get; set; }
+
+ ///
+ /// Also send evidence marked sensitive: query text, logins, host and program names, job error messages.
+ /// Off by default; such values are sent as "[udeladt]".
+ ///
+ public bool SendSensitiveEvidence { get; set; }
+
+ /// Also send the server's network name. Off by default; the instance is identified by its configured name.
+ public bool SendServerName { get; set; }
+
+ /// How often the state is sent when nothing changed. Changes are sent at once.
+ public TimeSpan SubmitInterval { get; set; } = TimeSpan.FromMinutes(15);
+
+ /// Additional public keys trusted for licenses (key id → base64 SubjectPublicKeyInfo).
+ public IDictionary TrustedKeys { get; } = new Dictionary();
+}
diff --git a/src/SQLInsight.Agent/Hub/HubOutbox.cs b/src/SQLInsight.Agent/Hub/HubOutbox.cs
new file mode 100644
index 0000000..15c626f
--- /dev/null
+++ b/src/SQLInsight.Agent/Hub/HubOutbox.cs
@@ -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;
+
+///
+/// Queues an instance's state for the hub: at once when findings changed, otherwise at most every SubmitInterval.
+///
+public sealed class HubOutbox(IOptions options, InsightRepository repository)
+{
+ public static readonly JsonSerializerOptions Json = new(JsonSerializerDefaults.Web);
+
+ private readonly Dictionary _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 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);
+ }
+}
diff --git a/src/SQLInsight.Agent/Hub/HubSyncService.cs b/src/SQLInsight.Agent/Hub/HubSyncService.cs
new file mode 100644
index 0000000..8bc4100
--- /dev/null
+++ b/src/SQLInsight.Agent/Hub/HubSyncService.cs
@@ -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;
+
+///
+/// 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.
+///
+public sealed partial class HubSyncService(
+ IOptions options,
+ InsightRepository repository,
+ IHttpClientFactory httpClientFactory,
+ TimeProvider time,
+ ILogger 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));
+ }
+
+ /// One delivery round. Returns how many submissions were sent.
+ public async Task 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);
+
+ /// 1, 2, 4 … minutes, at most an hour.
+ 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);
+}
diff --git a/src/SQLInsight.Agent/Hub/SubmissionBuilder.cs b/src/SQLInsight.Agent/Hub/SubmissionBuilder.cs
new file mode 100644
index 0000000..dbb6026
--- /dev/null
+++ b/src/SQLInsight.Agent/Hub/SubmissionBuilder.cs
@@ -0,0 +1,56 @@
+using SQLInsight.Contracts;
+using SQLInsight.Core.Instances;
+using SQLInsight.Repository;
+using SQLInsight.Rules.Engine;
+
+namespace SQLInsight.Agent.Hub;
+
+///
+/// Turns an instance's current state into a hub submission, leaving out what the customer has not allowed.
+///
+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 results, IReadOnlyList 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(),
+ };
+ }
+}
diff --git a/src/SQLInsight.Agent/Monitoring/InstanceMonitor.cs b/src/SQLInsight.Agent/Monitoring/InstanceMonitor.cs
index c6e1a13..c772d82 100644
--- a/src/SQLInsight.Agent/Monitoring/InstanceMonitor.cs
+++ b/src/SQLInsight.Agent/Monitoring/InstanceMonitor.cs
@@ -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)
diff --git a/src/SQLInsight.Agent/Monitoring/MonitoringService.cs b/src/SQLInsight.Agent/Monitoring/MonitoringService.cs
index 75bfb0b..7425fd7 100644
--- a/src/SQLInsight.Agent/Monitoring/MonitoringService.cs
+++ b/src/SQLInsight.Agent/Monitoring/MonitoringService.cs
@@ -18,7 +18,8 @@ public sealed partial class MonitoringService(
TimeProvider timeProvider,
ILoggerFactory loggerFactory,
NotificationDispatcher notifications,
- IOptions notificationOptions) : BackgroundService
+ IOptions notificationOptions,
+ Hub.HubOutbox hubOutbox) : BackgroundService
{
private readonly ILogger _logger = loggerFactory.CreateLogger();
@@ -30,7 +31,7 @@ public sealed partial class MonitoringService(
var intervals = new Dictionary(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)
{
diff --git a/src/SQLInsight.Agent/Program.cs b/src/SQLInsight.Agent/Program.cs
index 255cbda..dc6adea 100644
--- a/src/SQLInsight.Agent/Program.cs
+++ b/src/SQLInsight.Agent/Program.cs
@@ -51,6 +51,10 @@ builder.Services.AddSingleton(sp =>
return new NotificationDispatcher(channels, options, sp.GetRequiredService(), sp.GetRequiredService>());
});
+builder.Services.Configure(builder.Configuration.GetSection(SQLInsight.Agent.Hub.HubOptions.SectionName));
+builder.Services.AddHttpClient("hub", c => c.Timeout = TimeSpan.FromSeconds(30));
+builder.Services.AddSingleton();
+builder.Services.AddHostedService();
builder.Services.AddHostedService();
var host = builder.Build();
diff --git a/src/SQLInsight.Agent/SQLInsight.Agent.csproj b/src/SQLInsight.Agent/SQLInsight.Agent.csproj
index fbe7931..9276c54 100644
--- a/src/SQLInsight.Agent/SQLInsight.Agent.csproj
+++ b/src/SQLInsight.Agent/SQLInsight.Agent.csproj
@@ -10,6 +10,8 @@
+
+
diff --git a/src/SQLInsight.Agent/appsettings.json b/src/SQLInsight.Agent/appsettings.json
index 037953b..b939261 100644
--- a/src/SQLInsight.Agent/appsettings.json
+++ b/src/SQLInsight.Agent/appsettings.json
@@ -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,
diff --git a/src/SQLInsight.Contracts/FindingsSubmission.cs b/src/SQLInsight.Contracts/FindingsSubmission.cs
index 7b41204..08b340a 100644
--- a/src/SQLInsight.Contracts/FindingsSubmission.cs
+++ b/src/SQLInsight.Contracts/FindingsSubmission.cs
@@ -1,38 +1,50 @@
namespace SQLInsight.Contracts;
///
-/// 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
-/// 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.
///
///
-/// 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
+/// for anything else. Sensitive evidence (query text, logins, hosts, messages) is
+/// replaced by unless the customer allows sending it.
///
public sealed record FindingsSubmission
{
public const int CurrentSchemaVersion = 1;
+ public const string Redacted = "[udeladt]";
public int SchemaVersion { get; init; } = CurrentSchemaVersion;
+
+ /// Unique per submission; the hub ignores a submission it has already processed.
+ 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 Results { get; init; }
+ public required IReadOnlyList Rules { get; init; }
+ public required IReadOnlyList Findings { get; init; }
}
public sealed record InstanceSnapshot
{
- /// A stable, customer-chosen name for the instance. The hub keys history on it.
+ /// The customer's name for the instance (SQLInsight:Instances:Name). The hub keys history on it.
public required string InstanceKey { get; init; }
+ /// SQL Server release, e.g. "SQL Server 2019".
+ 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; }
+
+ /// The server's network name; only sent when the customer allows it.
+ 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 Findings { get; init; } = [];
}
public sealed record FindingDto
{
+ public required string RuleId { get; init; }
+
+ /// Identifies the finding within the rule (e.g. "database: Sales"); empty for instance-wide findings.
+ public required string Subject { get; init; }
+
/// Info, Low, Medium, High or Critical.
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 Evidence { get; init; } = [];
}
public sealed record EvidenceDto(string Name, string Value);
+
+/// The hub's answer.
+public sealed record SubmissionReceipt(Guid SubmissionId, bool Duplicate, int ActiveFindings);
diff --git a/src/SQLInsight.Core/Findings/Finding.cs b/src/SQLInsight.Core/Findings/Finding.cs
index 5f77a4d..af24d1a 100644
--- a/src/SQLInsight.Core/Findings/Finding.cs
+++ b/src/SQLInsight.Core/Findings/Finding.cs
@@ -31,6 +31,10 @@ public sealed record Finding
public IReadOnlyList References { get; init; } = [];
}
-public sealed record Evidence(string Name, string Value);
+///
+/// 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.
+///
+public sealed record Evidence(string Name, string Value, bool Sensitive = false);
public sealed record Reference(string Title, Uri Url);
diff --git a/src/SQLInsight.Repository/InsightRepository.Hub.cs b/src/SQLInsight.Repository/InsightRepository.Hub.cs
new file mode 100644
index 0000000..ebb0c0a
--- /dev/null
+++ b/src/SQLInsight.Repository/InsightRepository.Hub.cs
@@ -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
+{
+ /// Queues the newest full state for an instance, replacing any older unsent state for it.
+ 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> GetDueSubmissionsAsync(DateTimeOffset now, int max = 20, CancellationToken cancellationToken = default)
+ {
+ await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false);
+ var rows = await connection.QueryAsync("""
+ 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);
+ }
+
+ /// Records a failed attempt. With null the submission is given up.
+ 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 GetHubStatusAsync(CancellationToken cancellationToken = default)
+ {
+ await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false);
+ return await connection.QuerySingleOrDefaultAsync("""
+ 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);
+ }
+
+ /// The last submission sent (or queued) per instance, so the UI can show exactly what leaves the network.
+ public async Task> 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();
+ }
+}
diff --git a/src/SQLInsight.Repository/InsightRepository.Maintenance.cs b/src/SQLInsight.Repository/InsightRepository.Maintenance.cs
index 832c146..6a38ad1 100644
--- a/src/SQLInsight.Repository/InsightRepository.Maintenance.cs
+++ b/src/SQLInsight.Repository/InsightRepository.Maintenance.cs
@@ -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)
diff --git a/src/SQLInsight.Repository/Migrations/0002_hub_sync.sql b/src/SQLInsight.Repository/Migrations/0002_hub_sync.sql
new file mode 100644
index 0000000..c491ed2
--- /dev/null
+++ b/src/SQLInsight.Repository/Migrations/0002_hub_sync.sql
@@ -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
+);
diff --git a/src/SQLInsight.Rules/Availability/AvailabilityRules.cs b/src/SQLInsight.Rules/Availability/AvailabilityRules.cs
index 9f33735..77b0317 100644
--- a/src/SQLInsight.Rules/Availability/AvailabilityRules.cs
+++ b/src/SQLInsight.Rules/Availability/AvailabilityRules.cs
@@ -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}",
diff --git a/src/SQLInsight.Rules/Concurrency/ConcurrencyRules.cs b/src/SQLInsight.Rules/Concurrency/ConcurrencyRules.cs
index f20353a..a81c248 100644
--- a/src/SQLInsight.Rules/Concurrency/ConcurrencyRules.cs
+++ b/src/SQLInsight.Rules/Concurrency/ConcurrencyRules.cs
@@ -33,14 +33,14 @@ public sealed class BlockingRule : Rule
var idleInTransaction = head.Status == "sleeping" && head.OpenTransactionCount > 0;
var evidence = new List
{
- 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));
}
}
diff --git a/src/SQLInsight.Rules/Performance/PlanRules.cs b/src/SQLInsight.Rules/Performance/PlanRules.cs
index 71c14e5..471ae9e 100644
--- a/src/SQLInsight.Rules/Performance/PlanRules.cs
+++ b/src/SQLInsight.Rules/Performance/PlanRules.cs
@@ -28,7 +28,7 @@ public abstract class PlanRule : Rule
protected static List 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})",
diff --git a/src/SQLInsight.Rules/Performance/QueryStoreRules.cs b/src/SQLInsight.Rules/Performance/QueryStoreRules.cs
index 93c6f0d..f9d992c 100644
--- a/src/SQLInsight.Rules/Performance/QueryStoreRules.cs
+++ b/src/SQLInsight.Rules/Performance/QueryStoreRules.cs
@@ -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 { new("Forespørgsel", QueryStoreText.Describe(busiest)) };
+ var evidence = new List { 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)}")));
diff --git a/tests/SQLInsight.Agent.Tests/SQLInsight.Agent.Tests.csproj b/tests/SQLInsight.Agent.Tests/SQLInsight.Agent.Tests.csproj
new file mode 100644
index 0000000..79dc568
--- /dev/null
+++ b/tests/SQLInsight.Agent.Tests/SQLInsight.Agent.Tests.csproj
@@ -0,0 +1,14 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/tests/SQLInsight.Agent.Tests/SubmissionBuilderTests.cs b/tests/SQLInsight.Agent.Tests/SubmissionBuilderTests.cs
new file mode 100644
index 0000000..0398889
--- /dev/null
+++ b/tests/SQLInsight.Agent.Tests/SubmissionBuilderTests.cs
@@ -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));
+ }
+}