using System.Collections.Concurrent; using System.Diagnostics.Metrics; using System.Net; using System.Net.Http.Headers; using System.Net.Http.Json; using ItWorx.KnowledgeHub.Application.Ingestion; using ItWorx.KnowledgeHub.Contracts.Ingestion; using ItWorx.KnowledgeHub.IntegrationTests.Infrastructure; using Microsoft.AspNetCore.Hosting; using Microsoft.Extensions.Logging; namespace ItWorx.KnowledgeHub.IntegrationTests.Ingestion; /// These metric contract tests are Docker-free. They exercise the production /// metric surface directly, with synthetic dimensions only. public sealed class IngestionMetricContractTests { [Fact] public void Pilot_metrics_emit_only_bounded_labels_and_normalize_untrusted_dimensions() { using var meter = new Meter("ingestion-observability-test"); var measurements = new List(); using var listener = new MeterListener { InstrumentPublished = (instrument, currentListener) => { if (instrument.Meter == meter) currentListener.EnableMeasurementEvents(instrument); } }; listener.SetMeasurementEventCallback((instrument, value, tags, _) => measurements.Add(Observe(instrument.Name, value, tags))); listener.SetMeasurementEventCallback((instrument, value, tags, _) => measurements.Add(Observe(instrument.Name, value, tags))); listener.Start(); var metrics = new IngestionMetrics(meter); metrics.RecordProposal("success", "none"); metrics.RecordStageDuration("source_staging", "success", 1.25); metrics.RecordReviewDuration("success", 2.5); metrics.RecordPublication("success", "none"); const string sensitiveBody = "do not log me"; const string sensitiveFileName = "secret-plan.pdf"; const string sensitiveSourceUrl = "https://private.example/source?token=secret"; metrics.RecordProposal(sensitiveBody, sensitiveSourceUrl); metrics.RecordStageDuration(sensitiveFileName, sensitiveBody, 3.75); metrics.RecordReviewDuration(sensitiveBody, 4.5); metrics.RecordPublication(sensitiveBody, sensitiveSourceUrl); var expected = new[] { ("ingestion_proposals_total", "outcome=other|problem_type=other|stage=proposal"), ("ingestion_proposals_total", "outcome=success|problem_type=none|stage=proposal"), ("ingestion_stage_duration_seconds", "outcome=other|problem_type=none|stage=other"), ("ingestion_stage_duration_seconds", "outcome=success|problem_type=none|stage=source_staging"), ("ingestion_review_duration_seconds", "outcome=other|problem_type=none|stage=review"), ("ingestion_review_duration_seconds", "outcome=success|problem_type=none|stage=review"), ("ingestion_publications_total", "outcome=other|problem_type=other|stage=publication"), ("ingestion_publications_total", "outcome=success|problem_type=none|stage=publication") } .OrderBy(measurement => measurement.Item1, StringComparer.Ordinal) .ThenBy(measurement => measurement.Item2, StringComparer.Ordinal) .ToArray(); var actual = measurements .Select(measurement => (measurement.Name, Tags: SerializeTags(measurement.Tags))) .OrderBy(measurement => measurement.Name, StringComparer.Ordinal) .ThenBy(measurement => measurement.Tags, StringComparer.Ordinal) .ToArray(); Assert.Equal(expected, actual); var allowedLabelKeys = new HashSet(["stage", "outcome", "problem_type"], StringComparer.Ordinal); Assert.All(measurements, measurement => Assert.Subset(allowedLabelKeys, measurement.Tags.Keys.ToHashSet(StringComparer.Ordinal))); Assert.All(measurements, measurement => Assert.True(measurement.Value > 0)); var serializedMeasurements = string.Join('\n', actual.Select(measurement => measurement.Tags)); Assert.DoesNotContain(sensitiveBody, serializedMeasurements, StringComparison.Ordinal); Assert.DoesNotContain(sensitiveFileName, serializedMeasurements, StringComparison.Ordinal); Assert.DoesNotContain(sensitiveSourceUrl, serializedMeasurements, StringComparison.Ordinal); } private static ObservedMeasurement Observe(string name, double value, ReadOnlySpan> tags) => new(name, value, tags.ToArray().ToDictionary(tag => tag.Key, tag => tag.Value?.ToString() ?? string.Empty, StringComparer.Ordinal)); private static string SerializeTags(IReadOnlyDictionary tags) => string.Join('|', tags.OrderBy(tag => tag.Key, StringComparer.Ordinal).Select(tag => $"{tag.Key}={tag.Value}")); private sealed record ObservedMeasurement(string Name, double Value, IReadOnlyDictionary Tags); } [Collection(PostgresTestGroup.Name)] public sealed class IngestionObservabilityTests(PostgresContainerFixture postgres) : OwnerSetupTestBase( postgres, new Dictionary { ["IngestionProposal:Enabled"] = "true" }) { [Fact] public async Task Proposal_and_source_requests_never_write_source_content_or_file_metadata_to_logs() { const string sensitiveFileName = "secret-plan.pdf"; const string sensitiveSource = "source bytes must stay out of logs"; const string sensitiveUrl = "https://private.example/source?token=secret"; var sensitiveManifest = $"do not log me {sensitiveUrl}"; var captured = new ConcurrentQueue(); using var loggingFactory = Factory.WithWebHostBuilder(builder => builder.ConfigureLogging(logging => logging.AddProvider(new CapturingLoggerProvider(captured)))); using var client = loggingFactory.CreateClient(); var login = await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword); login.EnsureSuccessStatusCode(); var csrf = await SetupHelper.GetCsrfTokenAsync(client); using var create = new HttpRequestMessage(HttpMethod.Post, "/api/v1/ingestion-proposals") { Content = JsonContent.Create(new CreateIngestionProposalRequest(sensitiveManifest, "safe-observability-correlation")) }; create.Headers.Add(csrf.HeaderName, csrf.Value); create.Headers.Add("Idempotency-Key", "safe-observability-create"); create.Headers.Add("X-Correlation-ID", "safe-observability-correlation"); var created = await client.SendAsync(create); Assert.Equal(HttpStatusCode.Created, created.StatusCode); var proposal = await created.Content.ReadFromJsonAsync(); Assert.NotNull(proposal); var sourceCsrf = await SetupHelper.GetCsrfTokenAsync(client); using var multipart = new MultipartFormDataContent(); var sourceBody = new ByteArrayContent(System.Text.Encoding.UTF8.GetBytes(sensitiveSource)); sourceBody.Headers.ContentType = new MediaTypeHeaderValue("application/pdf"); multipart.Add(sourceBody, "file", sensitiveFileName); using var upload = new HttpRequestMessage(HttpMethod.Post, $"/api/v1/ingestion-proposals/{proposal!.Id}/sources") { Content = multipart }; upload.Headers.Add(sourceCsrf.HeaderName, sourceCsrf.Value); upload.Headers.Add("Idempotency-Key", "safe-observability-source"); upload.Headers.Add("If-Match", created.Headers.ETag!.Tag); upload.Headers.Add("X-Correlation-ID", "safe-observability-correlation"); var staged = await client.SendAsync(upload); Assert.Equal(HttpStatusCode.OK, staged.StatusCode); var logText = string.Join('\n', captured); Assert.Contains("safe-observability-correlation", logText, StringComparison.Ordinal); Assert.DoesNotContain(sensitiveManifest, logText, StringComparison.Ordinal); Assert.DoesNotContain(sensitiveFileName, logText, StringComparison.Ordinal); Assert.DoesNotContain(sensitiveSource, logText, StringComparison.Ordinal); Assert.DoesNotContain(sensitiveUrl, logText, StringComparison.Ordinal); } private sealed class CapturingLoggerProvider(ConcurrentQueue sink) : ILoggerProvider { public ILogger CreateLogger(string categoryName) => new CapturingLogger(categoryName, sink); public void Dispose() { } private sealed class CapturingLogger(string categoryName, ConcurrentQueue sink) : ILogger { public IDisposable? BeginScope(TState state) where TState : notnull { sink.Enqueue($"[{categoryName}] scope: {Format(state)}"); return NoopDisposable.Instance; } public bool IsEnabled(LogLevel logLevel) => true; public void Log( LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter) => sink.Enqueue($"[{categoryName}] {formatter(state, exception)} {Format(state)} {exception}"); private static string Format(TState state) => state is IEnumerable> fields ? string.Join(' ', fields.Select(field => $"{field.Key}={field.Value}")) : state?.ToString() ?? string.Empty; } private sealed class NoopDisposable : IDisposable { public static NoopDisposable Instance { get; } = new(); public void Dispose() { } } } }