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
2 changes: 2 additions & 0 deletions backend/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,8 @@ Cada linha é um lembrete; a regra, com o porquê, está na seção indicada da
unicidade; migração só aditiva.** [§5](docs/ARCHITECTURE.md#5-tenancy-and-persistence)
- **Controller sem lógica; enum no fio é string.** [§6](docs/ARCHITECTURE.md#6-http-surface)
- **UTC, sempre.** [§7](docs/ARCHITECTURE.md#7-time)
- **Log com `ILogger<T>` e template com placeholders, nunca string interpolada; nível em `Serilog:MinimumLevel`.**
[§8](docs/ARCHITECTURE.md#logging)
- **Corpo em bloco com guard clauses; sem comentário de "o quê".** [§8](docs/ARCHITECTURE.md#8-code-style)

## Portões
Expand Down
2 changes: 2 additions & 0 deletions backend/AdminBackend.slnx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@
</Folder>
<Folder Name="/shared/">
<Project Path="shared/Admin.Identity.Client/Admin.Identity.Client.csproj" />
<Project Path="shared/Admin.Logging.Tests/Admin.Logging.Tests.csproj" />
<Project Path="shared/Admin.Logging/Admin.Logging.csproj" />
<Project Path="shared/Admin.SharedKernel.AspNetCore/Admin.SharedKernel.AspNetCore.csproj" />
<Project Path="shared/Admin.SharedKernel.EntityFrameworkCore/Admin.SharedKernel.EntityFrameworkCore.csproj" />
<Project Path="shared/Admin.SharedKernel.Tests/Admin.SharedKernel.Tests.csproj" />
Expand Down
4 changes: 4 additions & 0 deletions backend/AppHost/AppHost.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
using Admin.Logging;

var builder = DistributedApplication.CreateBuilder(args);

builder.Services.AddReadableLogging(builder.Configuration, builder.Environment);

var developmentPassword = builder.AddParameter(
"development-password",
"postgres",
Expand Down
1 change: 1 addition & 0 deletions backend/AppHost/AppHost.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
</ItemGroup>

<ItemGroup>
<ProjectReference Include="..\shared\Admin.Logging\Admin.Logging.csproj" IsAspireProjectResource="false" />
<ProjectReference Include="..\services\identity-service\IdentityService.Api\IdentityService.Api.csproj" />
<ProjectReference Include="..\services\services-service\ServicesService.Api\ServicesService.Api.csproj" />
</ItemGroup>
Expand Down
8 changes: 0 additions & 8 deletions backend/AppHost/appsettings.Development.json

This file was deleted.

10 changes: 5 additions & 5 deletions backend/AppHost/appsettings.json
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning",
"Aspire.Hosting.Dcp": "Warning"
"Serilog": {
"MinimumLevel": {
"Override": {
"Aspire.Hosting.Dcp": "Warning"
}
}
}
}
4 changes: 4 additions & 0 deletions backend/Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,10 @@
<PackageVersion Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.19.0" />
<PackageVersion Include="OpenTelemetry.Instrumentation.Http" Version="1.19.0" />
<PackageVersion Include="OpenTelemetry.Instrumentation.Runtime" Version="1.19.0" />
<!-- Admin.Logging: the one logging pipeline of every .NET host (docs/adr/0054) -->
<PackageVersion Include="Serilog.AspNetCore" Version="10.0.0" />
<PackageVersion Include="Serilog.Expressions" Version="5.0.0" />
<PackageVersion Include="Serilog.Sinks.OpenTelemetry" Version="4.2.0" />
<!-- ASP.NET Core / API surface -->
<PackageVersion Include="Asp.Versioning.Mvc" Version="10.2.1" />
<PackageVersion Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.12" />
Expand Down
4 changes: 2 additions & 2 deletions backend/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,9 @@ services/<service>/
├── <Service>.Api/ ASP.NET Core controllers — → Application + Infrastructure
├── <Service>.Tests/ unit tests of Domain + Application
└── <Service>.PersistenceTests/ EF InMemory tenant tests, where a service needs them
shared/ cross-cutting infrastructure — never business rules
shared/ cross-cutting infrastructure (incl. Admin.Logging) — never business rules
AppHost/ .NET Aspire, local orchestration only
ServiceDefaults/ OpenTelemetry, health checks, service discovery
ServiceDefaults/ logging (via Admin.Logging), OpenTelemetry, health checks, service discovery
```

Each service is one business context with its own schema and database role. The project-reference
Expand Down
19 changes: 12 additions & 7 deletions backend/ServiceDefaults/Extensions.cs
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
using Admin.Logging;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Diagnostics.HealthChecks;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Diagnostics.HealthChecks;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.ServiceDiscovery;
using OpenTelemetry;
using OpenTelemetry.Metrics;
Expand All @@ -17,6 +17,8 @@ public static class Extensions

public static TBuilder AddServiceDefaults<TBuilder>(this TBuilder builder) where TBuilder : IHostApplicationBuilder
{
builder.ConfigureLogging();

builder.ConfigureOpenTelemetry();

builder.AddDefaultHealthChecks();
Expand All @@ -33,14 +35,17 @@ public static TBuilder AddServiceDefaults<TBuilder>(this TBuilder builder) where
return builder;
}

public static TBuilder ConfigureOpenTelemetry<TBuilder>(this TBuilder builder) where TBuilder : IHostApplicationBuilder
public static TBuilder ConfigureLogging<TBuilder>(this TBuilder builder) where TBuilder : IHostApplicationBuilder
{
builder.Logging.AddOpenTelemetry(logging =>
{
logging.IncludeFormattedMessage = true;
logging.IncludeScopes = true;
});
builder.Services.AddReadableLogging(builder.Configuration, builder.Environment, colorWhenRedirected: true);

builder.Services.AddRequestLogging(HealthEndpointPath, AlivenessEndpointPath);

return builder;
}

public static TBuilder ConfigureOpenTelemetry<TBuilder>(this TBuilder builder) where TBuilder : IHostApplicationBuilder
{
builder.Services.AddOpenTelemetry()
.WithMetrics(metrics =>
{
Expand Down
4 changes: 4 additions & 0 deletions backend/ServiceDefaults/ServiceDefaults.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -19,4 +19,8 @@
<PackageReference Include="OpenTelemetry.Instrumentation.Runtime" />
</ItemGroup>

<ItemGroup>
<ProjectReference Include="..\shared\Admin.Logging\Admin.Logging.csproj" />
</ItemGroup>

</Project>
17 changes: 15 additions & 2 deletions backend/docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,8 +49,9 @@ that wraps two stores in one transaction. Don't carry its specifics into a busin

A new service copies the project set below, the per-service `Domain/Common/` types, a schema and a
role in `infra/postgres/init/`, a schema-scoped migrations history table, and an AppHost resource;
it registers `TenantHeaderFilter` if it serves tenant-owned resources. Use the live services as the
template, not a copied snippet ([`docs/MONOREPO.md`](../../docs/MONOREPO.md)).
it registers `TenantHeaderFilter` if it serves tenant-owned resources. `AddServiceDefaults()` already
brings logging, telemetry and health checks, so there is nothing to copy for them. Use the live services
as the template, not a copied snippet ([`docs/MONOREPO.md`](../../docs/MONOREPO.md)).

### Projects and dependency direction

Expand All @@ -74,6 +75,7 @@ Shared projects in `backend/shared/` hold infrastructure, never business rules:
| `Admin.SharedKernel.AspNetCore` | `ToActionResult`, `ApiResponse<T>`, `ApiProblemDetails`, `AgenzaControllerBase`, `GenericExceptionHandler` | Api |
| `Admin.SharedKernel.EntityFrameworkCore` | `RepositoryBase<T>`, `ApplyAuditableConventions` (soft-delete and tenant filters) | Infrastructure |
| `Admin.Identity.Client` | JWT validation, `ITenantAccessor`, `ICurrentUserAccessor`, `TenantHeaderFilter`, `[IgnoreTenant]` | Infrastructure, Api |
| `Admin.Logging` | the Serilog pipeline: console format, default levels, OTLP export, one line per request | `ServiceDefaults`, `AppHost` — never a layer |

`BaseEntity`, `TenantOwnedEntity`, `DomainResult` and `DomainError` are **duplicated per service on
purpose** — Domain references nothing, so it cannot share them
Expand Down Expand Up @@ -354,6 +356,17 @@ Older code is converted when touched, not in bulk.
No "what" comments and no XML doc comments; a one-line "why" only for a genuine race or a non-obvious
constraint (root [`AGENTS.md`](../../AGENTS.md)). Rationale belongs in an ADR.

### Logging

Inject `ILogger<T>` and write a message template with named placeholders, never an interpolated string. Serilog is
the pipeline behind it, owned by `Admin.Logging` and brought in by `AddServiceDefaults()`
([0054](../../docs/adr/0054-serilog-readable-console-logging.md)): a service configures nothing. Domain and Application
stay on `Microsoft.Extensions.Logging.Abstractions` and nothing calls the static `Log`. The default levels live in
`Admin.Logging`; a host that needs another level sets it under `Serilog:MinimumLevel` in its `appsettings.json`, and drops a
noisy message by text with `Serilog:Filter` (identity-service does, for OpenIddict's request dumps)
(`Logging:LogLevel` is ignored). A message carries constraint names, codes and ids, not request input; when it must,
strip control characters first (CWE-117), as `GenericExceptionHandler` and the request line do.

## 9. Tests

| Tier | Project | Proves | Tools |
Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,4 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"ConnectionStrings": {
"Default": "Host=localhost;Port=5432;Database=appdb;Username=identity_app;Password=postgres"
},
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,13 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
"Serilog": {
"Filter": [
{
"Name": "ByExcluding",
"Args": {
"expression": "StartsWith(SourceContext, 'OpenIddict') and (@mt like '%was successfully%' or @mt like 'The request URI matched%')"
}
}
]
},
"AllowedHosts": "*",
"DatabaseBootstrap": {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,4 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"ConnectionStrings": {
"Default": "Host=localhost;Port=5432;Database=appdb;Username=services_app;Password=postgres"
},
Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,4 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*",
"DatabaseBootstrap": {
"RunOnStartup": false
Expand Down
29 changes: 29 additions & 0 deletions backend/shared/Admin.Logging.Tests/Admin.Logging.Tests.csproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
</PropertyGroup>

<ItemGroup>
<PackageReference Include="AwesomeAssertions" />
<PackageReference Include="coverlet.msbuild" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" />
<PackageReference Include="Microsoft.NET.Test.Sdk" />
<PackageReference Include="NSubstitute" />
<PackageReference Include="xunit.v3.mtp-off" />
<PackageReference Include="xunit.runner.visualstudio" />
</ItemGroup>

<ItemGroup>
<Using Include="Xunit" />
<Using Include="AwesomeAssertions" />
</ItemGroup>

<ItemGroup>
<ProjectReference Include="..\Admin.Logging\Admin.Logging.csproj" />
</ItemGroup>

</Project>
115 changes: 115 additions & 0 deletions backend/shared/Admin.Logging.Tests/ReadableConsoleTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
using System.Globalization;
using Microsoft.Extensions.Logging;
using Serilog;
using Serilog.Extensions.Logging;

namespace Admin.Logging.Tests;

internal sealed class SampleHandler;

public class ReadableConsoleTests
{
[Fact]
public void Format_ShowsTheFullSourceContext()
{
var output = Render(colors: false, logger => logger
.ForContext("SourceContext", "Microsoft.Hosting.Lifetime")
.Information("Now listening on: {Address}", "http://localhost:5080"));

output.Should().MatchRegex(@"^\[\d{2}:\d{2}:\d{2} INF\] Microsoft\.Hosting\.Lifetime: Now listening on: http://localhost:5080\n$");
}

[Fact]
public void Format_ForAnILoggerOfT_ShowsTheFullNameOfThatClass()
{
var output = Render(colors: false, logger =>
{
using var factory = new SerilogLoggerFactory(logger);
factory.CreateLogger<SampleHandler>().LogInformation("Client {ClientId} created", 42);
});

output.Should().MatchRegex(@"^\[\d{2}:\d{2}:\d{2} INF\] Admin\.Logging\.Tests\.SampleHandler: Client 42 created\n$");
}

[Fact]
public void Format_WithoutASourceContext_OmitsIt()
{
var output = Render(colors: false, logger => logger.Warning("Something odd"));

output.Should().MatchRegex(@"^\[\d{2}:\d{2}:\d{2} WRN\] Something odd\n$");
}

[Fact]
public void Format_WithAnException_WritesItAfterTheMessageLine()
{
var output = Render(colors: false, logger => logger.Error(new InvalidOperationException("boom"), "Save failed"));

var lines = output.Split('\n');
lines[0].Should().EndWith("Save failed");
lines[1].Should().Contain("InvalidOperationException: boom");
}

[Fact]
public void Format_UsesTheInvariantCulture()
{
var previous = CultureInfo.CurrentCulture;
CultureInfo.CurrentCulture = new CultureInfo("pt-BR");

try
{
var output = Render(colors: false, logger => logger.Information("took {Elapsed:0.0000} ms", 35.8));

output.Should().Contain("took 35.8000 ms");
}
finally
{
CultureInfo.CurrentCulture = previous;
}
}

[Fact]
public void Format_WithColors_EmitsAnsiCodesEvenWhenOutputIsRedirected()
{
var output = Render(colors: true, logger => logger.Information("hello"));

output.Should().Contain("\u001b[");
}

[Fact]
public void Format_WithoutColors_EmitsNoAnsiCodes()
{
var output = Render(colors: false, logger => logger.Information("hello"));

output.Should().NotContain("\u001b[");
}

[Theory]
[InlineData(true, false, false, false, true)]
[InlineData(true, false, true, false, false)]
[InlineData(true, false, true, true, true)]
[InlineData(true, true, false, true, false)]
[InlineData(false, false, false, true, false)]
public void ShouldUseColors_FollowsEnvironmentRedirectionAndNoColor(
bool isDevelopment,
bool noColorRequested,
bool outputRedirected,
bool colorWhenRedirected,
bool expected)
{
var colors = ReadableConsole.ShouldUseColors(isDevelopment, noColorRequested, outputRedirected, colorWhenRedirected);

colors.Should().Be(expected);
}

private static string Render(bool colors, Action<Serilog.ILogger> write)
{
var output = new StringWriter();
var logger = new LoggerConfiguration()
.WriteTo.Sink(new FormattingSink(ReadableConsole.CreateFormatter(colors), output))
.CreateLogger();

write(logger);

return output.ToString();
}
}
Loading
Loading