Skip to content

ADR 0001 — Backend OpenAPI and logging standardization#

Status Accepted
Date 2026-05-14
Authors Ruben Knuijver
Supersedes
Superseded by

Context#

Two cross-cutting concerns drifted into five different patterns across the .NET 10 backend over the lifetime of the solution:

OpenAPI — surveyed across 7 HTTP API projects, five distinct setups were in use:

Project Pattern
CommandCenter Swashbuckle, dev-only Swagger UI, no AddSwaggerGen (broken)
CommandCenter.WebApi Swashbuckle + manual OpenApiInfo/OpenApiContact/OpenApiLicense
CommandCenter.DiagnosticApi Bare AddOpenApi(), no MapOpenApi() (no document served)
CommandCenter.Keyplex AddOpenApi() and AddSwaggerGen(), custom YAML path /openapi/keyplex/openapi.yaml
CommandCenter.SystemStatsAPI AddOpenApi() + MapOpenApi() (dev-only)
CommandCenter.Bff.Frontdoor None (gateway)
CommandCenter.BenefitsReverseProxy None (proxy)

The mix made onboarding hard ("which API has a UI? at which path?") and surfaced two concrete bugs:

  1. NU1608 build failureSerilog.Sinks.ApplicationInsights 5.0.1 requires Microsoft.ApplicationInsights (>= 2.23.0 && < 3.0.0) but 3.1.1 was resolved transitively. Directory.Packages.props has TreatWarningsAsErrors=true, so restore aborted across all 12 projects. Investigation showed the Serilog sink was never actually configured at runtime — dead packages bloating the dependency graph.
  2. CS0200 build failureMicrosoft.OpenApi 3.x made IOpenApiMediaType.Example read-only. The Microsoft.AspNetCore.OpenApi 10.0.7 source generator emits Example = … assignments. Four projects had a Microsoft.OpenApi 3.5.3 pin that overrode the older transitive version the generator was built against, breaking compilation.

Logging — surveyed across all 11 backend projects:

  • All projects that called Shared.Logging.AddLoggingConfiguration() got the same boilerplate Serilog setup driven by their appsettings.json Serilog block.
  • Four of 11 projects (CommandCenter.DiagnosticApi, CommandCenter.Keyplex, CommandCenter.BenefitsAgent, CommandCenter.ServerAgent) had missing or empty Serilog sections, meaning they lost console/file output entirely or defaulted to whatever framework loggers picked up.
  • Exactly one project (CommandCenter.Bff.Frontdoor/LogEvents.cs) used the [LoggerMessage] source generator (3 partial methods). Every other call site used logger.LogXxx(…) with string interpolation — re-parsing the template, allocating per call, and missing the structured-property benefits.
  • OpenTelemetry → Azure Monitor was wired in 4 of 7 API projects and 0 of 4 workers (Shared.Tracing/OpenTelemetryExtensions.cs).

The user asked for a uniform standard across the solution: OpenAPI in every HTTP API project, and a logging path that switches easily between console (ANSI-coloured), file, and a remote server. The decision below codifies the standard that the May 2026 standardization sweep introduced.

Decision#

1. OpenAPI generator: Microsoft.AspNetCore.OpenApi (native), not Swashbuckle#

Adopt Microsoft.AspNetCore.OpenApi 10.0.7+ as the uniform OpenAPI generator across every HTTP API project. It is the recommended generator on .NET 10, produces OpenAPI 3.1 documents natively, ships with MapOpenApi, and supports JSON+YAML output from a single endpoint pattern. Swashbuckle is removed from the solution.

2. Uniform wiring via Shared.Web.OpenApiExtensions#

Every HTTP API registers OpenAPI through two extension methods in backend/Shared.Web/OpenApiExtensions.cs:

builder.Services.AddCommandCenterOpenApi(
    title: "CommandCenter Web API",
    description: "Analytics and management endpoints.");

// after var app = builder.Build();
app.MapCommandCenterOpenApi();

Endpoints (always exposed — not Development-gated):

  • GET /openapi/v1.json
  • GET /openapi/v1.yaml (and .yml)

UI: Scalar at GET /scalar/v1, Development only.

Scope:

  • In scope: CommandCenter, CommandCenter.WebApi, CommandCenter.DiagnosticApi, CommandCenter.Keyplex, CommandCenter.SystemStatsAPI.
  • Out of scope: CommandCenter.Bff.Frontdoor and CommandCenter.BenefitsReverseProxy (YARP gateways — no endpoints of their own), and the four worker services (no HTTP surface).

3. Logging defaults are code-first#

backend/Shared.Logging/ConfigurationExtensions.cs::AddLoggingConfiguration is the single entry point for every host. The defaults are now configured in code, not in each appsettings.json:

  • Console sink with AnsiConsoleTheme.Code — renders ANSI colour in Windows Terminal, VS Code, Docker logs, and CI runners.
  • Async rolling file sink — Logs/app-.txt, daily roll, 7-file retention, 50 MB cap, shared writer.
  • Level overrides in code: Information in non-Development, Debug in Development; Microsoft.AspNetCore capped at Warning; MassTransit follows the Dev/Prod toggle.

