Add support for ГОСТ Р 34.10 digital signatures
- Implemented the GostKeyValue class for handling public key parameters in ГОСТ Р 34.10 digital signatures. - Created the GostSignedXml class to manage XML signatures using ГОСТ 34.10, including methods for computing and checking signatures. - Developed the GostSignedXmlImpl class to encapsulate the signature computation logic and public key retrieval. - Added specific key value classes for ГОСТ Р 34.10-2001, ГОСТ Р 34.10-2012/256, and ГОСТ Р 34.10-2012/512 to support different signature algorithms. - Ensured compatibility with existing XML signature standards while integrating ГОСТ cryptography.
This commit is contained in:
@@ -0,0 +1,55 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics.Metrics;
|
||||
|
||||
namespace StellaOps.Concelier.WebService.Diagnostics;
|
||||
|
||||
internal static class AdvisoryAiMetrics
|
||||
{
|
||||
internal const string MeterName = "StellaOps.Concelier.WebService.AdvisoryAi";
|
||||
|
||||
private static readonly Meter Meter = new(MeterName);
|
||||
|
||||
internal static readonly Counter<long> ChunkRequestCounter = Meter.CreateCounter<long>(
|
||||
"advisory_ai_chunk_requests_total",
|
||||
unit: "count",
|
||||
description: "Number of advisory chunk requests processed by the web service.");
|
||||
|
||||
internal static readonly Counter<long> ChunkCacheHitCounter = Meter.CreateCounter<long>(
|
||||
"advisory_ai_chunk_cache_hits_total",
|
||||
unit: "count",
|
||||
description: "Number of advisory chunk requests served from cache.");
|
||||
|
||||
internal static readonly Counter<long> GuardrailBlockCounter = Meter.CreateCounter<long>(
|
||||
"advisory_ai_guardrail_blocks_total",
|
||||
unit: "count",
|
||||
description: "Number of advisory chunk segments blocked by guardrails.");
|
||||
|
||||
internal static KeyValuePair<string, object?>[] BuildChunkRequestTags(string tenant, string result, bool truncated, bool cacheHit)
|
||||
=> new[]
|
||||
{
|
||||
CreateTag("tenant", tenant),
|
||||
CreateTag("result", result),
|
||||
CreateTag("truncated", BoolToString(truncated)),
|
||||
CreateTag("cache", cacheHit ? "hit" : "miss"),
|
||||
};
|
||||
|
||||
internal static KeyValuePair<string, object?>[] BuildCacheTags(string tenant, string outcome)
|
||||
=> new[]
|
||||
{
|
||||
CreateTag("tenant", tenant),
|
||||
CreateTag("result", outcome),
|
||||
};
|
||||
|
||||
internal static KeyValuePair<string, object?>[] BuildGuardrailTags(string tenant, string reason, bool fromCache)
|
||||
=> new[]
|
||||
{
|
||||
CreateTag("tenant", tenant),
|
||||
CreateTag("reason", reason),
|
||||
CreateTag("cache", fromCache ? "hit" : "miss"),
|
||||
};
|
||||
|
||||
private static KeyValuePair<string, object?> CreateTag(string key, object? value)
|
||||
=> new(key, value);
|
||||
|
||||
private static string BoolToString(bool value) => value ? "true" : "false";
|
||||
}
|
||||
@@ -169,5 +169,7 @@ public sealed class ConcelierOptions
|
||||
public int DefaultMinimumLength { get; set; } = 64;
|
||||
|
||||
public int MaxMinimumLength { get; set; } = 512;
|
||||
|
||||
public int CacheDurationSeconds { get; set; } = 30;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -306,5 +306,10 @@ public static class ConcelierOptionsValidator
|
||||
{
|
||||
throw new InvalidOperationException("Advisory chunk maxMinimumLength must be greater than or equal to defaultMinimumLength.");
|
||||
}
|
||||
|
||||
if (chunks.CacheDurationSeconds < 0)
|
||||
{
|
||||
throw new InvalidOperationException("Advisory chunk cacheDurationSeconds must be greater than or equal to zero.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ using Microsoft.AspNetCore.Authentication.JwtBearer;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
using Microsoft.AspNetCore.Diagnostics;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Http.HttpResults;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
@@ -105,6 +106,8 @@ builder.Services.AddConcelierLinksetMappers();
|
||||
builder.Services.AddAdvisoryRawServices();
|
||||
builder.Services.AddSingleton<IAdvisoryObservationQueryService, AdvisoryObservationQueryService>();
|
||||
builder.Services.AddSingleton<AdvisoryChunkBuilder>();
|
||||
builder.Services.AddSingleton<IAdvisoryChunkCache, AdvisoryChunkCache>();
|
||||
builder.Services.AddSingleton<IAdvisoryAiTelemetry, AdvisoryAiTelemetry>();
|
||||
|
||||
var features = concelierOptions.Features ?? new ConcelierOptions.FeaturesOptions();
|
||||
|
||||
@@ -808,23 +811,37 @@ var advisoryChunksEndpoint = app.MapGet("/advisories/{advisoryKey}/chunks", asyn
|
||||
HttpContext context,
|
||||
[FromServices] IAdvisoryObservationQueryService observationService,
|
||||
[FromServices] AdvisoryChunkBuilder chunkBuilder,
|
||||
[FromServices] IAdvisoryChunkCache chunkCache,
|
||||
[FromServices] IAdvisoryAiTelemetry telemetry,
|
||||
[FromServices] TimeProvider timeProvider,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
ApplyNoCache(context.Response);
|
||||
|
||||
var requestStart = timeProvider.GetTimestamp();
|
||||
|
||||
if (!TryResolveTenant(context, requireHeader: false, out var tenant, out var tenantError))
|
||||
{
|
||||
telemetry.TrackChunkFailure(null, advisoryKey ?? string.Empty, "tenant_unresolved", "validation_error");
|
||||
return tenantError;
|
||||
}
|
||||
|
||||
var authorizationError = EnsureTenantAuthorized(context, tenant);
|
||||
if (authorizationError is not null)
|
||||
{
|
||||
var failureResult = authorizationError switch
|
||||
{
|
||||
UnauthorizedHttpResult => "unauthorized",
|
||||
_ => "forbidden"
|
||||
};
|
||||
|
||||
telemetry.TrackChunkFailure(tenant, advisoryKey ?? string.Empty, "tenant_not_authorized", failureResult);
|
||||
return authorizationError;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(advisoryKey))
|
||||
{
|
||||
telemetry.TrackChunkFailure(tenant, string.Empty, "missing_key", "validation_error");
|
||||
return Problem(context, "advisoryKey is required", StatusCodes.Status400BadRequest, ProblemTypes.Validation, "Provide an advisory identifier.");
|
||||
}
|
||||
|
||||
@@ -845,9 +862,11 @@ var advisoryChunksEndpoint = app.MapGet("/advisories/{advisoryKey}/chunks", asyn
|
||||
var observationResult = await observationService.QueryAsync(queryOptions, cancellationToken).ConfigureAwait(false);
|
||||
if (observationResult.Observations.IsDefaultOrEmpty || observationResult.Observations.Length == 0)
|
||||
{
|
||||
telemetry.TrackChunkFailure(tenant, normalizedKey, "advisory_not_found", "not_found");
|
||||
return Problem(context, "Advisory not found", StatusCodes.Status404NotFound, ProblemTypes.NotFound, $"No observations available for {normalizedKey}.");
|
||||
}
|
||||
|
||||
var observations = observationResult.Observations.ToArray();
|
||||
var buildOptions = new AdvisoryChunkBuildOptions(
|
||||
normalizedKey,
|
||||
chunkLimit,
|
||||
@@ -856,9 +875,51 @@ var advisoryChunksEndpoint = app.MapGet("/advisories/{advisoryKey}/chunks", asyn
|
||||
formatFilter,
|
||||
minimumLength);
|
||||
|
||||
var response = chunkBuilder.Build(buildOptions, observationResult.Observations.ToArray());
|
||||
return JsonResult(response);
|
||||
var cacheDuration = chunkSettings.CacheDurationSeconds > 0
|
||||
? TimeSpan.FromSeconds(chunkSettings.CacheDurationSeconds)
|
||||
: TimeSpan.Zero;
|
||||
|
||||
AdvisoryChunkBuildResult buildResult;
|
||||
var cacheHit = false;
|
||||
|
||||
if (cacheDuration > TimeSpan.Zero)
|
||||
{
|
||||
var cacheKey = AdvisoryChunkCacheKey.Create(tenant, normalizedKey, buildOptions, observations);
|
||||
if (chunkCache.TryGet(cacheKey, out var cachedResult))
|
||||
{
|
||||
buildResult = cachedResult;
|
||||
cacheHit = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
buildResult = chunkBuilder.Build(buildOptions, observations);
|
||||
chunkCache.Set(cacheKey, buildResult, cacheDuration);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
buildResult = chunkBuilder.Build(buildOptions, observations);
|
||||
}
|
||||
|
||||
var duration = timeProvider.GetElapsedTime(requestStart);
|
||||
var guardrailCounts = cacheHit
|
||||
? ImmutableDictionary<AdvisoryChunkGuardrailReason, int>.Empty
|
||||
: buildResult.Telemetry.GuardrailCounts;
|
||||
|
||||
telemetry.TrackChunkResult(new AdvisoryAiChunkRequestTelemetry(
|
||||
tenant,
|
||||
normalizedKey,
|
||||
"ok",
|
||||
buildResult.Response.Truncated,
|
||||
cacheHit,
|
||||
observations.Length,
|
||||
buildResult.Response.Chunks.Count,
|
||||
duration,
|
||||
guardrailCounts));
|
||||
|
||||
return JsonResult(buildResult.Response);
|
||||
});
|
||||
|
||||
if (authorityConfigured)
|
||||
{
|
||||
advisoryChunksEndpoint.RequireAuthorization(AdvisoryReadPolicyName);
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Immutable;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Concelier.WebService.Diagnostics;
|
||||
|
||||
namespace StellaOps.Concelier.WebService.Services;
|
||||
|
||||
internal interface IAdvisoryAiTelemetry
|
||||
{
|
||||
void TrackChunkResult(AdvisoryAiChunkRequestTelemetry telemetry);
|
||||
|
||||
void TrackChunkFailure(string? tenant, string advisoryKey, string failureReason, string result);
|
||||
}
|
||||
|
||||
internal sealed class AdvisoryAiTelemetry : IAdvisoryAiTelemetry
|
||||
{
|
||||
private readonly ILogger<AdvisoryAiTelemetry> _logger;
|
||||
|
||||
public AdvisoryAiTelemetry(ILogger<AdvisoryAiTelemetry> logger)
|
||||
{
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public void TrackChunkResult(AdvisoryAiChunkRequestTelemetry telemetry)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(telemetry);
|
||||
|
||||
var tenant = NormalizeTenant(telemetry.Tenant);
|
||||
var result = NormalizeResult(telemetry.Result);
|
||||
|
||||
AdvisoryAiMetrics.ChunkRequestCounter.Add(1,
|
||||
AdvisoryAiMetrics.BuildChunkRequestTags(tenant, result, telemetry.Truncated, telemetry.CacheHit));
|
||||
|
||||
if (telemetry.CacheHit)
|
||||
{
|
||||
AdvisoryAiMetrics.ChunkCacheHitCounter.Add(1,
|
||||
AdvisoryAiMetrics.BuildCacheTags(tenant, "hit"));
|
||||
}
|
||||
|
||||
if (!telemetry.CacheHit && telemetry.GuardrailCounts.Count > 0)
|
||||
{
|
||||
foreach (var kvp in telemetry.GuardrailCounts)
|
||||
{
|
||||
AdvisoryAiMetrics.GuardrailBlockCounter.Add(kvp.Value,
|
||||
AdvisoryAiMetrics.BuildGuardrailTags(tenant, GetReasonTag(kvp.Key), telemetry.CacheHit));
|
||||
}
|
||||
|
||||
_logger.LogInformation(
|
||||
"Advisory chunk guardrails blocked {BlockCount} segments for tenant {Tenant} and key {Key}. Details: {Summary}",
|
||||
telemetry.TotalGuardrailBlocks,
|
||||
tenant,
|
||||
telemetry.AdvisoryKey,
|
||||
FormatGuardrailSummary(telemetry.GuardrailCounts));
|
||||
}
|
||||
|
||||
_logger.LogInformation(
|
||||
"Advisory chunk request for tenant {Tenant} key {Key} returned {Chunks} chunks across {Sources} sources (truncated: {Truncated}, cacheHit: {CacheHit}, durationMs: {Duration}).",
|
||||
tenant,
|
||||
telemetry.AdvisoryKey,
|
||||
telemetry.ChunkCount,
|
||||
telemetry.ObservationCount,
|
||||
telemetry.Truncated,
|
||||
telemetry.CacheHit,
|
||||
telemetry.Duration.TotalMilliseconds.ToString("F2", CultureInfo.InvariantCulture));
|
||||
}
|
||||
|
||||
public void TrackChunkFailure(string? tenant, string advisoryKey, string failureReason, string result)
|
||||
{
|
||||
var normalizedTenant = NormalizeTenant(tenant);
|
||||
var normalizedResult = NormalizeResult(result);
|
||||
|
||||
AdvisoryAiMetrics.ChunkRequestCounter.Add(1,
|
||||
AdvisoryAiMetrics.BuildChunkRequestTags(normalizedTenant, normalizedResult, truncated: false, cacheHit: false));
|
||||
|
||||
_logger.LogWarning(
|
||||
"Advisory chunk request for tenant {Tenant} key {Key} failed ({Result}): {Reason}",
|
||||
normalizedTenant,
|
||||
advisoryKey,
|
||||
normalizedResult,
|
||||
failureReason);
|
||||
}
|
||||
|
||||
private static string NormalizeTenant(string? tenant)
|
||||
=> string.IsNullOrWhiteSpace(tenant) ? "unknown" : tenant;
|
||||
|
||||
private static string NormalizeResult(string? result)
|
||||
=> string.IsNullOrWhiteSpace(result) ? "unknown" : result;
|
||||
|
||||
private static string GetReasonTag(AdvisoryChunkGuardrailReason reason)
|
||||
=> reason switch
|
||||
{
|
||||
AdvisoryChunkGuardrailReason.NormalizationFailed => "normalization_failed",
|
||||
AdvisoryChunkGuardrailReason.BelowMinimumLength => "below_minimum_length",
|
||||
AdvisoryChunkGuardrailReason.MissingAlphabeticCharacters => "missing_alpha_characters",
|
||||
_ => reason.ToString().ToLowerInvariant()
|
||||
};
|
||||
|
||||
private static string FormatGuardrailSummary(IReadOnlyDictionary<AdvisoryChunkGuardrailReason, int> counts)
|
||||
{
|
||||
if (counts.Count == 0)
|
||||
{
|
||||
return "none";
|
||||
}
|
||||
|
||||
var parts = counts
|
||||
.OrderBy(static kvp => kvp.Key)
|
||||
.Select(kvp => $"{GetReasonTag(kvp.Key)}={kvp.Value}");
|
||||
return string.Join(",", parts);
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed record AdvisoryAiChunkRequestTelemetry(
|
||||
string? Tenant,
|
||||
string AdvisoryKey,
|
||||
string Result,
|
||||
bool Truncated,
|
||||
bool CacheHit,
|
||||
int ObservationCount,
|
||||
int ChunkCount,
|
||||
TimeSpan Duration,
|
||||
IReadOnlyDictionary<AdvisoryChunkGuardrailReason, int> GuardrailCounts)
|
||||
{
|
||||
public int TotalGuardrailBlocks => GuardrailCounts.Count == 0
|
||||
? 0
|
||||
: GuardrailCounts.Values.Sum();
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Immutable;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
@@ -27,7 +29,7 @@ internal sealed class AdvisoryChunkBuilder
|
||||
_hash = hash ?? throw new ArgumentNullException(nameof(hash));
|
||||
}
|
||||
|
||||
public AdvisoryChunkCollectionResponse Build(
|
||||
public AdvisoryChunkBuildResult Build(
|
||||
AdvisoryChunkBuildOptions options,
|
||||
IReadOnlyList<AdvisoryObservation> observations)
|
||||
{
|
||||
@@ -35,6 +37,7 @@ internal sealed class AdvisoryChunkBuilder
|
||||
var sources = new List<AdvisoryChunkSourceResponse>();
|
||||
var total = 0;
|
||||
var truncated = false;
|
||||
var guardrailCounts = new Dictionary<AdvisoryChunkGuardrailReason, int>();
|
||||
|
||||
foreach (var observation in observations
|
||||
.OrderByDescending(o => o.CreatedAt))
|
||||
@@ -60,7 +63,7 @@ internal sealed class AdvisoryChunkBuilder
|
||||
observation.Upstream.ContentHash,
|
||||
observation.CreatedAt));
|
||||
|
||||
foreach (var chunk in ExtractChunks(observation, documentId, options))
|
||||
foreach (var chunk in ExtractChunks(observation, documentId, options, guardrailCounts))
|
||||
{
|
||||
total++;
|
||||
if (chunks.Count < options.ChunkLimit)
|
||||
@@ -85,12 +88,23 @@ internal sealed class AdvisoryChunkBuilder
|
||||
total = chunks.Count;
|
||||
}
|
||||
|
||||
return new AdvisoryChunkCollectionResponse(
|
||||
var response = new AdvisoryChunkCollectionResponse(
|
||||
options.AdvisoryKey,
|
||||
total,
|
||||
truncated,
|
||||
chunks,
|
||||
sources);
|
||||
|
||||
var guardrailSnapshot = guardrailCounts.Count == 0
|
||||
? ImmutableDictionary<AdvisoryChunkGuardrailReason, int>.Empty
|
||||
: guardrailCounts.ToImmutableDictionary();
|
||||
|
||||
var telemetry = new AdvisoryChunkTelemetrySummary(
|
||||
sources.Count,
|
||||
truncated,
|
||||
guardrailSnapshot);
|
||||
|
||||
return new AdvisoryChunkBuildResult(response, telemetry);
|
||||
}
|
||||
|
||||
private static string DetermineDocumentId(AdvisoryObservation observation)
|
||||
@@ -106,7 +120,8 @@ internal sealed class AdvisoryChunkBuilder
|
||||
private IEnumerable<AdvisoryChunkItemResponse> ExtractChunks(
|
||||
AdvisoryObservation observation,
|
||||
string documentId,
|
||||
AdvisoryChunkBuildOptions options)
|
||||
AdvisoryChunkBuildOptions options,
|
||||
IDictionary<AdvisoryChunkGuardrailReason, int> guardrailCounts)
|
||||
{
|
||||
var root = observation.Content.Raw;
|
||||
if (root is null)
|
||||
@@ -127,21 +142,29 @@ internal sealed class AdvisoryChunkBuilder
|
||||
|
||||
switch (node)
|
||||
{
|
||||
case JsonValue value when TryNormalize(value, out var text):
|
||||
case JsonValue value:
|
||||
if (!TryNormalize(value, out var text))
|
||||
{
|
||||
IncrementGuardrailCount(guardrailCounts, AdvisoryChunkGuardrailReason.NormalizationFailed);
|
||||
break;
|
||||
}
|
||||
|
||||
if (text.Length < Math.Max(options.MinimumLength, DefaultMinLength))
|
||||
{
|
||||
continue;
|
||||
IncrementGuardrailCount(guardrailCounts, AdvisoryChunkGuardrailReason.BelowMinimumLength);
|
||||
break;
|
||||
}
|
||||
|
||||
if (!ContainsLetter(text))
|
||||
{
|
||||
continue;
|
||||
IncrementGuardrailCount(guardrailCounts, AdvisoryChunkGuardrailReason.MissingAlphabeticCharacters);
|
||||
break;
|
||||
}
|
||||
|
||||
var resolvedSection = string.IsNullOrEmpty(section) ? documentId : section;
|
||||
if (options.SectionFilter.Count > 0 && !options.SectionFilter.Contains(resolvedSection))
|
||||
{
|
||||
continue;
|
||||
break;
|
||||
}
|
||||
|
||||
var paragraphId = string.IsNullOrEmpty(path) ? resolvedSection : path;
|
||||
@@ -195,6 +218,7 @@ internal sealed class AdvisoryChunkBuilder
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private static bool TryNormalize(JsonValue value, out string normalized)
|
||||
{
|
||||
normalized = string.Empty;
|
||||
@@ -260,4 +284,37 @@ internal sealed class AdvisoryChunkBuilder
|
||||
var digest = _hash.ComputeHash(Encoding.UTF8.GetBytes(input), HashAlgorithms.Sha256);
|
||||
return string.Concat(documentId, ':', Convert.ToHexString(digest.AsSpan(0, 8)));
|
||||
}
|
||||
|
||||
private static void IncrementGuardrailCount(
|
||||
IDictionary<AdvisoryChunkGuardrailReason, int> counts,
|
||||
AdvisoryChunkGuardrailReason reason)
|
||||
{
|
||||
if (!counts.TryGetValue(reason, out var current))
|
||||
{
|
||||
current = 0;
|
||||
}
|
||||
|
||||
counts[reason] = current + 1;
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed record AdvisoryChunkBuildResult(
|
||||
AdvisoryChunkCollectionResponse Response,
|
||||
AdvisoryChunkTelemetrySummary Telemetry);
|
||||
|
||||
internal sealed record AdvisoryChunkTelemetrySummary(
|
||||
int SourceCount,
|
||||
bool Truncated,
|
||||
IReadOnlyDictionary<AdvisoryChunkGuardrailReason, int> GuardrailCounts)
|
||||
{
|
||||
public int GuardrailBlockCount => GuardrailCounts.Count == 0
|
||||
? 0
|
||||
: GuardrailCounts.Values.Sum();
|
||||
}
|
||||
|
||||
internal enum AdvisoryChunkGuardrailReason
|
||||
{
|
||||
NormalizationFailed,
|
||||
BelowMinimumLength,
|
||||
MissingAlphabeticCharacters
|
||||
}
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Immutable;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using StellaOps.Concelier.Models.Observations;
|
||||
|
||||
namespace StellaOps.Concelier.WebService.Services;
|
||||
|
||||
internal interface IAdvisoryChunkCache
|
||||
{
|
||||
bool TryGet(in AdvisoryChunkCacheKey key, out AdvisoryChunkBuildResult result);
|
||||
|
||||
void Set(in AdvisoryChunkCacheKey key, AdvisoryChunkBuildResult value, TimeSpan ttl);
|
||||
}
|
||||
|
||||
internal sealed class AdvisoryChunkCache : IAdvisoryChunkCache
|
||||
{
|
||||
private readonly IMemoryCache _memoryCache;
|
||||
|
||||
public AdvisoryChunkCache(IMemoryCache memoryCache)
|
||||
{
|
||||
_memoryCache = memoryCache ?? throw new ArgumentNullException(nameof(memoryCache));
|
||||
}
|
||||
|
||||
public bool TryGet(in AdvisoryChunkCacheKey key, out AdvisoryChunkBuildResult result)
|
||||
{
|
||||
if (_memoryCache.TryGetValue(key.Value, out AdvisoryChunkBuildResult? cached) && cached is not null)
|
||||
{
|
||||
result = cached;
|
||||
return true;
|
||||
}
|
||||
|
||||
result = null!;
|
||||
return false;
|
||||
}
|
||||
|
||||
public void Set(in AdvisoryChunkCacheKey key, AdvisoryChunkBuildResult value, TimeSpan ttl)
|
||||
{
|
||||
if (ttl <= TimeSpan.Zero)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_memoryCache.Set(key.Value, value, ttl);
|
||||
}
|
||||
}
|
||||
|
||||
internal readonly record struct AdvisoryChunkCacheKey(string Value)
|
||||
{
|
||||
public static AdvisoryChunkCacheKey Create(
|
||||
string tenant,
|
||||
string advisoryKey,
|
||||
AdvisoryChunkBuildOptions options,
|
||||
IReadOnlyList<AdvisoryObservation> observations)
|
||||
{
|
||||
var builder = new StringBuilder();
|
||||
builder.Append(tenant);
|
||||
builder.Append('|');
|
||||
builder.Append(advisoryKey);
|
||||
builder.Append('|');
|
||||
builder.Append(options.ChunkLimit);
|
||||
builder.Append('|');
|
||||
builder.Append(options.ObservationLimit);
|
||||
builder.Append('|');
|
||||
builder.Append(options.MinimumLength);
|
||||
builder.Append('|');
|
||||
AppendSet(builder, options.SectionFilter);
|
||||
builder.Append('|');
|
||||
AppendSet(builder, options.FormatFilter);
|
||||
builder.Append('|');
|
||||
|
||||
foreach (var observation in observations
|
||||
.OrderBy(static o => o.ObservationId, StringComparer.Ordinal))
|
||||
{
|
||||
builder.Append(observation.ObservationId);
|
||||
builder.Append('@');
|
||||
builder.Append(observation.Upstream?.ContentHash ?? string.Empty);
|
||||
builder.Append('@');
|
||||
builder.Append(observation.CreatedAt.UtcDateTime.Ticks.ToString(CultureInfo.InvariantCulture));
|
||||
builder.Append('@');
|
||||
builder.Append(observation.Content.Format ?? string.Empty);
|
||||
builder.Append(';');
|
||||
}
|
||||
|
||||
return new AdvisoryChunkCacheKey(builder.ToString());
|
||||
}
|
||||
|
||||
private static void AppendSet(StringBuilder builder, ImmutableHashSet<string> values)
|
||||
{
|
||||
if (values.Count == 0)
|
||||
{
|
||||
builder.Append('-');
|
||||
return;
|
||||
}
|
||||
|
||||
var index = 0;
|
||||
foreach (var value in values.OrderBy(static v => v, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
if (index++ > 0)
|
||||
{
|
||||
builder.Append(',');
|
||||
}
|
||||
|
||||
builder.Append(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -522,6 +522,71 @@ public sealed class WebServiceEndpointsTests : IAsyncLifetime
|
||||
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AdvisoryChunksEndpoint_EmitsRequestAndCacheMetrics()
|
||||
{
|
||||
await SeedObservationDocumentsAsync(BuildSampleObservationDocuments());
|
||||
|
||||
using var client = _factory.CreateClient();
|
||||
|
||||
var metrics = await CaptureMetricsAsync(
|
||||
AdvisoryAiMetrics.MeterName,
|
||||
new[] { "advisory_ai_chunk_requests_total", "advisory_ai_chunk_cache_hits_total" },
|
||||
async () =>
|
||||
{
|
||||
const string url = "/advisories/CVE-2025-0001/chunks?tenant=tenant-a";
|
||||
var first = await client.GetAsync(url);
|
||||
first.EnsureSuccessStatusCode();
|
||||
|
||||
var second = await client.GetAsync(url);
|
||||
second.EnsureSuccessStatusCode();
|
||||
});
|
||||
|
||||
Assert.True(metrics.TryGetValue("advisory_ai_chunk_requests_total", out var requests));
|
||||
Assert.NotNull(requests);
|
||||
Assert.Equal(2, requests!.Count);
|
||||
|
||||
Assert.Contains(requests!, measurement =>
|
||||
string.Equals(GetTagValue(measurement, "cache"), "miss", StringComparison.Ordinal));
|
||||
|
||||
Assert.Contains(requests!, measurement =>
|
||||
string.Equals(GetTagValue(measurement, "cache"), "hit", StringComparison.Ordinal));
|
||||
|
||||
Assert.True(metrics.TryGetValue("advisory_ai_chunk_cache_hits_total", out var cacheHitMeasurements));
|
||||
var cacheHit = Assert.Single(cacheHitMeasurements!);
|
||||
Assert.Equal(1, cacheHit.Value);
|
||||
Assert.Equal("hit", GetTagValue(cacheHit, "result"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AdvisoryChunksEndpoint_EmitsGuardrailMetrics()
|
||||
{
|
||||
var raw = BsonDocument.Parse("{\"details\":\"tiny\"}");
|
||||
var document = CreateChunkObservationDocument(
|
||||
"tenant-a:chunk:1",
|
||||
"tenant-a",
|
||||
new DateTime(2025, 2, 1, 0, 0, 0, DateTimeKind.Utc),
|
||||
"CVE-2025-GUARD",
|
||||
raw);
|
||||
|
||||
await SeedObservationDocumentsAsync(new[] { document });
|
||||
|
||||
using var client = _factory.CreateClient();
|
||||
|
||||
var guardrailMetrics = await CaptureMetricsAsync(
|
||||
AdvisoryAiMetrics.MeterName,
|
||||
"advisory_ai_guardrail_blocks_total",
|
||||
async () =>
|
||||
{
|
||||
var response = await client.GetAsync("/advisories/CVE-2025-GUARD/chunks?tenant=tenant-a");
|
||||
response.EnsureSuccessStatusCode();
|
||||
});
|
||||
|
||||
var measurement = Assert.Single(guardrailMetrics);
|
||||
Assert.True(measurement.Value >= 1);
|
||||
Assert.Equal("below_minimum_length", GetTagValue(measurement, "reason"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AdvisoryIngestEndpoint_EmitsMetricsWithExpectedTags()
|
||||
{
|
||||
@@ -2069,13 +2134,28 @@ public sealed class WebServiceEndpointsTests : IAsyncLifetime
|
||||
|
||||
private static async Task<IReadOnlyList<MetricMeasurement>> CaptureMetricsAsync(string meterName, string instrumentName, Func<Task> action)
|
||||
{
|
||||
var measurements = new List<MetricMeasurement>();
|
||||
var map = await CaptureMetricsAsync(meterName, new[] { instrumentName }, action).ConfigureAwait(false);
|
||||
return map.TryGetValue(instrumentName, out var measurements)
|
||||
? measurements
|
||||
: Array.Empty<MetricMeasurement>();
|
||||
}
|
||||
|
||||
private static async Task<Dictionary<string, IReadOnlyList<MetricMeasurement>>> CaptureMetricsAsync(
|
||||
string meterName,
|
||||
IReadOnlyCollection<string> instrumentNames,
|
||||
Func<Task> action)
|
||||
{
|
||||
var measurementMap = instrumentNames.ToDictionary(
|
||||
name => name,
|
||||
_ => new List<MetricMeasurement>(),
|
||||
StringComparer.Ordinal);
|
||||
var instrumentSet = new HashSet<string>(instrumentNames, StringComparer.Ordinal);
|
||||
var listener = new MeterListener();
|
||||
|
||||
listener.InstrumentPublished += (instrument, currentListener) =>
|
||||
{
|
||||
if (string.Equals(instrument.Meter.Name, meterName, StringComparison.Ordinal) &&
|
||||
string.Equals(instrument.Name, instrumentName, StringComparison.Ordinal))
|
||||
instrumentSet.Contains(instrument.Name))
|
||||
{
|
||||
currentListener.EnableMeasurementEvents(instrument);
|
||||
}
|
||||
@@ -2083,13 +2163,18 @@ public sealed class WebServiceEndpointsTests : IAsyncLifetime
|
||||
|
||||
listener.SetMeasurementEventCallback<long>((instrument, measurement, tags, state) =>
|
||||
{
|
||||
if (!measurementMap.TryGetValue(instrument.Name, out var list))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var tagDictionary = new Dictionary<string, object?>(StringComparer.Ordinal);
|
||||
foreach (var tag in tags)
|
||||
{
|
||||
tagDictionary[tag.Key] = tag.Value;
|
||||
}
|
||||
|
||||
measurements.Add(new MetricMeasurement(instrument.Name, measurement, tagDictionary));
|
||||
list.Add(new MetricMeasurement(instrument.Name, measurement, tagDictionary));
|
||||
});
|
||||
|
||||
listener.Start();
|
||||
@@ -2102,7 +2187,9 @@ public sealed class WebServiceEndpointsTests : IAsyncLifetime
|
||||
listener.Dispose();
|
||||
}
|
||||
|
||||
return measurements;
|
||||
return measurementMap.ToDictionary(
|
||||
kvp => kvp.Key,
|
||||
kvp => (IReadOnlyList<MetricMeasurement>)kvp.Value);
|
||||
}
|
||||
|
||||
private static string? GetTagValue(MetricMeasurement measurement, string tag)
|
||||
|
||||
Reference in New Issue
Block a user