using System.Diagnostics;
using Dapper;
using KArtSell.BuildingBlocks.Data;
using Microsoft.Extensions.Logging;
using Npgsql;
namespace KArtSell.Host.Jobs;
///
/// Tracks consumer performance metrics: latency, success/failure rates, throughput.
/// Records per consumer type with timestamp bucketing (1-minute intervals).
/// Used for observability dashboards and alerting on degradation.
///
public sealed class ConsumerMetrics(
IDbConnectionFactory connectionFactory,
ILogger logger)
{
public sealed class ConsumerInvocation
{
public required string ConsumerType { get; init; }
public required string EventType { get; init; }
public required Stopwatch Stopwatch { get; init; }
public required string CorrelationId { get; init; }
public bool Success { get; set; }
public string? ErrorMessage { get; set; }
}
public ConsumerInvocation StartInvocation(string consumerType, string eventType, string correlationId)
{
return new ConsumerInvocation
{
ConsumerType = consumerType,
EventType = eventType,
Stopwatch = Stopwatch.StartNew(),
CorrelationId = correlationId
};
}
public async Task RecordInvocationAsync(
ConsumerInvocation invocation,
CancellationToken cancellationToken = default)
{
invocation.Stopwatch.Stop();
var durationMs = invocation.Stopwatch.ElapsedMilliseconds;
var conn = await connectionFactory.OpenAsync(cancellationToken) as NpgsqlConnection
?? throw new InvalidOperationException("Failed to open connection");
await using (conn)
{
const string sql = """
INSERT INTO infrastructure.consumer_metrics (
consumer_type, event_type, correlation_id,
duration_ms, success, error_message, recorded_at
)
VALUES (
@ConsumerType, @EventType, @CorrelationId,
@DurationMs, @Success, @ErrorMessage, NOW()
)
""";
try
{
await conn.ExecuteAsync(sql, new
{
invocation.ConsumerType,
invocation.EventType,
invocation.CorrelationId,
durationMs,
invocation.Success,
invocation.ErrorMessage
});
if (invocation.Success)
{
LogSuccess(logger, invocation.ConsumerType, invocation.EventType, durationMs, null);
}
else
{
LogFailure(logger, invocation.ConsumerType, invocation.EventType, durationMs, invocation.ErrorMessage, null);
}
}
catch (Exception ex)
{
logger.LogError(
ex,
"Failed to record consumer metrics for {ConsumerType}({EventType}). Duration: {DurationMs}ms",
invocation.ConsumerType, invocation.EventType, durationMs);
}
}
}
public async Task GetMetricsSnapshotAsync(
string consumerType,
int last_minutes = 5,
CancellationToken cancellationToken = default)
{
var conn = await connectionFactory.OpenAsync(cancellationToken) as NpgsqlConnection
?? throw new InvalidOperationException("Failed to open connection");
await using (conn)
{
const string sql = """
SELECT
COUNT(*) AS total_invocations,
SUM(CASE WHEN success THEN 1 ELSE 0 END) AS successful_invocations,
SUM(CASE WHEN NOT success THEN 1 ELSE 0 END) AS failed_invocations,
AVG(duration_ms) AS avg_duration_ms,
MAX(duration_ms) AS max_duration_ms,
MIN(duration_ms) AS min_duration_ms,
PERCENTILE_CONT(0.95) WITHIN GROUP (ORDER BY duration_ms) AS p95_duration_ms
FROM infrastructure.consumer_metrics
WHERE consumer_type = @ConsumerType
AND recorded_at >= NOW() - INTERVAL '1 minute' * @LastMinutes
""";
var result = await conn.QuerySingleOrDefaultAsync(
sql,
new { consumerType, lastMinutes = last_minutes });
if (result == null)
{
return new ConsumerMetricsSnapshot
{
ConsumerType = consumerType,
TotalInvocations = 0,
SuccessfulInvocations = 0,
FailedInvocations = 0,
SuccessRate = 0,
AvgDurationMs = 0,
MaxDurationMs = 0,
P95DurationMs = 0
};
}
var successRate = result.TotalInvocations > 0
? (double)result.SuccessfulInvocations / result.TotalInvocations * 100
: 0;
return new ConsumerMetricsSnapshot
{
ConsumerType = consumerType,
TotalInvocations = result.TotalInvocations,
SuccessfulInvocations = result.SuccessfulInvocations,
FailedInvocations = result.FailedInvocations,
SuccessRate = successRate,
AvgDurationMs = result.AvgDurationMs ?? 0,
MaxDurationMs = result.MaxDurationMs ?? 0,
P95DurationMs = result.P95DurationMs ?? 0
};
}
}
private sealed record MetricsRow
{
public int TotalInvocations { get; init; }
public int SuccessfulInvocations { get; init; }
public int FailedInvocations { get; init; }
public double? AvgDurationMs { get; init; }
public long? MaxDurationMs { get; init; }
public long? MinDurationMs { get; init; }
public double? P95DurationMs { get; init; }
}
private static readonly Action LogSuccess =
LoggerMessage.Define(
LogLevel.Debug,
new EventId(1, nameof(LogSuccess)),
"Consumer {ConsumerType} processed {EventType} successfully in {DurationMs}ms");
private static readonly Action LogFailure =
LoggerMessage.Define(
LogLevel.Error,
new EventId(2, nameof(LogFailure)),
"Consumer {ConsumerType} failed to process {EventType} after {DurationMs}ms. Error: {ErrorMessage}");
}
public sealed record ConsumerMetricsSnapshot
{
public required string ConsumerType { get; init; }
public int TotalInvocations { get; init; }
public int SuccessfulInvocations { get; init; }
public int FailedInvocations { get; init; }
public double SuccessRate { get; init; }
public double AvgDurationMs { get; init; }
public long MaxDurationMs { get; init; }
public double P95DurationMs { get; init; }
}