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;
+ }
+}