feat(vuln): emit VulnerabilityCacheWarmupCompleted customEvent per cold start

Trace-level warmup logs are dropped by default App Insights sampling, so
ops cannot reliably tell whether the MSRC / KEV cache hydration ran
cleanly on a given cold start. Add a sampling-bypass customEvent
(host.json already excludes "Event" from sampling) emitted once per
warmup run.

Captured properties let ops verify runtime health at a glance:
- msrcEntries / kevEntries — how many entries each cache holds after hydrate
- msrcBlobHit / kevBlobHit — derived from entries > 0; false on first deploy
  before any sync has written the blob, true on every subsequent cold start
- msrcLastRefresh / kevLastRefresh — ISO timestamp from the persisted snapshot
- durationMs — total warmup wall time (parallel Task.WhenAll, typically <100ms)
- errorType / errorMessage — populated only if Task.WhenAll threw

Query:
  customEvents
  | where name == 'VulnerabilityCacheWarmupCompleted'
  | project timestamp, customDimensions
  | order by timestamp desc

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Oliver Kieselbach
2026-04-28 17:02:41 +02:00
parent 5b9410eb9c
commit 7c27518dbb
@@ -1,6 +1,9 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.ApplicationInsights;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
@@ -12,20 +15,31 @@ namespace AutopilotMonitor.Functions.Services.Vulnerability
/// host comes up immediately — the actual blob reads run in the background and the
/// correlation hot path defensively calls <c>EnsureHydratedAsync</c> in case telemetry
/// arrives before warmup completes.
///
/// Emits a <c>VulnerabilityCacheWarmupCompleted</c> custom event per cold start so
/// runtime health is observable without relying on aggressively sampled trace logs:
/// <code>
/// customEvents
/// | where name == 'VulnerabilityCacheWarmupCompleted'
/// | project timestamp, customDimensions
/// </code>
/// </summary>
public class VulnerabilityCacheWarmer : IHostedService
{
private readonly MsrcCorrelationService _msrc;
private readonly KevDataService _kev;
private readonly TelemetryClient? _telemetryClient;
private readonly ILogger<VulnerabilityCacheWarmer> _logger;
public VulnerabilityCacheWarmer(
MsrcCorrelationService msrc,
KevDataService kev,
TelemetryClient? telemetryClient,
ILogger<VulnerabilityCacheWarmer> logger)
{
_msrc = msrc;
_kev = kev;
_telemetryClient = telemetryClient;
_logger = logger;
}
@@ -37,6 +51,9 @@ namespace AutopilotMonitor.Functions.Services.Vulnerability
// in-flight load instead of duplicating work.
_ = Task.Run(async () =>
{
var sw = Stopwatch.StartNew();
string? errorType = null;
string? errorMessage = null;
try
{
await Task.WhenAll(
@@ -48,8 +65,30 @@ namespace AutopilotMonitor.Functions.Services.Vulnerability
}
catch (Exception ex)
{
errorType = ex.GetType().Name;
errorMessage = ex.Message;
_logger.LogWarning(ex, "Vulnerability cache warmup failed — defensive hydration will still cover correctness");
}
finally
{
sw.Stop();
// Sampling-bypass observability — host.json's excludedTypes adds "Event"
// so this survives even when default trace sampling is aggressive. Lets
// ops verify per-cold-start that hydration ran and which source(s) hit
// the blob (msrcBlobHit/kevBlobHit = entries > 0 after EnsureHydrated).
_telemetryClient?.TrackEvent("VulnerabilityCacheWarmupCompleted", new Dictionary<string, string>
{
{ "msrcEntries", _msrc.IndexSize.ToString() },
{ "kevEntries", _kev.CatalogSize.ToString() },
{ "msrcBlobHit", (_msrc.IndexSize > 0).ToString() },
{ "kevBlobHit", (_kev.CatalogSize > 0).ToString() },
{ "msrcLastRefresh", _msrc.LastRefresh == DateTime.MinValue ? "" : _msrc.LastRefresh.ToString("o") },
{ "kevLastRefresh", _kev.LastRefresh == DateTime.MinValue ? "" : _kev.LastRefresh.ToString("o") },
{ "durationMs", sw.ElapsedMilliseconds.ToString() },
{ "errorType", errorType ?? "" },
{ "errorMessage", errorMessage ?? "" }
});
}
}, cancellationToken);
return Task.CompletedTask;