Add full profiles and latency tests
This commit is contained in:
@@ -0,0 +1,64 @@
|
||||
using System.IdentityModel.Tokens.Jwt;
|
||||
using Microsoft.IdentityModel.Protocols;
|
||||
using Microsoft.IdentityModel.Protocols.OpenIdConnect;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
|
||||
namespace JobTrackerApi.Services;
|
||||
|
||||
public sealed record GoogleTokenPrincipal(string Subject, string? Email, string? GivenName, string? FamilyName, string? Name);
|
||||
|
||||
public interface IGoogleTokenValidator
|
||||
{
|
||||
Task<GoogleTokenPrincipal> ValidateAsync(string idToken, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
public sealed class GoogleTokenValidator : IGoogleTokenValidator
|
||||
{
|
||||
private readonly IConfiguration _cfg;
|
||||
private readonly IConfigurationManager<OpenIdConnectConfiguration> _configManager;
|
||||
|
||||
public GoogleTokenValidator(IConfiguration cfg)
|
||||
{
|
||||
_cfg = cfg;
|
||||
_configManager = new ConfigurationManager<OpenIdConnectConfiguration>(
|
||||
"https://accounts.google.com/.well-known/openid-configuration",
|
||||
new OpenIdConnectConfigurationRetriever());
|
||||
}
|
||||
|
||||
public async Task<GoogleTokenPrincipal> ValidateAsync(string idToken, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var audience = (_cfg["Auth:GoogleClientId"] ?? "").Trim();
|
||||
if (string.IsNullOrWhiteSpace(audience))
|
||||
{
|
||||
throw new InvalidOperationException("Google sign-in is not configured.");
|
||||
}
|
||||
|
||||
var config = await _configManager.GetConfigurationAsync(cancellationToken);
|
||||
var handler = new JwtSecurityTokenHandler();
|
||||
var principal = handler.ValidateToken(idToken, new TokenValidationParameters
|
||||
{
|
||||
ValidateIssuer = true,
|
||||
ValidIssuers = new[] { "accounts.google.com", "https://accounts.google.com" },
|
||||
ValidateAudience = true,
|
||||
ValidAudience = audience,
|
||||
ValidateLifetime = true,
|
||||
ValidateIssuerSigningKey = true,
|
||||
IssuerSigningKeys = config.SigningKeys,
|
||||
ClockSkew = TimeSpan.FromMinutes(2),
|
||||
}, out _);
|
||||
|
||||
var subject = principal.FindFirst("sub")?.Value?.Trim();
|
||||
if (string.IsNullOrWhiteSpace(subject))
|
||||
{
|
||||
throw new InvalidOperationException("Google token is missing a subject.");
|
||||
}
|
||||
|
||||
return new GoogleTokenPrincipal(
|
||||
Subject: subject,
|
||||
Email: principal.FindFirst("email")?.Value?.Trim(),
|
||||
GivenName: principal.FindFirst("given_name")?.Value?.Trim(),
|
||||
FamilyName: principal.FindFirst("family_name")?.Value?.Trim(),
|
||||
Name: principal.FindFirst("name")?.Value?.Trim()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,9 @@ using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace JobTrackerApi.Services
|
||||
{
|
||||
@@ -13,6 +16,11 @@ namespace JobTrackerApi.Services
|
||||
bool Healthy,
|
||||
string? Model,
|
||||
double? HealthLatencyMs,
|
||||
double? ProbeLatencyMs,
|
||||
DateTimeOffset? LastProbeAt,
|
||||
DateTimeOffset? LastProbeSuccessAt,
|
||||
DateTimeOffset? LastProbeFailureAt,
|
||||
int ProbeFailures,
|
||||
int Requests,
|
||||
int CacheHits,
|
||||
int CacheMisses,
|
||||
@@ -26,6 +34,7 @@ namespace JobTrackerApi.Services
|
||||
public interface ISummarizerService
|
||||
{
|
||||
Task<string?> SummarizeAsync(string text, int maxLength = 150, int minLength = 30);
|
||||
Task RunProbeAsync(CancellationToken cancellationToken = default);
|
||||
Task<SummarizerMetrics> GetMetricsAsync(CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
@@ -41,6 +50,11 @@ namespace JobTrackerApi.Services
|
||||
private long _totalLatencyTicks;
|
||||
private DateTimeOffset? _lastSuccessAt;
|
||||
private DateTimeOffset? _lastFailureAt;
|
||||
private double? _lastProbeLatencyMs;
|
||||
private DateTimeOffset? _lastProbeAt;
|
||||
private DateTimeOffset? _lastProbeSuccessAt;
|
||||
private DateTimeOffset? _lastProbeFailureAt;
|
||||
private int _probeFailures;
|
||||
private string? _lastError;
|
||||
|
||||
public SummarizerService(IHttpClientFactory httpFactory, IMemoryCache cache)
|
||||
@@ -111,6 +125,69 @@ namespace JobTrackerApi.Services
|
||||
}
|
||||
}
|
||||
|
||||
public async Task RunProbeAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string probeText = "Summarizer latency probe for job tracker telemetry.";
|
||||
var client = _httpFactory.CreateClient("summarizer");
|
||||
var payload = JsonSerializer.Serialize(new { text = probeText, max_length = 48, min_length = 12 });
|
||||
using var content = new StringContent(payload, Encoding.UTF8, "application/json");
|
||||
var sw = Stopwatch.StartNew();
|
||||
|
||||
try
|
||||
{
|
||||
using var res = await client.PostAsync("/summarize", content, cancellationToken);
|
||||
sw.Stop();
|
||||
|
||||
lock (_metricsLock)
|
||||
{
|
||||
_lastProbeAt = DateTimeOffset.UtcNow;
|
||||
_lastProbeLatencyMs = Math.Round(sw.Elapsed.TotalMilliseconds, 1);
|
||||
}
|
||||
|
||||
if (!res.IsSuccessStatusCode)
|
||||
{
|
||||
Interlocked.Increment(ref _probeFailures);
|
||||
lock (_metricsLock)
|
||||
{
|
||||
_lastProbeFailureAt = DateTimeOffset.UtcNow;
|
||||
_lastError = $"Probe returned {(int)res.StatusCode}.";
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
using var stream = await res.Content.ReadAsStreamAsync(cancellationToken);
|
||||
using var doc = await JsonDocument.ParseAsync(stream, cancellationToken: cancellationToken);
|
||||
if (!doc.RootElement.TryGetProperty("summary", out var summaryEl) || string.IsNullOrWhiteSpace(summaryEl.GetString()))
|
||||
{
|
||||
Interlocked.Increment(ref _probeFailures);
|
||||
lock (_metricsLock)
|
||||
{
|
||||
_lastProbeFailureAt = DateTimeOffset.UtcNow;
|
||||
_lastError = "Probe returned an empty summary.";
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
lock (_metricsLock)
|
||||
{
|
||||
_lastProbeSuccessAt = DateTimeOffset.UtcNow;
|
||||
_lastError = null;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
sw.Stop();
|
||||
Interlocked.Increment(ref _probeFailures);
|
||||
lock (_metricsLock)
|
||||
{
|
||||
_lastProbeAt = DateTimeOffset.UtcNow;
|
||||
_lastProbeLatencyMs = Math.Round(sw.Elapsed.TotalMilliseconds, 1);
|
||||
_lastProbeFailureAt = DateTimeOffset.UtcNow;
|
||||
_lastError = ex.Message;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<SummarizerMetrics> GetMetricsAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
var client = _httpFactory.CreateClient("summarizer");
|
||||
@@ -154,11 +231,19 @@ namespace JobTrackerApi.Services
|
||||
|
||||
DateTimeOffset? lastSuccessAt;
|
||||
DateTimeOffset? lastFailureAt;
|
||||
double? probeLatencyMs;
|
||||
DateTimeOffset? lastProbeAt;
|
||||
DateTimeOffset? lastProbeSuccessAt;
|
||||
DateTimeOffset? lastProbeFailureAt;
|
||||
string? lastError;
|
||||
lock (_metricsLock)
|
||||
{
|
||||
lastSuccessAt = _lastSuccessAt;
|
||||
lastFailureAt = _lastFailureAt;
|
||||
probeLatencyMs = _lastProbeLatencyMs;
|
||||
lastProbeAt = _lastProbeAt;
|
||||
lastProbeSuccessAt = _lastProbeSuccessAt;
|
||||
lastProbeFailureAt = _lastProbeFailureAt;
|
||||
lastError = _lastError;
|
||||
}
|
||||
|
||||
@@ -175,6 +260,11 @@ namespace JobTrackerApi.Services
|
||||
Healthy: healthy,
|
||||
Model: model,
|
||||
HealthLatencyMs: healthLatencyMs,
|
||||
ProbeLatencyMs: probeLatencyMs,
|
||||
LastProbeAt: lastProbeAt,
|
||||
LastProbeSuccessAt: lastProbeSuccessAt,
|
||||
LastProbeFailureAt: lastProbeFailureAt,
|
||||
ProbeFailures: Volatile.Read(ref _probeFailures),
|
||||
Requests: requests,
|
||||
CacheHits: cacheHits,
|
||||
CacheMisses: cacheMisses,
|
||||
@@ -186,4 +276,55 @@ namespace JobTrackerApi.Services
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class SummarizerProbeHostedService : BackgroundService
|
||||
{
|
||||
private readonly IServiceScopeFactory _scopeFactory;
|
||||
private readonly ILogger<SummarizerProbeHostedService> _logger;
|
||||
private readonly IConfiguration _cfg;
|
||||
|
||||
public SummarizerProbeHostedService(IServiceScopeFactory scopeFactory, ILogger<SummarizerProbeHostedService> logger, IConfiguration cfg)
|
||||
{
|
||||
_scopeFactory = scopeFactory;
|
||||
_logger = logger;
|
||||
_cfg = cfg;
|
||||
}
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
var enabled = _cfg.GetValue("Summarizer:ProbeEnabled", true);
|
||||
if (!enabled)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var intervalSeconds = Math.Clamp(_cfg.GetValue("Summarizer:ProbeIntervalSeconds", 300), 30, 3600);
|
||||
var initialDelaySeconds = Math.Clamp(_cfg.GetValue("Summarizer:ProbeInitialDelaySeconds", 15), 0, 600);
|
||||
|
||||
if (initialDelaySeconds > 0)
|
||||
{
|
||||
await Task.Delay(TimeSpan.FromSeconds(initialDelaySeconds), stoppingToken);
|
||||
}
|
||||
|
||||
using var timer = new PeriodicTimer(TimeSpan.FromSeconds(intervalSeconds));
|
||||
do
|
||||
{
|
||||
try
|
||||
{
|
||||
using var scope = _scopeFactory.CreateScope();
|
||||
var summarizer = scope.ServiceProvider.GetRequiredService<ISummarizerService>();
|
||||
await summarizer.RunProbeAsync(stoppingToken);
|
||||
}
|
||||
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
break;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Summarizer latency probe failed.");
|
||||
}
|
||||
}
|
||||
while (await timer.WaitForNextTickAsync(stoppingToken));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user