using System.Globalization; using System.Reflection; using System.Text; using SQLInsight.Core.Findings; using SQLInsight.Repository; namespace SQLInsight.Web.Api; /// /// Read-only integration: JSON for scripts and other tools, and the Prometheus text format for Prometheus/Grafana. /// public static class IntegrationEndpoints { /// An instance counts as up when the last contact succeeded and is this recent. public static readonly TimeSpan UpWindow = TimeSpan.FromMinutes(10); public static void Map(IEndpointRouteBuilder app) { var api = app.MapGroup("/api/v1").DisableAntiforgery().WithTags("SQLInsight Pro API v1").RequireRead(); api.MapGet("/instances", async (InsightRepository repository, CancellationToken ct) => (await repository.GetInstancesAsync(ct).ConfigureAwait(false)).Select(i => new { i.Name, i.ProductVersion, i.ProductLevel, i.ProductUpdateLevel, i.Edition, i.LastSeenAt, Up = IsUp(i), i.LastError, Findings = new { i.Critical, i.High, i.Medium, i.Low, i.Info }, i.NotMeasurable, })) .WithSummary("Instanser med status og antal fund / Instances"); app.MapGet("/metrics", async (InsightRepository repository, CancellationToken ct) => Results.Text(await PrometheusAsync(repository, ct).ConfigureAwait(false), "text/plain; version=0.0.4; charset=utf-8")).RequireRead(); } private static bool IsUp(InstanceOverview i) => !i.IsFailing && i.LastSeenAt is { } seen && DateTime.UtcNow - seen < UpWindow; public static async Task PrometheusAsync(InsightRepository repository, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(repository); var instances = await repository.GetInstancesAsync(cancellationToken).ConfigureAwait(false); var findings = await repository.GetActiveFindingsAsync(cancellationToken: cancellationToken).ConfigureAwait(false); var metrics = await repository.GetLatestMetricsAsync(TimeSpan.FromMinutes(15), cancellationToken).ConfigureAwait(false); var update = await repository.GetUpdateStatusAsync(cancellationToken).ConfigureAwait(false); var text = new StringBuilder(); var version = typeof(IntegrationEndpoints).Assembly.GetCustomAttribute()?.InformationalVersion.Split('+')[0] ?? "0.0.0"; Family(text, "sqlinsight_build_info", "Version of SQLInsight Pro and the rule pack in use."); Sample(text, "sqlinsight_build_info", [("version", version), ("rulepack", update?.RulePackVersion ?? "")], 1); Family(text, "sqlinsight_instance_up", "1 if the last contact with the instance succeeded within 10 minutes."); foreach (var i in instances) { Sample(text, "sqlinsight_instance_up", [("instance", i.Name)], IsUp(i) ? 1 : 0); } Family(text, "sqlinsight_instance_last_seen_timestamp_seconds", "Unix time of the last successful contact."); foreach (var i in instances.Where(i => i.LastSeenAt is not null)) { Sample(text, "sqlinsight_instance_last_seen_timestamp_seconds", [("instance", i.Name)], new DateTimeOffset(DateTime.SpecifyKind(i.LastSeenAt!.Value, DateTimeKind.Utc)).ToUnixTimeSeconds()); } Family(text, "sqlinsight_instance_info", "Version and edition of each instance."); foreach (var i in instances) { Sample(text, "sqlinsight_instance_info", [("instance", i.Name), ("version", i.ProductVersion ?? ""), ("level", $"{i.ProductLevel} {i.ProductUpdateLevel}".Trim()), ("edition", i.Edition ?? "")], 1); } Family(text, "sqlinsight_findings", "Active findings per instance and severity."); foreach (var i in instances) { foreach (var (severity, count) in new[] { ("critical", i.Critical), ("high", i.High), ("medium", i.Medium), ("low", i.Low), ("info", i.Info) }) { Sample(text, "sqlinsight_findings", [("instance", i.Name), ("severity", severity)], count); } } Family(text, "sqlinsight_rule_findings", "Active findings per instance and rule."); foreach (var group in findings.GroupBy(f => (f.InstanceName, f.RuleId)).OrderBy(g => g.Key)) { Sample(text, "sqlinsight_rule_findings", [("instance", group.Key.InstanceName), ("rule", group.Key.RuleId), ("severity", group.Max(f => f.Severity).ToString().ToLowerInvariant())], group.Count()); } Family(text, "sqlinsight_rules_not_measurable", "Rules that could not be evaluated (version, permissions or errors)."); foreach (var i in instances) { Sample(text, "sqlinsight_rules_not_measurable", [("instance", i.Name)], i.NotMeasurable); } Family(text, "sqlinsight_metric", "Newest sample of each collected metric (cpu.sqlserver in %, memory.ple in s, workload.* per second, …)."); foreach (var m in metrics) { Sample(text, "sqlinsight_metric", [("instance", m.Instance), ("metric", m.Metric)], m.Value); } return text.ToString(); } private static void Family(StringBuilder text, string name, string help) => text.Append("# HELP ").Append(name).Append(' ').Append(help).Append('\n').Append("# TYPE ").Append(name).Append(" gauge\n"); private static void Sample(StringBuilder text, string name, (string Name, string Value)[] labels, double value) { text.Append(name).Append('{'); for (var i = 0; i < labels.Length; i++) { text.Append(i == 0 ? "" : ",").Append(labels[i].Name).Append("=\"").Append(Escape(labels[i].Value)).Append('"'); } text.Append("} ").Append(value.ToString("R", CultureInfo.InvariantCulture)).Append('\n'); } /// Label value escaping from the Prometheus text format: backslash, double quote and newline. public static string Escape(string value) => value.Replace("\\", "\\\\", StringComparison.Ordinal).Replace("\"", "\\\"", StringComparison.Ordinal).Replace("\n", "\\n", StringComparison.Ordinal); }