diff --git a/src/ingresso/Unifesspa.UniPlus.Ingresso.API/Program.cs b/src/ingresso/Unifesspa.UniPlus.Ingresso.API/Program.cs index 41843c9e..f6cacfab 100644 --- a/src/ingresso/Unifesspa.UniPlus.Ingresso.API/Program.cs +++ b/src/ingresso/Unifesspa.UniPlus.Ingresso.API/Program.cs @@ -7,6 +7,7 @@ using Unifesspa.UniPlus.Infrastructure.Core.Logging; using Unifesspa.UniPlus.Infrastructure.Core.Messaging; using Unifesspa.UniPlus.Infrastructure.Core.Middleware; +using Unifesspa.UniPlus.Infrastructure.Core.Observability; using Unifesspa.UniPlus.Infrastructure.Core.Profile; using Unifesspa.UniPlus.Infrastructure.Core.Smoke; using Unifesspa.UniPlus.Ingresso.API.Errors; @@ -15,8 +16,11 @@ WebApplicationBuilder builder = WebApplication.CreateBuilder(args); +// service.name canônico — ver explicação em Selecao.API/Program.cs. +const string nomeServicoIngresso = "uniplus-ingresso"; + builder.Host.UseSerilog((context, loggerConfig) => - loggerConfig.ConfigurarSerilog(context.Configuration)); + loggerConfig.ConfigurarSerilog(context.Configuration, nomeServicoIngresso)); builder.Services.AddControllers() .AddJsonOptions(options => @@ -42,6 +46,10 @@ builder.Services.AddOidcAuthentication(builder.Configuration, builder.Environment); builder.Services.AddCorrelationIdAccessor(); builder.Services.AddRequestLogging(builder.Configuration); + +// Observabilidade (ADR-0018) — ver explicação em Selecao.API/Program.cs. +builder.Services.AdicionarObservabilidade(nomeServicoIngresso, builder.Configuration, builder.Environment); + // AddIngressoInfrastructure agora resolve a connection string via // IConfiguration injetada no factory do AddDbContext (issue #204) — // simetria com UseWolverineOutboxCascading e com Selecao. diff --git a/src/ingresso/Unifesspa.UniPlus.Ingresso.API/appsettings.json b/src/ingresso/Unifesspa.UniPlus.Ingresso.API/appsettings.json index 6995faba..0b793231 100644 --- a/src/ingresso/Unifesspa.UniPlus.Ingresso.API/appsettings.json +++ b/src/ingresso/Unifesspa.UniPlus.Ingresso.API/appsettings.json @@ -18,6 +18,9 @@ "Authority": "", "Audience": "uniplus" }, + "Observability": { + "Enabled": true + }, "Serilog": { "Using": ["Serilog.Sinks.Console"], "MinimumLevel": { diff --git a/src/portal/Unifesspa.UniPlus.Portal.API/Program.cs b/src/portal/Unifesspa.UniPlus.Portal.API/Program.cs index 770ca670..3e72ff14 100644 --- a/src/portal/Unifesspa.UniPlus.Portal.API/Program.cs +++ b/src/portal/Unifesspa.UniPlus.Portal.API/Program.cs @@ -9,6 +9,7 @@ using Unifesspa.UniPlus.Infrastructure.Core.Logging; using Unifesspa.UniPlus.Infrastructure.Core.Messaging; using Unifesspa.UniPlus.Infrastructure.Core.Middleware; +using Unifesspa.UniPlus.Infrastructure.Core.Observability; using Unifesspa.UniPlus.Infrastructure.Core.Profile; using Unifesspa.UniPlus.Infrastructure.Core.Smoke; using Unifesspa.UniPlus.Portal.API.Errors; @@ -17,8 +18,11 @@ WebApplicationBuilder builder = WebApplication.CreateBuilder(args); +// service.name canônico — ver explicação em Selecao.API/Program.cs. +const string nomeServicoPortal = "uniplus-portal"; + builder.Host.UseSerilog((context, loggerConfig) => - loggerConfig.ConfigurarSerilog(context.Configuration)); + loggerConfig.ConfigurarSerilog(context.Configuration, nomeServicoPortal)); builder.Services.AddControllers() .AddJsonOptions(options => @@ -53,6 +57,10 @@ builder.Services.AddOidcAuthentication(builder.Configuration, builder.Environment); builder.Services.AddCorrelationIdAccessor(); builder.Services.AddRequestLogging(builder.Configuration); + +// Observabilidade (ADR-0018) — ver explicação em Selecao.API/Program.cs. +builder.Services.AdicionarObservabilidade(nomeServicoPortal, builder.Configuration, builder.Environment); + // AddPortalInfrastructure resolve a connection string via IConfiguration // injetada no factory do AddDbContext — simetria com Selecao/Ingresso (#204). builder.Services.AddPortalInfrastructure(); diff --git a/src/portal/Unifesspa.UniPlus.Portal.API/appsettings.json b/src/portal/Unifesspa.UniPlus.Portal.API/appsettings.json index 97f05c6f..57ce026a 100644 --- a/src/portal/Unifesspa.UniPlus.Portal.API/appsettings.json +++ b/src/portal/Unifesspa.UniPlus.Portal.API/appsettings.json @@ -18,6 +18,9 @@ "Authority": "", "Audience": "uniplus" }, + "Observability": { + "Enabled": true + }, "Serilog": { "Using": ["Serilog.Sinks.Console"], "MinimumLevel": { diff --git a/src/selecao/Unifesspa.UniPlus.Selecao.API/Program.cs b/src/selecao/Unifesspa.UniPlus.Selecao.API/Program.cs index a032f4c4..dbf7d8d1 100644 --- a/src/selecao/Unifesspa.UniPlus.Selecao.API/Program.cs +++ b/src/selecao/Unifesspa.UniPlus.Selecao.API/Program.cs @@ -11,6 +11,7 @@ using Unifesspa.UniPlus.Infrastructure.Core.Messaging; using Unifesspa.UniPlus.Infrastructure.Core.Messaging.SchemaRegistry; using Unifesspa.UniPlus.Infrastructure.Core.Middleware; +using Unifesspa.UniPlus.Infrastructure.Core.Observability; using Unifesspa.UniPlus.Infrastructure.Core.Profile; using Unifesspa.UniPlus.Infrastructure.Core.Smoke; using Unifesspa.UniPlus.Selecao.API.Errors; @@ -28,8 +29,14 @@ WebApplicationBuilder builder = WebApplication.CreateBuilder(args); +// service.name canônico para Resource OTel (tracing/metrics) e ResourceAttributes +// do sink OTLP do Serilog (logs). Mantido em const local para garantir igualdade +// estrita entre os 2 pipelines — drift de naming entre logs e traces seria a +// 1ª coisa a quebrar drill-down Loki↔Tempo no Grafana. +const string nomeServicoSelecao = "uniplus-selecao"; + builder.Host.UseSerilog((context, loggerConfig) => - loggerConfig.ConfigurarSerilog(context.Configuration)); + loggerConfig.ConfigurarSerilog(context.Configuration, nomeServicoSelecao)); builder.Services.AddControllers() .AddJsonOptions(options => @@ -71,6 +78,13 @@ builder.Services.AddOidcAuthentication(builder.Configuration, builder.Environment); builder.Services.AddCorrelationIdAccessor(); builder.Services.AddRequestLogging(builder.Configuration); + +// Observabilidade (ADR-0018) — tracing + metrics via OpenTelemetry SDK para o Collector +// institucional. Logs já fluem via Serilog OTLP sink configurado em UseSerilog acima. +// Toggle Observability:Enabled em appsettings; default true. Em test factories sem +// Collector provisionado, sobrescrever para false em InMemoryCollection. +builder.Services.AdicionarObservabilidade(nomeServicoSelecao, builder.Configuration, builder.Environment); + builder.Services.AddSelecaoApplication(); // AddSelecaoInfrastructure agora resolve a connection string via // IConfiguration injetada no factory do AddDbContext (issue #204) — diff --git a/src/selecao/Unifesspa.UniPlus.Selecao.API/appsettings.json b/src/selecao/Unifesspa.UniPlus.Selecao.API/appsettings.json index 055f7ad3..fd377366 100644 --- a/src/selecao/Unifesspa.UniPlus.Selecao.API/appsettings.json +++ b/src/selecao/Unifesspa.UniPlus.Selecao.API/appsettings.json @@ -18,6 +18,9 @@ "Authority": "", "Audience": "uniplus" }, + "Observability": { + "Enabled": true + }, "Serilog": { "Using": ["Serilog.Sinks.Console"], "MinimumLevel": { diff --git a/tests/Unifesspa.UniPlus.Infrastructure.Core.IntegrationTests/Observability/OpenTelemetryWiringTests.cs b/tests/Unifesspa.UniPlus.Infrastructure.Core.IntegrationTests/Observability/OpenTelemetryWiringTests.cs new file mode 100644 index 00000000..3cf21906 --- /dev/null +++ b/tests/Unifesspa.UniPlus.Infrastructure.Core.IntegrationTests/Observability/OpenTelemetryWiringTests.cs @@ -0,0 +1,102 @@ +namespace Unifesspa.UniPlus.Infrastructure.Core.IntegrationTests.Observability; + +using System.Collections.Generic; +using System.Diagnostics; + +using AwesomeAssertions; + +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.FileProviders; +using Microsoft.Extensions.Hosting; + +using OpenTelemetry.Trace; + +using Unifesspa.UniPlus.Infrastructure.Core.Observability; +using Unifesspa.UniPlus.IntegrationTests.Fixtures.Hosting; + +[Collection(OtelCollectorContainerFixture.CollectionName)] +public sealed class OpenTelemetryWiringTests(OtelCollectorContainerFixture collector) +{ + private const string ServiceName = "uniplus-test-otel-wiring"; + private const string EnvVarName = "OTEL_EXPORTER_OTLP_ENDPOINT"; + + [Fact] + public async Task AdicionarObservabilidade_PipelineEndToEnd_ExportaSpanRotuladoParaCollector() + { + // O OtlpExporter padrão lê o endpoint da env var OTEL_EXPORTER_OTLP_ENDPOINT + // (sem opção de override via DI sem mudar a assinatura pública). Setamos + // process-wide e restauramos no finally — xUnit não paraleliza dentro da + // mesma collection, então não há race com outros testes. + string? endpointAnterior = Environment.GetEnvironmentVariable(EnvVarName); + try + { + Environment.SetEnvironmentVariable(EnvVarName, collector.GrpcEndpoint); + + ServiceCollection services = new(); + services.AddLogging(); + IConfiguration configuration = new ConfigurationBuilder().Build(); + IHostEnvironment environment = new TestHostEnvironment("Development"); + + services.AdicionarObservabilidade(ServiceName, configuration, environment); + + await using ServiceProvider provider = services.BuildServiceProvider(); + + // Resolver o TracerProvider força o startup do pipeline OTel — exporter + // OTLP abre a conexão gRPC com o Collector neste ponto. + TracerProvider? tracerProvider = provider.GetService(); + tracerProvider.Should().NotBeNull("AdicionarObservabilidade deve registrar um TracerProvider quando Observability:Enabled é o default true"); + + // ActivitySource compartilha o nome do serviço — registrado em + // AddSource(nomeServico) na pipeline. Sem isso, listeners do OTel + // SDK ignoram os spans emitidos. + using ActivitySource source = new(ServiceName); + using (Activity? span = source.StartActivity("test-wiring-span")) + { + span.Should().NotBeNull("o sampler em Development é AlwaysOn — span não pode ser dropado"); + span!.SetTag("test.scenario", "wiring-end-to-end"); + } + + // ForceFlush garante que o batch processor envia imediatamente em vez + // de esperar o intervalo (5s default). Timeout 5s é margem para gRPC + // handshake + envio em runners CI mais lentos. + tracerProvider!.ForceFlush(timeoutMilliseconds: 5_000).Should().BeTrue(); + + // Buffer extra (200ms) para o Collector processar o batch recebido e + // emitir no exporter debug. Heurística: na prática 50ms basta, 200ms + // dá folga sem aumentar tempo do teste materialmente. + await Task.Delay(TimeSpan.FromMilliseconds(200)); + + string collectorOutput = await collector.GetLogsAsync(); + + collectorOutput.Should().Contain("ResourceSpans", because: "o exporter debug do Collector escreve o nome do tipo OTLP recebido em stderr"); + collectorOutput.Should().Contain(ServiceName, because: $"o Resource attribute service.name precisa chegar até o Collector — confirma que ConfigureResource(...AddService(\"{ServiceName}\")) está wired"); + collectorOutput.Should().Contain("test-wiring-span", because: "o nome do span emitido localmente precisa aparecer no output do Collector — confirma que AddOtlpExporter() está exportando de fato"); + collectorOutput.Should().Contain(OpenTelemetryConfiguration.ServiceNamespaceResourceValue, because: "service.namespace=uniplus deve chegar no Resource exportado"); + } + finally + { + Environment.SetEnvironmentVariable(EnvVarName, endpointAnterior); + } + } + + /// + /// Stub minimal de — só popula + /// EnvironmentName (única propriedade lida por + /// e + /// ). + /// Evita NSubstitute aqui porque o test project não referencia o pacote + /// (kept lean — IntegrationTests usa Testcontainers + AwesomeAssertions + /// e nada além). + /// + private sealed class TestHostEnvironment(string environmentName) : IHostEnvironment + { + public string EnvironmentName { get; set; } = environmentName; + + public string ApplicationName { get; set; } = "Unifesspa.UniPlus.Infrastructure.Core.IntegrationTests"; + + public string ContentRootPath { get; set; } = AppContext.BaseDirectory; + + public IFileProvider ContentRootFileProvider { get; set; } = new NullFileProvider(); + } +} diff --git a/tests/Unifesspa.UniPlus.Infrastructure.Core.IntegrationTests/Observability/OtelCollectorCollection.cs b/tests/Unifesspa.UniPlus.Infrastructure.Core.IntegrationTests/Observability/OtelCollectorCollection.cs new file mode 100644 index 00000000..ccb6b34e --- /dev/null +++ b/tests/Unifesspa.UniPlus.Infrastructure.Core.IntegrationTests/Observability/OtelCollectorCollection.cs @@ -0,0 +1,18 @@ +namespace Unifesspa.UniPlus.Infrastructure.Core.IntegrationTests.Observability; + +using System.Diagnostics.CodeAnalysis; + +using Unifesspa.UniPlus.IntegrationTests.Fixtures.Hosting; + +[CollectionDefinition(OtelCollectorContainerFixture.CollectionName)] +[SuppressMessage( + "Naming", + "CA1711:Identifiers should not have incorrect suffix", + Justification = "Convenção xUnit: o sufixo 'Collection' identifica a classe como CollectionDefinition.")] +[SuppressMessage( + "Performance", + "CA1515:Consider making public types internal", + Justification = "xUnit 2.x exige que CollectionDefinitions sejam públicas para que o runner as descubra.")] +public sealed class OtelCollectorCollection : ICollectionFixture +{ +} diff --git a/tests/Unifesspa.UniPlus.IntegrationTests.Fixtures/Hosting/ApiFactoryBase.cs b/tests/Unifesspa.UniPlus.IntegrationTests.Fixtures/Hosting/ApiFactoryBase.cs index d5cfdf5f..d80af8cf 100644 --- a/tests/Unifesspa.UniPlus.IntegrationTests.Fixtures/Hosting/ApiFactoryBase.cs +++ b/tests/Unifesspa.UniPlus.IntegrationTests.Fixtures/Hosting/ApiFactoryBase.cs @@ -20,6 +20,37 @@ namespace Unifesspa.UniPlus.IntegrationTests.Fixtures.Hosting; public abstract class ApiFactoryBase : WebApplicationFactory where TEntryPoint : class { + /// + /// Static initializer que silencia o pipeline OpenTelemetry para todas as suites + /// HTTP-only que usam . + /// + /// + /// Por que env var em vez de ConfigureAppConfiguration? + /// Em minimal hosting (WebApplication.CreateBuilder(args)), o + /// Program.csbuilder.Configuration e chama + /// AdicionarObservabilidade/ConfigurarSerilog ANTES de + /// builder.Build() rodar — momento em que o + /// ConfigureAppConfiguration do + /// é aplicado. Override via AddInMemoryCollection chega tarde demais + /// e o exporter OTLP fica tentando localhost:4317 em loop. + /// Env var (Observability__Enabled=false) é lida pelo + /// EnvironmentVariablesConfigurationProvider default do + /// CreateBuilder — chega A TEMPO da leitura no Program.cs. + /// Process-wide: setar env var afeta o processo inteiro. + /// Aceitável aqui porque (a) é idempotente, (b) o único test no projeto que + /// exercita observabilidade real + /// (OpenTelemetryWiringTests) constrói + /// manualmente sem CreateBuilder, então não é afetado pela env var, e + /// (c) o OpenTelemetryWiringTests também sobrescreve + /// OTEL_EXPORTER_OTLP_ENDPOINT via try/finally, demonstrando o + /// padrão para testes que queiram OTel ligado. + /// + static ApiFactoryBase() + { + Environment.SetEnvironmentVariable("Observability__Enabled", "false"); + } + + /// /// Quando true (default), o factory remove o /// da lista de @@ -81,6 +112,10 @@ protected override void ConfigureWebHost(IWebHostBuilder builder) builder.ConfigureAppConfiguration((_, configurationBuilder) => { + // Observability:Enabled=false é injetado via env var no static ctor + // desta classe (ver explicação no XML doc) — ConfigureAppConfiguration + // roda em builder.Build() e seria tarde demais para o Program.cs ler + // antes de chamar AdicionarObservabilidade. configurationBuilder.AddInMemoryCollection(GetConfigurationOverrides()); }); diff --git a/tests/Unifesspa.UniPlus.IntegrationTests.Fixtures/Hosting/OtelCollectorContainerFixture.cs b/tests/Unifesspa.UniPlus.IntegrationTests.Fixtures/Hosting/OtelCollectorContainerFixture.cs new file mode 100644 index 00000000..5a5699d0 --- /dev/null +++ b/tests/Unifesspa.UniPlus.IntegrationTests.Fixtures/Hosting/OtelCollectorContainerFixture.cs @@ -0,0 +1,112 @@ +namespace Unifesspa.UniPlus.IntegrationTests.Fixtures.Hosting; + +using System.Text; + +using DotNet.Testcontainers.Builders; +using DotNet.Testcontainers.Configurations; +using DotNet.Testcontainers.Containers; + +/// +/// Sobe um otel/opentelemetry-collector-contrib via Testcontainers configurado com +/// receivers OTLP (gRPC + HTTP) e exporter debug com verbosity detailed — +/// permite que testes E2E afirmem que spans/metrics/logs chegaram ao Collector inspecionando +/// o stdout do container via . Compartilhada via +/// [Collection("OtelCollector")]; cada assembly que usa declara sua própria +/// [CollectionDefinition] com o mesmo nome. +/// +/// +/// O config YAML é montado inline via WithResourceMapping (bytes em memória, sem +/// arquivo temp em disco) — único exporter habilitado é debug, o que garante isolamento +/// (não há rede saindo do Collector para Loki/Tempo/Prometheus em ambientes de CI). +/// A imagem é fixada em uma tag estável da família 0.x; alinhar com a versão +/// usada pelo Collector institucional reduz superfície de surpresa quando o exporter ou +/// receiver evoluir. Atualização vem via Renovate (uniplus-api#366). +/// +public sealed class OtelCollectorContainerFixture : IAsyncLifetime +{ + public const string Image = "otel/opentelemetry-collector-contrib:0.117.0"; + public const string CollectionName = "OtelCollector"; + + private const ushort GrpcPort = 4317; + private const ushort HttpPort = 4318; + private const string ConfigPath = "/etc/otelcol-contrib/config.yaml"; + + private readonly IContainer _container; + + public OtelCollectorContainerFixture() + { + byte[] config = Encoding.UTF8.GetBytes(BuildMinimalConfig()); + + _container = new ContainerBuilder(Image) + .WithPortBinding(GrpcPort, true) + .WithPortBinding(HttpPort, true) + .WithResourceMapping(config, ConfigPath) + .WithCommand("--config", ConfigPath) + .WithWaitStrategy( + Wait.ForUnixContainer() + .UntilMessageIsLogged("Everything is ready. Begin running and processing data.")) + .Build(); + } + + /// + /// Endpoint OTLP gRPC no formato http://host:port (apto a alimentar + /// OTEL_EXPORTER_OTLP_ENDPOINT). + /// + public string GrpcEndpoint => + $"http://{_container.Hostname}:{_container.GetMappedPublicPort(GrpcPort)}"; + + /// + /// Lê stdout + stderr acumulados do container concatenados em uma única string. + /// Usado pelos testes para afirmar que spans/metrics/logs foram exportados + /// (procuram tokens como "ResourceSpans", "service.name"). + /// + /// + /// O otel/opentelemetry-collector-contrib emite o output do exporter + /// debug em stderr (não stdout) — incluímos os dois aqui + /// para que a fixture funcione consistentemente independente de qual stream + /// o Collector escolher emitir em versões futuras. + /// + public async Task GetLogsAsync() + { + (string Stdout, string Stderr) logs = await _container.GetLogsAsync().ConfigureAwait(false); + return string.Concat(logs.Stdout, logs.Stderr); + } + + public Task InitializeAsync() => _container.StartAsync(); + + public async Task DisposeAsync() => + await _container.DisposeAsync().ConfigureAwait(false); + + private static string BuildMinimalConfig() => """ + receivers: + otlp: + protocols: + grpc: + endpoint: 0.0.0.0:4317 + http: + endpoint: 0.0.0.0:4318 + + processors: + batch: + timeout: 100ms + + exporters: + debug: + verbosity: detailed + + service: + pipelines: + traces: + receivers: [otlp] + processors: [batch] + exporters: [debug] + metrics: + receivers: [otlp] + processors: [batch] + exporters: [debug] + logs: + receivers: [otlp] + processors: [batch] + exporters: [debug] + """; +}