Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// Configuração canônica do pipeline Serilog Uni+: lê <c>appsettings</c>, override
/// de Microsoft/EF para Warning, <c>FromLogContext</c>, <see cref="PiiMaskingEnricher"/>
/// (ADR-0011), Console sink. Quando <c>Observability:Enabled</c> não está desligado,
/// adiciona o sink OTLP gRPC (ADR-0018) — logs fluem ao Collector → Loki com correlação
/// <c>traceId</c>/<c>spanId</c> automática por evento.
/// </summary>
public static class SerilogConfiguration
{
public static LoggerConfiguration ConfigurarSerilog(this LoggerConfiguration loggerConfiguration, IConfiguration configuration)
/// <summary>
/// Sobrecarga sem nome de serviço — mantida para callers que ainda não precisam
/// rotular logs com <c>service.name</c>/<c>service.namespace</c> no Loki.
/// O sink OTLP é registrado mesmo assim (quando o toggle está ativo); apenas
/// não popula <c>ResourceAttributes</c>, deixando o sink usar suas próprias
/// inferências (env vars padrão OTel, se presentes).
/// </summary>
public static LoggerConfiguration ConfigurarSerilog(
this LoggerConfiguration loggerConfiguration,
IConfiguration configuration)
=> loggerConfiguration.ConfigurarSerilog(configuration, nomeServico: null);

/// <summary>
/// Configura o pipeline com <c>service.name</c>/<c>service.namespace</c> aplicados
/// ao Resource do sink OTLP — habilita queries LogQL como
/// <c>{service_name="uniplus-selecao"}</c>.
/// </summary>
/// <remarks>
/// <para>O Console sink é sempre preservado para que <c>docker logs</c> continue
/// útil em bootstrap debugging.</para>
/// <para><see cref="PiiMaskingEnricher"/> 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.</para>
/// <para>Endpoint OTLP é lido pela env var <c>OTEL_EXPORTER_OTLP_ENDPOINT</c>
/// (default <c>http://localhost:4317</c>). Os atributos <c>service.*</c> são
/// injetados aqui porque o pipeline Serilog→OTLP é independente do pipeline
/// OTel SDK (<see cref="OpenTelemetryConfiguration.AdicionarObservabilidade"/>) —
/// duplicação intencional para evitar drift entre logs e traces no Loki/Tempo.</para>
/// </remarks>
/// <param name="loggerConfiguration">Configuração base do Serilog.</param>
/// <param name="configuration">Configuração da aplicação — fornece toggle
/// <see cref="OpenTelemetryConfiguration.EnabledConfigurationKey"/> e overrides
/// de level via <c>Serilog</c> section.</param>
/// <param name="nomeServico">Nome canônico do serviço para rotular o sink OTLP
/// (<c>service.name</c> + <c>service.namespace</c>). Quando <c>null</c>, sink
/// é registrado sem ResourceAttributes.</param>
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<PiiMaskingEnricher>()
.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<string, object>
{
["service.name"] = nomeServico,
["service.namespace"] = OpenTelemetryConfiguration.ServiceNamespaceResourceValue,
};
}
});
}

return loggerConfiguration;
}
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
namespace Unifesspa.UniPlus.Infrastructure.Core.Middleware;

using System.Diagnostics;
using System.Text.RegularExpressions;

using Microsoft.AspNetCore.Http;
Expand All @@ -13,6 +14,13 @@ public sealed partial class CorrelationIdMiddleware
public const string LogContextProperty = "CorrelationId";
public const int MaxCorrelationIdLength = 128;

/// <summary>
/// Nome do span attribute (Activity tag) que carrega o correlation_id no Tempo.
/// Lido por <c>derivedFields</c> do Loki datasource em Grafana para o drill-down
/// log → trace, fechando o ciclo log ↔ trace ↔ dashboard (ADR-0018).
/// </summary>
public const string ActivityTagName = "correlation_id";

private readonly RequestDelegate _next;

