elsa-core/src/modules/Elsa.Diagnostics.StructuredLogs.Persistence.Relational/Services/RelationalStructuredLogSqlBuilder.cs
Sipke Schoorstra 827ad6bc6b
[codex] Add structured log SQLite persistence (#7445)
* Add structured log persistence spec

* Clarify structured log persistence spec

* Plan structured log persistence implementation

* Regenerate structured log persistence tasks

* Address structured log persistence analysis findings

* Add structured log SQLite persistence

* Address structured log persistence review

* Harden structured log write buffer shutdown

* Start structured log SQLite migrations before buffer
2026-05-13 15:57:06 +02:00

137 lines
6.1 KiB
C#

using Elsa.Diagnostics.StructuredLogs.Models;
using Elsa.Diagnostics.StructuredLogs.Persistence.Relational.Contracts;
namespace Elsa.Diagnostics.StructuredLogs.Persistence.Relational.Services;
public class RelationalStructuredLogSqlBuilder(IRelationalStructuredLogDialect dialect)
{
private readonly string _table = dialect.QuoteIdentifier("StructuredLogEvents");
public string BuildInsert()
{
var columns = Columns;
var columnList = string.Join(", ", columns.Select(dialect.QuoteIdentifier));
var parameterList = string.Join(", ", columns.Select(x => $"{dialect.ParameterPrefix}{x}"));
return $"INSERT INTO {_table} ({columnList}) VALUES ({parameterList})";
}
public QueryDefinition BuildQuery(StructuredLogFilter filter)
{
var parameters = new Dictionary<string, object?>();
var predicates = BuildFilterPredicates(filter, parameters);
var where = predicates.Count == 0 ? "" : $" WHERE {string.Join(" AND ", predicates)}";
var limit = Math.Clamp(filter.Take ?? 100, 0, 1000);
var sql = $"SELECT {string.Join(", ", Columns.Select(dialect.QuoteIdentifier))} FROM {_table}{where} ORDER BY {dialect.QuoteIdentifier("Timestamp")} DESC, {dialect.QuoteIdentifier("ReceivedAt")} DESC, {dialect.QuoteIdentifier("Sequence")} DESC, {dialect.QuoteIdentifier("Id")} DESC";
sql = dialect.ApplyLimit(sql, limit);
return new(sql, parameters);
}
public string BuildListSources()
{
var sourceId = dialect.QuoteIdentifier("SourceId");
var receivedAt = dialect.QuoteIdentifier("ReceivedAt");
return $"SELECT {sourceId}, MAX({receivedAt}) AS {dialect.QuoteIdentifier("LastSeen")} FROM {_table} GROUP BY {sourceId} ORDER BY {sourceId}";
}
public QueryDefinition BuildDeleteOlderThan(string cutoff)
{
return new($"DELETE FROM {_table} WHERE {dialect.QuoteIdentifier("ReceivedAt")} < {dialect.ParameterPrefix}Cutoff", new Dictionary<string, object?> { ["Cutoff"] = cutoff });
}
public QueryDefinition BuildDeleteRowsBeyondMax(int maxRows)
{
var id = dialect.QuoteIdentifier("Id");
var receivedAt = dialect.QuoteIdentifier("ReceivedAt");
var sequence = dialect.QuoteIdentifier("Sequence");
var selectSql = $"SELECT {id} FROM {_table} ORDER BY {receivedAt} DESC, {sequence} DESC, {id} DESC";
var sql = $"DELETE FROM {_table} WHERE {id} IN ({dialect.ApplyOffset(selectSql, maxRows)})";
return new(sql, new Dictionary<string, object?>());
}
private List<string> BuildFilterPredicates(StructuredLogFilter filter, IDictionary<string, object?> parameters)
{
var predicates = new List<string>();
if (filter.MinimumLevel is { } minimumLevel)
AddPredicate(predicates, parameters, "Level", ">=", (int)minimumLevel);
if (filter.Levels is { Count: > 0 })
{
var names = filter.Levels.Select((level, index) =>
{
var name = $"Level{index}";
parameters[name] = (int)level;
return $"{dialect.ParameterPrefix}{name}";
});
predicates.Add($"{dialect.QuoteIdentifier("Level")} IN ({string.Join(", ", names)})");
}
if (!string.IsNullOrWhiteSpace(filter.CategoryPrefix))
AddPredicate(predicates, parameters, "Category", "LIKE", $"{filter.CategoryPrefix}%");
if (!string.IsNullOrWhiteSpace(filter.Text))
{
parameters["Text"] = $"%{filter.Text}%";
var textParameter = $"{dialect.ParameterPrefix}Text";
predicates.Add($"({dialect.QuoteIdentifier("Message")} LIKE {textParameter} OR {dialect.QuoteIdentifier("MessageTemplate")} LIKE {textParameter} OR {dialect.QuoteIdentifier("Category")} LIKE {textParameter} OR {dialect.QuoteIdentifier("ExceptionJson")} LIKE {textParameter} OR {dialect.QuoteIdentifier("ScopesJson")} LIKE {textParameter} OR {dialect.QuoteIdentifier("PropertiesJson")} LIKE {textParameter})");
}
AddStringPredicate(predicates, parameters, "TenantId", filter.TenantId);
AddStringPredicate(predicates, parameters, "WorkflowDefinitionId", filter.WorkflowDefinitionId);
AddStringPredicate(predicates, parameters, "WorkflowInstanceId", filter.WorkflowInstanceId);
AddStringPredicate(predicates, parameters, "TraceId", filter.TraceId);
AddStringPredicate(predicates, parameters, "SpanId", filter.SpanId);
AddStringPredicate(predicates, parameters, "CorrelationId", filter.CorrelationId);
AddStringPredicate(predicates, parameters, "SourceId", filter.SourceId);
if (filter.From is { } from)
AddPredicate(predicates, parameters, "Timestamp", ">=", RelationalStructuredLogMapper.FormatTimestamp(from), "TimestampFrom");
if (filter.To is { } to)
AddPredicate(predicates, parameters, "Timestamp", "<=", RelationalStructuredLogMapper.FormatTimestamp(to), "TimestampTo");
return predicates;
}
private void AddStringPredicate(ICollection<string> predicates, IDictionary<string, object?> parameters, string column, string? value)
{
if (string.IsNullOrWhiteSpace(value))
return;
AddPredicate(predicates, parameters, column, "=", value);
}
private void AddPredicate(ICollection<string> predicates, IDictionary<string, object?> parameters, string column, string op, object value, string? parameterName = null)
{
var name = parameterName ?? column;
predicates.Add($"{dialect.QuoteIdentifier(column)} {op} {dialect.ParameterPrefix}{name}");
parameters[name] = value;
}
private static readonly string[] Columns =
[
"Id",
"Sequence",
"Timestamp",
"ReceivedAt",
"Level",
"Category",
"EventId",
"EventName",
"Message",
"MessageTemplate",
"ExceptionJson",
"ScopesJson",
"PropertiesJson",
"TraceId",
"SpanId",
"CorrelationId",
"TenantId",
"WorkflowDefinitionId",
"WorkflowInstanceId",
"SourceId"
];
}
public record QueryDefinition(string Sql, IReadOnlyDictionary<string, object?> Parameters);