using System;
using System.Collections;
using System.Collections.Generic;
using System.Data;
using System.Diagnostics;
using System.Globalization;
using System.Linq;
using System.Reflection;
using System.Text.Json;
using System.Text.RegularExpressions;
using Dapper;
namespace GB5Shared.Telemetry.Database;
///
/// Single, shared factory for every DB-operation span raised anywhere in GB5 — Dapper via
/// IQueryExecutor, or any raw SqlConnection/NpgsqlConnection call site.
/// Every span carries three tags, built purely dynamically from the SQL text and the bound
/// parameter object at hand — nothing here is specific to any query, table, or module:
///
/// db.statement — the original parameterised SQL text.
/// db.params — every bound parameter name → resolved value, as JSON.
/// db.statement.runnable — the same SQL with every @param token inlined
/// as its literal value, ready to paste into SSMS/psql and run as-is.
///
/// Nothing is persisted to any table — these are OpenTelemetry span tags only, visible in
/// Zipkin/Jaeger/Tempo for the lifetime of the trace.
///
public static class DatabaseActivityHelper
{
private static readonly JsonSerializerOptions ParamJsonOptions = new() { WriteIndented = false };
private static readonly Regex ParamTokenPattern =
new(@"@(?[A-Za-z_][A-Za-z0-9_]*)", RegexOptions.Compiled);
private static readonly Regex LineCommentPattern =
new(@"--([^\r\n]*)", RegexOptions.Compiled);
///
/// Opens a client-side database span and stamps it with the full, dynamic query detail
/// (statement, parameters, runnable statement) — the one call every GB5 data-access path
/// (QueryExecutor or raw ADO.NET) should make around a command execution.
///
/// Full SQL text (or stored-procedure name) about to execute.
/// Target database / tenant schema name, if known.
/// The bound parameter object — anonymous type, POCO, DynamicParameters, or dictionary.
/// 0 (default) = never truncate. Positive value caps very long statements.
/// Database engine identifier: "mssql" or "postgresql".
/// Calling member name — set automatically via at call sites that want it.
public static Activity? StartDbActivity(
string sql,
string? databaseName = null,
object? parameters = null,
int maxStatementLength = 0,
string dbSystem = "mssql",
string? caller = null)
{
var op = DetectDbOp(sql);
// Include the calling DAL/BLL method in the span name itself (not just the db.caller
// tag) — "db select (AnalysisDAL.DynamicOutput)" reads clearly on its own, whereas a
// bare "db select" is meaningless if this span ever ends up representing a trace in a
// viewer's list (e.g. Zipkin can label a long-running trace's row using whichever span
// happened to close first, well before the enclosing endpoint span does).
var spanName = string.IsNullOrEmpty(caller)
? $"db {op.ToLowerInvariant()}"
: $"db {op.ToLowerInvariant()} ({caller})";
var activity = GB5ActivitySources.Database.StartActivity(spanName, ActivityKind.Client);
if (activity is null) return null;
activity.SetTag("db.system", dbSystem);
activity.SetTag("db.operation", op);
activity.SetTag("db.statement",
maxStatementLength > 0 && sql?.Length > maxStatementLength
? string.Concat(sql.AsSpan(0, maxStatementLength), " ...")
: sql ?? string.Empty);
var paramsJson = BuildParametersJson(parameters);
if (!string.IsNullOrEmpty(paramsJson))
activity.SetTag("db.params", paramsJson);
var runnableSql = BuildRunnableStatement(sql, parameters);
if (!string.IsNullOrEmpty(runnableSql))
activity.SetTag("db.statement.runnable",
maxStatementLength > 0 && runnableSql.Length > maxStatementLength
? string.Concat(runnableSql.AsSpan(0, maxStatementLength), " ...")
: runnableSql);
if (!string.IsNullOrEmpty(databaseName)) activity.SetTag("db.name", databaseName);
if (!string.IsNullOrEmpty(caller)) activity.SetTag("db.caller", caller);
return activity;
}
///
/// Marks a database span as failed and records exception details as span attributes/events.
///
public static void RecordDbError(Activity? activity, Exception ex)
{
if (activity is null) return;
activity.AddException(ex);
activity.SetTag("db.error.type", ex.GetType().Name);
activity.SetTag("db.error.message", ex.Message);
activity.SetStatus(ActivityStatusCode.Error, ex.Message);
}
// ── Dynamic parameter extraction / inlining ──────────────────────────────────────────
///
/// Serializes every bound parameter (name → resolved value) to a single JSON tag.
/// Works generically for Dapper anonymous objects, POCOs, ,
/// and plain dictionaries — no per-query or per-field hardcoding anywhere.
///
public static string BuildParametersJson(object? parameters)
{
var raw = ExtractParameterValues(parameters);
if (raw.Count == 0) return string.Empty;
var display = raw.ToDictionary(kv => kv.Key, kv => NormalizeParamValue(kv.Value), StringComparer.OrdinalIgnoreCase);
try
{
return JsonSerializer.Serialize(display, ParamJsonOptions);
}
catch
{
var safe = display.ToDictionary(kv => kv.Key, kv => (object?)(kv.Value?.ToString() ?? "null"));
return JsonSerializer.Serialize(safe, ParamJsonOptions);
}
}
///
/// Inlines every bound @paramName token in with its actual
/// value as a literal SQL Server expression — the resulting text can be pasted directly
/// into SSMS/psql and executed as-is. Works generically for any query/param shape;
/// unresolvable tokens (T-SQL system variables like @@ROWCOUNT, or a name with no
/// bound value) are left untouched rather than guessed at.
///
public static string? BuildRunnableStatement(string? sql, object? parameters)
{
if (string.IsNullOrEmpty(sql)) return null;
var raw = ExtractParameterValues(parameters);
if (raw.Count == 0) return null;
var inlined = ParamTokenPattern.Replace(sql, m =>
{
var name = m.Groups["name"].Value;
return raw.TryGetValue(name, out var value)
? ToSqlLiteral(value)
: m.Value;
});
// Trace viewers (Zipkin's web UI, a browser "copy", a chat client) routinely render a
// tag's \r\n as plain whitespace and collapse it onto one line. A "--" line comment
// only terminates at a real newline — once collapsed, it silently swallows every token
// after it. Block comments don't have that failure mode, so every "--" comment is
// rewritten to "/* ... */" here — the query stays valid whether or not the viewer
// preserves line breaks.
return LineCommentPattern.Replace(inlined, m => $"/*{m.Groups[1].Value} */");
}
///
/// Renders a bound parameter value as a literal SQL Server expression — the inverse of
/// parameter binding. TVP DataTables (which need a DECLARE/INSERT preamble, not a single
/// literal) are left as their original @token so the runnable statement never becomes
/// silently wrong.
///
public static string ToSqlLiteral(object? value)
{
switch (value)
{
case null:
case DBNull:
return "NULL";
case bool b:
return b ? "1" : "0";
case byte or sbyte or short or ushort or int or uint or long or ulong:
return Convert.ToString(value, CultureInfo.InvariantCulture)!;
case float or double or decimal:
return Convert.ToString(value, CultureInfo.InvariantCulture)!;
case DateTime dt:
return $"'{dt:yyyy-MM-dd HH:mm:ss.fff}'";
case DateTimeOffset dto:
return $"'{dto:yyyy-MM-dd HH:mm:ss.fff zzz}'";
case DateOnly d:
return $"'{d:yyyy-MM-dd}'";
case TimeOnly t:
return $"'{t:HH:mm:ss.fff}'";
case Guid g:
return $"'{g}'";
case byte[] bytes:
return bytes.Length == 0 ? "0x" : "0x" + Convert.ToHexString(bytes);
case DataTable:
return "@__TVP_NOT_INLINED__";
default:
return "'" + value.ToString()!.Replace("'", "''") + "'";
}
}
public static Dictionary ExtractParameterValues(object? parameters)
{
var result = new Dictionary(StringComparer.OrdinalIgnoreCase);
switch (parameters)
{
case null:
break;
case DynamicParameters dp:
foreach (var name in dp.ParameterNames)
{
object? value;
try { value = dp.Get