public CorrelationIdMiddleware(RequestDelegate next)
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// Registra OpenTelemetry com instrumentações canônicas Uni+ (tracing + metrics)
/// e exporter OTLP para a stack LGTM institucional. ADR-0018.
/// </summary>
public static class OpenTelemetryConfiguration
{
public static IServiceCollection AdicionarObservabilidade(this IServiceCollection services, string nomeServico)
/// <summary>
/// Nome canônico do <see cref="System.Diagnostics.ActivitySource"/> emitido
/// pelo Wolverine. Registrado tanto em <c>WithTracing.AddSource</c> quanto em
/// <c>WithMetrics.AddMeter</c> para que command/handler/outbox executions
/// apareçam em Tempo (traces) e Prometheus (métricas) — ADR-0018.
/// </summary>
public const string WolverineActivityAndMeterName = "Wolverine";


/// <summary>
/// Toggle de configuração para observabilidade. Default <c>true</c>; quando
/// <c>false</c>, nenhum <see cref="TracerProvider"/> ou <see cref="MeterProvider"/>
/// é 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 <see cref="Logging.SerilogConfiguration"/>
/// para condicionar o sink OTLP de logs.
/// </summary>
public const string EnabledConfigurationKey = "Observability:Enabled";

/// <summary>
/// Atributo Resource canônico do OTel para particionar telemetria entre
/// Development/Staging/Production nos dashboards Grafana.
/// </summary>
public const string DeploymentEnvironmentResourceAttribute = "deployment.environment";

/// <summary>
/// Namespace canônico Uni+ — todas as APIs do projeto compartilham. Permite
/// queries cross-service em PromQL/LogQL/TraceQL via
/// <c>service_namespace="uniplus"</c>.
/// </summary>
public const string ServiceNamespaceResourceValue = "uniplus";

/// <summary>
/// Sampling ratio head-based para ambientes não-Development. 10% conforme
/// ADR-0018; tail-based 100% para erros e latência alta é responsabilidade
/// do <c>tail_sampling_processor</c> no Collector, não da API.
/// </summary>
public const double ProductionSamplingRatio = 0.1;

/// <summary>
/// Registra OpenTelemetry com instrumentações canônicas Uni+ e exporter OTLP.
/// Endpoint OTLP é lido automaticamente pela env var
/// <c>OTEL_EXPORTER_OTLP_ENDPOINT</c> (default <c>http://localhost:4317</c>, gRPC).
/// </summary>
/// <remarks>
/// <para><b>Tracing:</b> AspNetCore + EF Core + HttpClient + ActivitySource nominal
/// (<paramref name="nomeServico"/>).</para>
/// <para><b>Metrics:</b> AspNetCore + Runtime + HttpClient + Meter nominal +
/// Wolverine (instrumentação built-in via <c>System.Diagnostics.Metrics.Meter</c>
/// nativo do framework — ADR-0018).</para>
/// <para><b>Sampler:</b> <see cref="AlwaysOnSampler"/> em Development,
/// <see cref="ParentBasedSampler"/> com <see cref="TraceIdRatioBasedSampler"/>
/// (10%) nos demais.</para>
/// </remarks>
/// <param name="services">A coleção de serviços.</param>
/// <param name="nomeServico">Nome canônico do serviço (ex.: <c>uniplus-selecao</c>),
/// usado como <c>service.name</c> no Resource e como nome da
/// <c>ActivitySource</c>/<c>Meter</c> nominal.</param>
/// <param name="configuration">Configuração da aplicação.</param>
/// <param name="environment">Ambiente de hosting — define o sampler e popula
/// <c>deployment.environment</c> no Resource.</param>
/// <returns>A própria <paramref name="services"/> para encadeamento fluente.</returns>
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<KeyValuePair<string, object>> resourceAttributes = new[]
{
new KeyValuePair<string, object>(
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)
Comment thread
marmota-alpina marked this conversation as resolved.
.AddSource(WolverineActivityAndMeterName)
.AddAspNetCoreInstrumentation()
.AddHttpClientInstrumentation()
.AddEntityFrameworkCoreInstrumentation()
.AddOtlpExporter())
.WithMetrics(metrics => metrics
.AddMeter(nomeServico)
.AddMeter(WolverineActivityAndMeterName)
.AddAspNetCoreInstrumentation()
.AddSource(nomeServico));
.AddHttpClientInstrumentation()
.AddRuntimeInstrumentation()
.AddOtlpExporter());

return services;
}

/// <summary>
/// Seleciona o sampler conforme ambiente: <see cref="AlwaysOnSampler"/>
/// em <c>Development</c> (debugging local — todos os spans), e
/// <see cref="ParentBasedSampler"/> com <see cref="TraceIdRatioBasedSampler"/>
/// em <see cref="ProductionSamplingRatio"/> (10%) nos demais ambientes —
/// ADR-0018 head-based sampling. Tail-based 100% para erro/latência alta
/// é responsabilidade do <c>tail_sampling_processor</c> no Collector.
/// </summary>
/// <remarks>
/// Extraído como <c>internal static</c> exatamente para tornar a regra de
/// seleção testável sem precisar inspecionar o <c>TracerProvider</c> via
/// reflection — <c>InternalsVisibleTo</c> em <c>Infrastructure.Core.csproj</c>
/// expõe esta API para <c>Unifesspa.UniPlus.Infrastructure.Core.UnitTests</c>.
/// </remarks>
internal static Sampler SelecionarSampler(IHostEnvironment environment)
{
ArgumentNullException.ThrowIfNull(environment);

return environment.IsDevelopment()
? new AlwaysOnSampler()
: new ParentBasedSampler(new TraceIdRatioBasedSampler(ProductionSamplingRatio));
}
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
namespace Unifesspa.UniPlus.Infrastructure.Core.UnitTests.Middleware;

using System.Diagnostics;

using AwesomeAssertions;

using Microsoft.AspNetCore.Http;
Expand Down Expand Up @@ -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<ActivityContext> _) => 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<ICorrelationIdWriter>();
CorrelationIdMiddleware middleware = new(_ => Task.CompletedTask);

Func<Task> 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
Expand Down
Loading
Loading