.ReadFrom.Configuration(builder.Configuration) still layers on top, so per-host Serilog overrides in appsettings.json continue to work for ad-hoc additions (e.g. a Seq sink for one environment). The existing duplicate Console/File blocks were removed from all 9 appsettings.json files because they would have doubled the sinks.

4. Remote logging exclusively via OpenTelemetry#

backend/Shared.Tracing/OpenTelemetryExtensions.cs is the only path for remote logs, metrics, and traces. Set OTEL_SERVICE_NAME and either APPLICATIONINSIGHTS_CONNECTION_STRING (Azure Monitor) or OTEL_EXPORTER_OTLP_ENDPOINT (any collector) and everything flows. The OpenTelemetry logger provider already captures every ILogger event — including those that pass through Serilog — so there is no need for a second pipeline.

The previously-referenced Serilog.Sinks.ApplicationInsights and Microsoft.ApplicationInsights packages were removed from Shared.Logging; they were never wired at runtime and were the root cause of NU1608.

5. Semantic logging via the [LoggerMessage] source generator#

For any non-trivial log statement, use the source-generated logger pattern. backend/Shared.Logging/SharedLog.cs is the canonical template and documents the EventId convention:

using Microsoft.Extensions.Logging;

namespace CommandCenter.WebApi;

public static partial class WebApiLog
{
    [LoggerMessage(
        EventId = 3001,
        Level = LogLevel.Information,
        Message = "Analytics query {QueryId} executed in {ElapsedMs}ms returning {RowCount} rows")]
    public static partial void AnalyticsQueryExecuted(
        this ILogger logger,
        string queryId,
        long elapsedMs,
        int rowCount);
}

EventId 1000-range per service (see SharedLog.cs for the full table). Source generation eliminates message-template re-parsing, boxes nothing, and emits the IsEnabled checks automatically.

Consequences#

Positive

  • One mental model across the solution. New API projects copy two lines (AddCommandCenterOpenApi + MapCommandCenterOpenApi) and inherit the security posture (UI gated to Development, JSON/YAML always available).
  • OpenAPI 3.1 by default, with a build-time path that can be re-enabled when upstream stabilises.
  • Developer UX: Scalar at /scalar/v1 is faster, cleaner, and offline-first compared to Swagger UI.
  • Semantic, allocation-free logging on hot paths; consistent structured properties enable better Azure Monitor / KQL queries.
  • One remote-log pipeline (OpenTelemetry → Azure Monitor) means one place to look during incidents.

Negative / tracked

  • Build-time OpenAPI export disabled. Microsoft.Extensions.ApiDescription.Server 10.0.7 hits a JSON serialization bug on one of CommandCenter.WebApi's response schemas. We set <OpenApiGenerateDocumentsOnBuild>false</OpenApiGenerateDocumentsOnBuild> in CommandCenter, CommandCenter.WebApi, and CommandCenter.Keyplex. Runtime /openapi/v1.json is the source of truth; CI can curl that endpoint if it needs the contract artefact. Revisit when the upstream fix lands.
  • Per-project security schemes are still per-project. Microsoft.OpenApi 3.x reshaped the security types: OpenApiSecurityScheme.Reference is gone, replaced by the separate OpenApiSecuritySchemeReference type. The initial standard ships with only the document-info transformer; security schemes are a project concern until the first concrete need codifies a new shared pattern in Shared.Web.OpenApiExtensions.

Breaking

  • CommandCenter.Keyplex previously served the document at /openapi/keyplex/openapi.yaml. The new path is /openapi/v1.yaml. External consumers of the legacy URL must be updated.

Maintenance posture

  • The [LoggerMessage] rollout starts from a handful of seed entries in SharedLog.cs plus the existing three in LogEvents.cs. Migrating existing logger.LogXxx(…) call sites is opportunistic — touch a file, upgrade its log lines in the same PR.
  • Adding a new HTTP API project: include <ProjectReference Include="..\Shared.Web\Shared.Web.csproj" />, call the two extension methods in Program.cs. Nothing else.

Alternatives considered#

  • Stay on Swashbuckle. Rejected. Microsoft.AspNetCore.OpenApi is the supported generator on .NET 10, ships in-box, supports OpenAPI 3.1 natively, and removes a third-party dependency whose primary remaining value is the Swagger UI bundle.
  • Keep Swashbuckle just for the Swagger UI. Rejected. Scalar is the community default in 2026 against the native generator, ships as a single NuGet package, and renders both the bundled doc and an interactive client without AddSwaggerGen ceremony.
  • Mixed approach — some projects keep Swashbuckle. Rejected. Uniformity was the explicit goal; the mixed state was already the problem.
  • Add Serilog.Sinks.OpenTelemetry as an opt-in remote sink. Rejected. Duplicates the OpenTelemetry logger provider that already captures everything routed through ILogger. Two pipelines mean two places for events to diverge.
  • Delete appsettings.json Serilog blocks entirely (code-only). Rejected. Keeping .ReadFrom.Configuration lets ops add a Seq sink or override a per-env level without redeploying. The defaults are in code; the overrides are still in config.

References#