diff --git a/src/shared/Unifesspa.UniPlus.Infrastructure.Core/Logging/SerilogConfiguration.cs b/src/shared/Unifesspa.UniPlus.Infrastructure.Core/Logging/SerilogConfiguration.cs index cf0172e1..7aac76ce 100644 --- a/src/shared/Unifesspa.UniPlus.Infrastructure.Core/Logging/SerilogConfiguration.cs +++ b/src/shared/Unifesspa.UniPlus.Infrastructure.Core/Logging/SerilogConfiguration.cs @@ -1,24 +1,102 @@ namespace Unifesspa.UniPlus.Infrastructure.Core.Logging; +using System.Collections.Generic; using System.Globalization; using Microsoft.Extensions.Configuration; using Serilog; using Serilog.Events; +using Serilog.Sinks.OpenTelemetry; +using Unifesspa.UniPlus.Infrastructure.Core.Observability; + +/// +/// Configuração canônica do pipeline Serilog Uni+: lê appsettings, override +/// de Microsoft/EF para Warning, FromLogContext, +/// (ADR-0011), Console sink. Quando Observability:Enabled não está desligado, +/// adiciona o sink OTLP gRPC (ADR-0018) — logs fluem ao Collector → Loki com correlação +/// traceId/spanId automática por evento. +/// public static class SerilogConfiguration { - public static LoggerConfiguration ConfigurarSerilog(this LoggerConfiguration loggerConfiguration, IConfiguration configuration) + /// + /// Sobrecarga sem nome de serviço — mantida para callers que ainda não precisam + /// rotular logs com service.name/service.namespace no Loki. + /// O sink OTLP é registrado mesmo assim (quando o toggle está ativo); apenas + /// não popula ResourceAttributes, deixando o sink usar suas próprias + /// inferências (env vars padrão OTel, se presentes). + /// + public static LoggerConfiguration ConfigurarSerilog( + this LoggerConfiguration loggerConfiguration, + IConfiguration configuration) + => loggerConfiguration.ConfigurarSerilog(configuration, nomeServico: null); + + /// + /// Configura o pipeline com service.name/service.namespace aplicados + /// ao Resource do sink OTLP — habilita queries LogQL como + /// {service_name="uniplus-selecao"}. + /// + /// + /// O Console sink é sempre preservado para que docker logs continue + /// útil em bootstrap debugging. + /// fica antes dos sinks por construção: + /// enrichers executam antes dos sinks no pipeline Serilog. CPF é mascarado + /// antes de qualquer egress (Console ou OTLP) — ADR-0011. + /// Endpoint OTLP é lido pela env var OTEL_EXPORTER_OTLP_ENDPOINT + /// (default http://localhost:4317). Os atributos service.* são + /// injetados aqui porque o pipeline Serilog→OTLP é independente do pipeline + /// OTel SDK () — + /// duplicação intencional para evitar drift entre logs e traces no Loki/Tempo. + /// + /// Configuração base do Serilog. + /// Configuração da aplicação — fornece toggle + /// e overrides + /// de level via Serilog section. + /// Nome canônico do serviço para rotular o sink OTLP + /// (service.name + service.namespace). Quando null, sink + /// é registrado sem ResourceAttributes. + public static LoggerConfiguration ConfigurarSerilog( + this LoggerConfiguration loggerConfiguration, + IConfiguration configuration, + string? nomeServico) { ArgumentNullException.ThrowIfNull(loggerConfiguration); + ArgumentNullException.ThrowIfNull(configuration); - return loggerConfiguration + loggerConfiguration .ReadFrom.Configuration(configuration) .MinimumLevel.Override("Microsoft", LogEventLevel.Warning) .MinimumLevel.Override("Microsoft.EntityFrameworkCore", LogEventLevel.Warning) .Enrich.FromLogContext() .Enrich.With() .WriteTo.Console(formatProvider: CultureInfo.InvariantCulture); + + bool observabilidadeAtivada = configuration.GetValue( + OpenTelemetryConfiguration.EnabledConfigurationKey, + defaultValue: true); + + if (observabilidadeAtivada) + { + loggerConfiguration.WriteTo.OpenTelemetry(options => + { + options.Protocol = OtlpProtocol.Grpc; + options.IncludedData = + IncludedData.TraceIdField + | IncludedData.SpanIdField + | IncludedData.MessageTemplateTextAttribute; + + if (!string.IsNullOrWhiteSpace(nomeServico)) + { + options.ResourceAttributes = new Dictionary + { + ["service.name"] = nomeServico, + ["service.namespace"] = OpenTelemetryConfiguration.ServiceNamespaceResourceValue, + }; + } + }); + } + + return loggerConfiguration; } } diff --git a/src/shared/Unifesspa.UniPlus.Infrastructure.Core/Middleware/CorrelationIdMiddleware.cs b/src/shared/Unifesspa.UniPlus.Infrastructure.Core/Middleware/CorrelationIdMiddleware.cs index e43a6fb2..8830cff4 100644 --- a/src/shared/Unifesspa.UniPlus.Infrastructure.Core/Middleware/CorrelationIdMiddleware.cs +++ b/src/shared/Unifesspa.UniPlus.Infrastructure.Core/Middleware/CorrelationIdMiddleware.cs @@ -1,5 +1,6 @@ namespace Unifesspa.UniPlus.Infrastructure.Core.Middleware; +using System.Diagnostics; using System.Text.RegularExpressions; using Microsoft.AspNetCore.Http; @@ -13,6 +14,13 @@ public sealed partial class CorrelationIdMiddleware public const string LogContextProperty = "CorrelationId"; public const int MaxCorrelationIdLength = 128; + /// + /// Nome do span attribute (Activity tag) que carrega o correlation_id no Tempo. + /// Lido por derivedFields do Loki datasource em Grafana para o drill-down + /// log → trace, fechando o ciclo log ↔ trace ↔ dashboard (ADR-0018). + /// + public const string ActivityTagName = "correlation_id"; + private readonly RequestDelegate _next; public CorrelationIdMiddleware(RequestDelegate next) @@ -30,6 +38,13 @@ public async Task InvokeAsync(HttpContext context, ICorrelationIdWriter writer) writer.SetCorrelationId(correlationId); + // Propagar correlation_id como Activity span attribute habilita o + // drill-down Loki → Tempo via derivedFields no Grafana (ADR-0018) e + // fecha o ciclo log ↔ trace. Activity.Current pode ser null fora do + // request pipeline com OTel wired (ex.: testes unitários sem listener); + // o ?. mantém o middleware safe nesse cenário. + Activity.Current?.SetTag(ActivityTagName, correlationId); + // Postergar a escrita do header até o início do flush da resposta // garante que o valor sobreviva a qualquer mutação feita por // middlewares/filtros downstream e seja comprometido logo antes do diff --git a/src/shared/Unifesspa.UniPlus.Infrastructure.Core/Observability/OpenTelemetryConfiguration.cs b/src/shared/Unifesspa.UniPlus.Infrastructure.Core/Observability/OpenTelemetryConfiguration.cs index 88c7c63f..271467d0 100644 --- a/src/shared/Unifesspa.UniPlus.Infrastructure.Core/Observability/OpenTelemetryConfiguration.cs +++ b/src/shared/Unifesspa.UniPlus.Infrastructure.Core/Observability/OpenTelemetryConfiguration.cs @@ -1,20 +1,152 @@ namespace Unifesspa.UniPlus.Infrastructure.Core.Observability; +using System.Collections.Generic; + +using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using OpenTelemetry.Metrics; using OpenTelemetry.Resources; using OpenTelemetry.Trace; +/// +/// Registra OpenTelemetry com instrumentações canônicas Uni+ (tracing + metrics) +/// e exporter OTLP para a stack LGTM institucional. ADR-0018. +/// public static class OpenTelemetryConfiguration { - public static IServiceCollection AdicionarObservabilidade(this IServiceCollection services, string nomeServico) + /// + /// Nome canônico do emitido + /// pelo Wolverine. Registrado tanto em WithTracing.AddSource quanto em + /// WithMetrics.AddMeter para que command/handler/outbox executions + /// apareçam em Tempo (traces) e Prometheus (métricas) — ADR-0018. + /// + public const string WolverineActivityAndMeterName = "Wolverine"; + + + /// + /// Toggle de configuração para observabilidade. Default true; quando + /// false, nenhum ou + /// é registrado. Cenário de uso: suites de teste HTTP-only sem Collector + /// provisionado, troubleshooting onde a stack LGTM está fora do ar + /// (degradação controlada). Mesma chave usada por + /// para condicionar o sink OTLP de logs. + /// + public const string EnabledConfigurationKey = "Observability:Enabled"; + + /// + /// Atributo Resource canônico do OTel para particionar telemetria entre + /// Development/Staging/Production nos dashboards Grafana. + /// + public const string DeploymentEnvironmentResourceAttribute = "deployment.environment"; + + /// + /// Namespace canônico Uni+ — todas as APIs do projeto compartilham. Permite + /// queries cross-service em PromQL/LogQL/TraceQL via + /// service_namespace="uniplus". + /// + public const string ServiceNamespaceResourceValue = "uniplus"; + + /// + /// Sampling ratio head-based para ambientes não-Development. 10% conforme + /// ADR-0018; tail-based 100% para erros e latência alta é responsabilidade + /// do tail_sampling_processor no Collector, não da API. + /// + public const double ProductionSamplingRatio = 0.1; + + /// + /// Registra OpenTelemetry com instrumentações canônicas Uni+ e exporter OTLP. + /// Endpoint OTLP é lido automaticamente pela env var + /// OTEL_EXPORTER_OTLP_ENDPOINT (default http://localhost:4317, gRPC). + /// + /// + /// Tracing: AspNetCore + EF Core + HttpClient + ActivitySource nominal + /// (). + /// Metrics: AspNetCore + Runtime + HttpClient + Meter nominal + + /// Wolverine (instrumentação built-in via System.Diagnostics.Metrics.Meter + /// nativo do framework — ADR-0018). + /// Sampler: em Development, + /// com + /// (10%) nos demais. + /// + /// A coleção de serviços. + /// Nome canônico do serviço (ex.: uniplus-selecao), + /// usado como service.name no Resource e como nome da + /// ActivitySource/Meter nominal. + /// Configuração da aplicação. + /// Ambiente de hosting — define o sampler e popula + /// deployment.environment no Resource. + /// A própria para encadeamento fluente. + public static IServiceCollection AdicionarObservabilidade( + this IServiceCollection services, + string nomeServico, + IConfiguration configuration, + IHostEnvironment environment) { + ArgumentNullException.ThrowIfNull(services); + ArgumentException.ThrowIfNullOrWhiteSpace(nomeServico); + ArgumentNullException.ThrowIfNull(configuration); + ArgumentNullException.ThrowIfNull(environment); + + bool enabled = configuration.GetValue(EnabledConfigurationKey, defaultValue: true); + if (!enabled) + { + return services; + } + + Sampler sampler = SelecionarSampler(environment); + + IEnumerable> resourceAttributes = new[] + { + new KeyValuePair( + DeploymentEnvironmentResourceAttribute, + environment.EnvironmentName), + }; + services.AddOpenTelemetry() - .ConfigureResource(resource => resource.AddService(nomeServico)) + .ConfigureResource(resource => resource + .AddService(serviceName: nomeServico, serviceNamespace: ServiceNamespaceResourceValue) + .AddAttributes(resourceAttributes)) .WithTracing(tracing => tracing + .SetSampler(sampler) + .AddSource(nomeServico) + .AddSource(WolverineActivityAndMeterName) + .AddAspNetCoreInstrumentation() + .AddHttpClientInstrumentation() + .AddEntityFrameworkCoreInstrumentation() + .AddOtlpExporter()) + .WithMetrics(metrics => metrics + .AddMeter(nomeServico) + .AddMeter(WolverineActivityAndMeterName) .AddAspNetCoreInstrumentation() - .AddSource(nomeServico)); + .AddHttpClientInstrumentation() + .AddRuntimeInstrumentation() + .AddOtlpExporter()); return services; } + + /// + /// Seleciona o sampler conforme ambiente: + /// em Development (debugging local — todos os spans), e + /// com + /// em (10%) nos demais ambientes — + /// ADR-0018 head-based sampling. Tail-based 100% para erro/latência alta + /// é responsabilidade do tail_sampling_processor no Collector. + /// + /// + /// Extraído como internal static exatamente para tornar a regra de + /// seleção testável sem precisar inspecionar o TracerProvider via + /// reflection — InternalsVisibleTo em Infrastructure.Core.csproj + /// expõe esta API para Unifesspa.UniPlus.Infrastructure.Core.UnitTests. + /// + internal static Sampler SelecionarSampler(IHostEnvironment environment) + { + ArgumentNullException.ThrowIfNull(environment); + + return environment.IsDevelopment() + ? new AlwaysOnSampler() + : new ParentBasedSampler(new TraceIdRatioBasedSampler(ProductionSamplingRatio)); + } } diff --git a/tests/Unifesspa.UniPlus.Infrastructure.Core.UnitTests/Middleware/CorrelationIdMiddlewareTests.cs b/tests/Unifesspa.UniPlus.Infrastructure.Core.UnitTests/Middleware/CorrelationIdMiddlewareTests.cs index 299ce81e..76fb9b68 100644 --- a/tests/Unifesspa.UniPlus.Infrastructure.Core.UnitTests/Middleware/CorrelationIdMiddlewareTests.cs +++ b/tests/Unifesspa.UniPlus.Infrastructure.Core.UnitTests/Middleware/CorrelationIdMiddlewareTests.cs @@ -1,5 +1,7 @@ namespace Unifesspa.UniPlus.Infrastructure.Core.UnitTests.Middleware; +using System.Diagnostics; + using AwesomeAssertions; using Microsoft.AspNetCore.Http; @@ -221,6 +223,61 @@ public async Task InvokeAsync_DeveEnriquecerLogContextComCorrelationId() .ToString().Trim('"').Should().Be(id); } + [Fact] + public async Task InvokeAsync_ComActivityAtiva_DeveSetarCorrelationIdComoTagDoSpan() + { + // ActivityListener é necessário para que ActivitySource.StartActivity + // retorne instância: sem listener, Activity.Current ficaria null e o + // teste seria vacuoso. Sample = AllData garante que o span não é + // dropado pelo sampler default. + using ActivitySource source = new(nameof(InvokeAsync_ComActivityAtiva_DeveSetarCorrelationIdComoTagDoSpan)); + using ActivityListener listener = new() + { + ShouldListenTo = _ => true, + Sample = (ref ActivityCreationOptions _) => ActivitySamplingResult.AllData, + }; + ActivitySource.AddActivityListener(listener); + + const string id = "activity-tag-test"; + (DefaultHttpContext context, _) = CriarContexto(); + context.Request.Headers[CorrelationIdMiddleware.HeaderName] = id; + Activity? activityCapturadaNoPipeline = null; + + using (Activity? activity = source.StartActivity("test-span")) + { + CorrelationIdMiddleware middleware = new(_ => + { + activityCapturadaNoPipeline = Activity.Current; + return Task.CompletedTask; + }); + + await middleware.InvokeAsync(context, new CorrelationIdAccessor()); + } + + activityCapturadaNoPipeline.Should().NotBeNull(); + activityCapturadaNoPipeline!.GetTagItem(CorrelationIdMiddleware.ActivityTagName) + .Should().Be(id); + } + + [Fact] + public async Task InvokeAsync_SemActivityAtiva_NaoDeveFalhar() + { + // Sem ActivityListener registrado para esta classe, ActivitySource não + // emite spans e Activity.Current permanece null durante o pipeline. + // O middleware deve ser null-safe via Activity.Current?.SetTag(...). + Activity.Current.Should().BeNull(); + + const string id = "sem-activity"; + (DefaultHttpContext context, _) = CriarContexto(); + context.Request.Headers[CorrelationIdMiddleware.HeaderName] = id; + ICorrelationIdWriter accessor = Substitute.For(); + CorrelationIdMiddleware middleware = new(_ => Task.CompletedTask); + + Func acao = async () => await middleware.InvokeAsync(context, accessor); + + await acao.Should().NotThrowAsync(); + } + // Cria um DefaultHttpContext com IHttpResponseFeature customizado que // captura os callbacks registrados em Response.OnStarting (o feature // default lança NotSupportedException). Retorna também um delegate que diff --git a/tests/Unifesspa.UniPlus.Infrastructure.Core.UnitTests/Observability/OpenTelemetryConfigurationTests.cs b/tests/Unifesspa.UniPlus.Infrastructure.Core.UnitTests/Observability/OpenTelemetryConfigurationTests.cs new file mode 100644 index 00000000..fcaeb8a8 --- /dev/null +++ b/tests/Unifesspa.UniPlus.Infrastructure.Core.UnitTests/Observability/OpenTelemetryConfigurationTests.cs @@ -0,0 +1,170 @@ +namespace Unifesspa.UniPlus.Infrastructure.Core.UnitTests.Observability; + +using System.Collections.Generic; + +using AwesomeAssertions; + +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; + +using NSubstitute; + +using OpenTelemetry.Metrics; +using OpenTelemetry.Trace; + +using Unifesspa.UniPlus.Infrastructure.Core.Observability; + +public class OpenTelemetryConfigurationTests +{ + [Fact] + public void AdicionarObservabilidade_QuandoToggleDesabilitado_NaoRegistraTracerProviderNemMeterProvider() + { + ServiceCollection services = new(); + IConfiguration configuration = NovaConfiguracao(new Dictionary + { + [OpenTelemetryConfiguration.EnabledConfigurationKey] = "false", + }); + IHostEnvironment environment = NovoAmbiente("Development"); + + services.AdicionarObservabilidade("uniplus-test", configuration, environment); + + ServiceProvider provider = services.BuildServiceProvider(); + provider.GetService().Should().BeNull(); + provider.GetService().Should().BeNull(); + } + + [Fact] + public void AdicionarObservabilidade_QuandoToggleAtivo_RegistraTracerProviderEMeterProvider() + { + ServiceCollection services = new(); + IConfiguration configuration = NovaConfiguracao(); + IHostEnvironment environment = NovoAmbiente("Development"); + + services.AdicionarObservabilidade("uniplus-test", configuration, environment); + + ServiceProvider provider = services.BuildServiceProvider(); + provider.GetService().Should().NotBeNull(); + provider.GetService().Should().NotBeNull(); + } + + [Fact] + public void AdicionarObservabilidade_QuandoToggleAusente_AssumeAtivoPorDefault() + { + // Default seguro pra produção: ausência da chave significa "ligado". + // CI explicitamente desliga via configuração quando não há Collector. + ServiceCollection services = new(); + IConfiguration configuration = NovaConfiguracao(); + IHostEnvironment environment = NovoAmbiente("Production"); + + services.AdicionarObservabilidade("uniplus-test", configuration, environment); + + ServiceProvider provider = services.BuildServiceProvider(); + provider.GetService().Should().NotBeNull(); + } + + [Fact] + public void AdicionarObservabilidade_NomeServicoVazio_DeveLancarArgumentException() + { + ServiceCollection services = new(); + IConfiguration configuration = NovaConfiguracao(); + IHostEnvironment environment = NovoAmbiente("Development"); + + Action acao = () => services.AdicionarObservabilidade(string.Empty, configuration, environment); + + acao.Should().Throw(); + } + + [Fact] + public void AdicionarObservabilidade_ServicesNulo_DeveLancarArgumentNullException() + { + IServiceCollection? services = null; + IConfiguration configuration = NovaConfiguracao(); + IHostEnvironment environment = NovoAmbiente("Development"); + + Action acao = () => services!.AdicionarObservabilidade("uniplus-test", configuration, environment); + + acao.Should().Throw(); + } + + [Fact] + public void AdicionarObservabilidade_ConfigurationNulo_DeveLancarArgumentNullException() + { + ServiceCollection services = new(); + IConfiguration? configuration = null; + IHostEnvironment environment = NovoAmbiente("Development"); + + Action acao = () => services.AdicionarObservabilidade("uniplus-test", configuration!, environment); + + acao.Should().Throw(); + } + + [Fact] + public void AdicionarObservabilidade_EnvironmentNulo_DeveLancarArgumentNullException() + { + ServiceCollection services = new(); + IConfiguration configuration = NovaConfiguracao(); + IHostEnvironment? environment = null; + + Action acao = () => services.AdicionarObservabilidade("uniplus-test", configuration, environment!); + + acao.Should().Throw(); + } + + [Fact] + public void SelecionarSampler_EmDevelopment_DeveRetornarAlwaysOnSampler() + { + IHostEnvironment environment = NovoAmbiente("Development"); + + Sampler sampler = OpenTelemetryConfiguration.SelecionarSampler(environment); + + sampler.Should().BeOfType(); + } + + [Theory] + [InlineData("Production")] + [InlineData("Staging")] + [InlineData("HML")] + [InlineData("Test")] + public void SelecionarSampler_ForaDeDevelopment_DeveRetornarParentBasedComTraceIdRatio10Pct(string environmentName) + { + IHostEnvironment environment = NovoAmbiente(environmentName); + + Sampler sampler = OpenTelemetryConfiguration.SelecionarSampler(environment); + + // ParentBasedSampler.Description segue o formato canônico OTel: + // "ParentBased{root=TraceIdRatioBased{0.100000},...}". Validar pela + // descrição evita reflection no campo privado _rootSampler e quebra de + // teste em upgrades minor da SDK que reorganizem internals. + sampler.Should().BeOfType(); + sampler.Description.Should().Contain("TraceIdRatioBased").And.Contain("0.1"); + } + + [Fact] + public void SelecionarSampler_EnvironmentNulo_DeveLancarArgumentNullException() + { + IHostEnvironment? environment = null; + + Action acao = () => OpenTelemetryConfiguration.SelecionarSampler(environment!); + + acao.Should().Throw(); + } + + private static IConfiguration NovaConfiguracao(IEnumerable>? values = null) + { + ConfigurationBuilder builder = new(); + if (values is not null) + { + builder.AddInMemoryCollection(values); + } + + return builder.Build(); + } + + private static IHostEnvironment NovoAmbiente(string environmentName) + { + IHostEnvironment environment = Substitute.For(); + environment.EnvironmentName.Returns(environmentName); + return environment; + } +}