From 9a953b89f2e2065db8fbbbc91bb6b65d0058ca38 Mon Sep 17 00:00:00 2001 From: Moritz Waldau Date: Sun, 6 Sep 2026 13:04:06 +0200 Subject: [PATCH 1/2] update logging --- .editorconfig | 14 ++ CLAUDE.md | 6 + Directory.Build.props | 9 +- Directory.Packages.props | 10 + docs/assessments/2026-05-07-v1.md | 6 + docs/assessments/2026-05-09-v1.md | 6 + docs/logging.md | 223 ++++++++++++++++++ docs/operations.md | 13 +- .../Behaviors/LoggingBehavior.cs | 34 ++- .../Behaviors/ValidationBehavior.cs | 18 +- .../OrderSphereDataClassifications.cs | 48 ++++ .../OrderSphere.BuildingBlocks.csproj | 5 + .../Security/AmbientCorrelationContext.cs | 36 +++ .../EventBusDiagnostics.cs | 56 ++++- .../MessageProcessingScope.cs | 98 ++++++++ .../ProcessorLog.cs | 70 ++++++ .../Components/AdvisorDrawer.razor | 2 +- .../Services/LoggingHandler.cs | 23 +- .../OrderSphere.ApiGateway/Program.cs | 7 +- .../appsettings.Development.json | 2 + .../OrderSphere.ApiGateway/appsettings.json | 5 + .../Auth/RefreshTokenHandler.cs | 2 +- .../Workers/RealtimeNotificationProcessor.cs | 12 +- .../appsettings.Development.json | 3 + .../OrderSphere.Bff/appsettings.Testing.json | 11 + src/Gateways/OrderSphere.Bff/appsettings.json | 5 + .../appsettings.Development.json | 3 +- .../OrderSphere.AppHost/appsettings.json | 1 + .../ClientCredentialsTokenHandler.cs | 3 +- .../Authentication/OAuthErrorReader.cs | 58 +++++ .../OrderSphere.ServiceDefaults/Extensions.cs | 93 +++++++- .../Logging/CorrelationPropagationHandler.cs | 31 +++ .../Logging/OrderSphereLogEnricher.cs | 52 ++++ .../Logging/OrderSphereStaticLogEnricher.cs | 34 +++ .../RequestContextEnrichmentMiddleware.cs | 79 +++++-- .../OrderSphere.ServiceDefaults.csproj | 5 + .../Security/SecurityAuditLogger.cs | 44 +++- .../Workers/CustomerErasureProcessor.cs | 14 +- .../appsettings.Development.json | 4 +- .../OrderSphere.Advisory.Api/appsettings.json | 9 +- .../OrderSphere.Mcp.Server/Program.cs | 4 + .../appsettings.Development.json | 4 +- .../OrderSphere.Mcp.Server/appsettings.json | 9 +- .../OrderSphere.Basket.Api/appsettings.json | 14 ++ .../OrderSphere.Catalog.Api/appsettings.json | 5 + .../Workers/CustomerErasureProcessor.cs | 14 +- .../Workers/InvoiceProcessor.cs | 19 +- .../appsettings.json | 5 + .../Channels/PushNotificationChannel.cs | 4 +- .../Channels/SmsNotificationChannel.cs | 4 +- .../Clients/FallbackUserProfileClient.cs | 2 +- .../Clients/HttpUserProfileClient.cs | 6 +- .../Email/LoggingNotificationEmailService.cs | 17 +- .../Email/NotificationEmailService.cs | 8 +- .../OrderSphere.Notification.Worker/Log.cs | 118 +++++++++ .../OrderSphere.Notification.Worker.csproj | 3 + .../Program.cs | 4 + .../Workers/InvoiceGeneratedProcessor.cs | 11 +- .../Workers/NotificationProcessor.cs | 13 +- .../appsettings.json | 14 ++ .../OrderSphere.Ordering.Api/appsettings.json | 14 ++ .../Checkout/CheckoutCartCommandHandler.cs | 4 +- .../Coupon/ValidateCouponQueryHandler.cs | 4 +- .../OrderSphere.Ordering.Worker/Program.cs | 12 +- .../Workers/CustomerErasureProcessor.cs | 14 +- .../Workers/OrderHistoryProjector.cs | 16 +- .../Workers/OrderProcessor.cs | 26 +- .../Workers/PaymentRefundProcessor.cs | 16 +- .../Workers/PaymentResultProcessor.cs | 16 +- .../appsettings.json | 14 ++ .../appsettings.Development.json | 4 +- .../OrderSphere.Partners.Api/appsettings.json | 7 +- .../OrderSphere.Payment.Api/appsettings.json | 9 +- .../OrderSphere.Payment.Worker/Program.cs | 4 + .../Workers/CustomerErasureProcessor.cs | 14 +- .../OrderConfirmationFailedProcessor.cs | 18 +- .../Workers/PaymentProcessor.cs | 17 +- .../Workers/RefundRequestedProcessor.cs | 18 +- .../appsettings.Development.json | 8 + .../appsettings.json | 7 +- .../appsettings.json | 14 ++ .../appsettings.Development.json | 4 +- .../OrderSphere.Webhooks.Api/appsettings.json | 7 +- .../OrderSphere.Webhooks.Worker/Program.cs | 4 + .../Workers/WebhookEventProcessor.cs | 13 +- .../appsettings.Development.json | 4 +- .../appsettings.json | 7 +- .../OrderSphere.Domain.Tests.csproj | 1 + .../AmbientCorrelationContextTests.cs | 60 +++++ .../MessageProcessingScopeTests.cs | 108 +++++++++ ...here.EventBus.AzureServiceBus.Tests.csproj | 1 + .../Logging/LogEnrichmentTests.cs | 78 ++++++ .../Logging/TracesSampleRatioTests.cs | 62 +++++ .../OrderSphere.IntegrationTests.csproj | 1 + .../Logging/LogRedactionTests.cs | 74 ++++++ .../OrderSphere.Notification.Tests.csproj | 5 + .../Behaviors/ValidationBehaviorTests.cs | 19 +- 97 files changed, 1893 insertions(+), 243 deletions(-) create mode 100644 docs/logging.md create mode 100644 src/BuildingBlocks/OrderSphere.BuildingBlocks.Domain/Compliance/OrderSphereDataClassifications.cs create mode 100644 src/BuildingBlocks/OrderSphere.BuildingBlocks.Domain/Security/AmbientCorrelationContext.cs create mode 100644 src/BuildingBlocks/OrderSphere.BuildingBlocks.EventBus.AzureServiceBus/MessageProcessingScope.cs create mode 100644 src/BuildingBlocks/OrderSphere.BuildingBlocks.EventBus.AzureServiceBus/ProcessorLog.cs create mode 100644 src/Hosting/OrderSphere.ServiceDefaults/Authentication/OAuthErrorReader.cs create mode 100644 src/Hosting/OrderSphere.ServiceDefaults/Logging/CorrelationPropagationHandler.cs create mode 100644 src/Hosting/OrderSphere.ServiceDefaults/Logging/OrderSphereLogEnricher.cs create mode 100644 src/Hosting/OrderSphere.ServiceDefaults/Logging/OrderSphereStaticLogEnricher.cs create mode 100644 src/Services/Basket/OrderSphere.Basket.Api/appsettings.json create mode 100644 src/Services/Notification/OrderSphere.Notification.Worker/Log.cs create mode 100644 src/Services/Notification/OrderSphere.Notification.Worker/appsettings.json create mode 100644 src/Services/Ordering/OrderSphere.Ordering.Api/appsettings.json create mode 100644 src/Services/Ordering/OrderSphere.Ordering.Worker/appsettings.json create mode 100644 src/Services/UserProfile/OrderSphere.UserProfile.Api/appsettings.json create mode 100644 tests/OrderSphere.Domain.Tests/Security/AmbientCorrelationContextTests.cs create mode 100644 tests/OrderSphere.EventBus.AzureServiceBus.Tests/MessageProcessingScopeTests.cs create mode 100644 tests/OrderSphere.IntegrationTests/Logging/LogEnrichmentTests.cs create mode 100644 tests/OrderSphere.IntegrationTests/Logging/TracesSampleRatioTests.cs create mode 100644 tests/OrderSphere.Notification.Tests/Logging/LogRedactionTests.cs diff --git a/.editorconfig b/.editorconfig index 0c96cec0..a6aa11e3 100644 --- a/.editorconfig +++ b/.editorconfig @@ -71,6 +71,20 @@ dotnet_style_readonly_field = true:suggestion # Unused usings flagged (kept as warning so analyzers surface them) dotnet_diagnostic.IDE0005.severity = warning +# --- Logging (see docs/logging.md) ------------------------------------------ + +# CA2254: the logging message template must be a compile-time constant. An interpolated or +# concatenated template produces a distinct template per call, which destroys grouping in the +# log backend and defeats structured querying. Error: there are zero violations today, so this +# is a ratchet rather than a backlog. +dotnet_diagnostic.CA2254.severity = error + +# CA1848: prefer the LoggerMessage source generator over the ILogger.LogX extension methods. +# Suggestion rather than warning: it is required on hot paths and on anything carrying +# classified data (redaction depends on it), but forcing it on every one of the ~250 remaining +# call sites would be churn without benefit. +dotnet_diagnostic.CA1848.severity = suggestion + # --- Naming conventions ----------------------------------------------------- # Interfaces start with I diff --git a/CLAUDE.md b/CLAUDE.md index 0391e955..3018dd80 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -34,6 +34,12 @@ These are the rules that are not derivable by reading existing code. For everyth - Soft-delete is enforced by a global query filter, not per-query: every `AuditableEntity` gets `builder.HasQueryFilter(x => !x.IsDeleted)` in its EF configuration, so queries inherit the filter automatically — do not repeat `!x.IsDeleted` in handlers. Use `IgnoreQueryFilters()` only where deleted rows must be read deliberately. - All I/O is `async`/`await`. No `.Result`, no `.Wait()`, no `.GetAwaiter().GetResult()`. - Nullable reference types are enabled. Treat warnings as real. +- Log messages use constant templates with named placeholders — never interpolation or concatenation. `tenant_id`, `correlation_id`, `user_id` and `trace_id` are added by enrichment in ServiceDefaults; do not pass them as template arguments. A `Result` failure logs at `Warning`, not `Error`. +- Personal data may only be logged through a `[LoggerMessage]` method whose parameter carries a classification attribute (`[DirectPii]`, `[PseudonymousId]`, `[FreeText]`) — redaction does not apply to plain `logger.LogX(...)` calls. + +## Logging + +Log schema, field names, level policy, EventId ranges and PII enforcement live in [`docs/logging.md`](docs/logging.md). Read it before adding a log statement in a hot path or one that touches customer data. ## UI and styling diff --git a/Directory.Build.props b/Directory.Build.props index ebc9caf6..82e5cb50 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -1,4 +1,4 @@ - + - $(NoWarn);CS8669 + + $(NoWarn);CS8669;EXTEXP0002 + + + + + @@ -95,6 +103,8 @@ + + diff --git a/docs/assessments/2026-05-07-v1.md b/docs/assessments/2026-05-07-v1.md index 61fe312c..17b6771c 100644 --- a/docs/assessments/2026-05-07-v1.md +++ b/docs/assessments/2026-05-07-v1.md @@ -1,5 +1,11 @@ # OrderSphere — Project assessment +> **Correction (2026-09-06).** The observability rows below list Serilog. That was never +> accurate: OrderSphere has only ever used Microsoft.Extensions.Logging with the +> OpenTelemetry provider (`git log -S Serilog` returns no commit). See +> [logging.md](../logging.md) for the actual pipeline. + + > **⚠️ SUPERSEDED (2026-06-17).** This document assesses the former Blazor Server monolith > (`OrderSphere.UI` + ASP.NET Identity, 7 projects), which has since been replaced by an 8-service > microservices platform. The bug IDs (B*) and critical issues (K*) below reference files that no diff --git a/docs/assessments/2026-05-09-v1.md b/docs/assessments/2026-05-09-v1.md index 7eeb988d..aba30367 100644 --- a/docs/assessments/2026-05-09-v1.md +++ b/docs/assessments/2026-05-09-v1.md @@ -1,5 +1,11 @@ # OrderSphere — Project assessment +> **Correction (2026-09-06).** The observability rows below list Serilog. That was never +> accurate: OrderSphere has only ever used Microsoft.Extensions.Logging with the +> OpenTelemetry provider (`git log -S Serilog` returns no commit). See +> [logging.md](../logging.md) for the actual pipeline. + + > **⚠️ SUPERSEDED (2026-06-17).** This document assesses the former Blazor Server monolith > (`OrderSphere.UI` + ASP.NET Identity, 7 projects), which has since been replaced by an 8-service > microservices platform. The bug IDs (B*) and critical issues (K*) below reference files that no diff --git a/docs/logging.md b/docs/logging.md new file mode 100644 index 00000000..c8373ff3 --- /dev/null +++ b/docs/logging.md @@ -0,0 +1,223 @@ +# Structured Logging + +Binding conventions for log output across all OrderSphere services. Companion to +[operations.md](operations.md) (telemetry export, dashboards, alerts) and +[data-classification.md](data-classification.md) (which data is sensitive and why). + +## Pipeline + +`Microsoft.Extensions.Logging` with the OpenTelemetry logger provider. There is no Serilog and +none is planned: the OTel provider is already the single export path to the Aspire dashboard and +Azure Monitor, and it attaches `trace_id` / `span_id` to every record. + +Two extensions sit on top, both wired centrally in +`src/Hosting/OrderSphere.ServiceDefaults/Extensions.cs` and therefore active in all 22 hosts: + +| Concern | Package | Entry point | +|---|---|---| +| Enrichment | `Microsoft.Extensions.Telemetry` | `builder.Logging.EnableEnrichment()` | +| Redaction | `Microsoft.Extensions.Compliance.Redaction` | `builder.Logging.EnableRedaction()` | + +`EnableEnrichment()` replaces the default `ILoggerFactory` with `ExtendedLoggerFactory`. +Enrichment tags are written into the log-record state, which the OpenTelemetry provider reads as +attributes — so enriched fields appear as Custom Dimensions in Application Insights with no +per-service configuration. + +## Field set + +Every record carries these without the call site doing anything: + +| Field | Source | Present in | +|---|---|---| +| `service.name`, `service.version`, `deployment.environment` | OTel `Resource` | all | +| `trace_id`, `span_id` | OTel logger provider | all (when a trace is active) | +| `service_instance_id`, `build_version` | `OrderSphereStaticLogEnricher` | all | +| `tenant_id` | `AmbientTenantContext` via `OrderSphereLogEnricher` | when a tenant scope is open | +| `correlation_id` | `AmbientCorrelationContext` via `OrderSphereLogEnricher` | requests and message loops | +| `user_id` | `IHttpContextAccessor` (`sub` claim) via `OrderSphereLogEnricher` | authenticated HTTP requests | +| `client_ip_hash` | `RequestContextEnrichmentMiddleware` | HTTP requests | +| `message_id`, `event_type`, `queue` | `MessageProcessingScope` | Service Bus message loops | + +The enricher reads `AsyncLocal` slots rather than `HttpContext`. That is the whole reason worker +records carry the same fields as API records: the message loop opens the ambient scopes and the +same singleton enricher picks them up, with no worker code aware of logging infrastructure. + +### Naming + +- Enrichment and scope keys: `snake_case` (`tenant_id`), matching OpenTelemetry convention. +- Message-template placeholders: `PascalCase` (`{OrderId}`), matching the `ILogger` convention. +- Source-generated `[LoggerMessage]` parameters: `camelCase`, which is what the generator emits + as the property name. + +## Correlation + +One value, end to end: + +1. The API Gateway sets `X-Request-Id` when absent, **seeded from the current trace id** + (`ApiGateway/Program.cs`). Client-visible id, `correlation_id` and `trace_id` are the same + string. +2. `RequestContextEnrichmentMiddleware` opens `AmbientCorrelationContext` from that header and + echoes it on the response. +3. `CorrelationPropagationHandler`, registered on `ConfigureHttpClientDefaults`, puts it on every + outgoing service-to-service call. +4. `EventBusDiagnostics.Inject` writes it onto the Service Bus message as `x-request-id`. +5. `MessageProcessingScope` reads it back on the consuming side, falling back to the message's + `traceparent` trace id and finally to the message id. +6. Across the outbox — where only `traceparent` is persisted on the row — + `EventBusDiagnostics.RestorePublishParent` reopens the correlation scope from the restored + trace id. Because step 1 seeds from the trace id, this is the same value; no outbox column and + no schema change were needed. + +`IntegrationEvent.CorrelationId` is unrelated: it is a business idempotency key. Where it is +logged it is named `EventCorrelationId` to keep the two apart. + +## Levels + +| Level | Use | Notes | +|---|---|---| +| `Trace` | never enabled in production | | +| `Debug` | flow detail: handler entry, message received, validation failures | default off in production | +| `Information` | the business outcome, **once** per unit of work | not per step | +| `Warning` | expected failure, including every `Result` failure | the normal level for "not found", "already processed", "declined" | +| `Error` | unexpected exception, or risk of data loss | includes dead-lettering | +| `Critical` | the process can no longer do its job | | + +Consequences worth stating explicitly, because they are easy to get wrong: + +- **A `Result` failure is a `Warning`, not an `Error`.** An empty cart, an unknown coupon, a + missing payment to refund — these are outcomes the code handles deliberately. +- **`LoggingBehavior` logs handler success at `Debug`.** It fires for every query on every API, + and the duration it reports is already in the request span and the + `ordersphere.mediatr.request.duration` histogram. An `Information` record per read would be + duplication at the single highest-volume point in the system. +- **Message processors emit one `Information` record per message**, at the end. Receipt is + `Debug`; the identifiers that used to be repeated in the text are structured fields on the + scope. +- Never log an exception's `Message` as the template. Pass the exception as the first argument: + `logger.LogWarning(ex, "...")`. + +## PII + +The rule is in [data-classification.md](data-classification.md); this is how it is enforced. + +**Redaction applies only to `[LoggerMessage]` parameters carrying a classification attribute.** +A plain `logger.LogInformation("... {Email}", email)` is *not* redacted. Personal data must +therefore be logged through a source-generated method. + +```csharp +[LoggerMessage(EventId = 8002, Level = LogLevel.Information, + Message = "Confirmation email sent for order {orderId}.")] +public static partial void OrderConfirmationEmailSent( + this ILogger logger, Guid orderId, [DirectPii] string recipient); +``` + +Attributes live in `BuildingBlocks.Domain/Compliance/OrderSphereDataClassifications.cs` and map to +the tiers in data-classification.md: + +| Attribute | Tier | Redactor | Rationale | +|---|---|---|---| +| `[DirectPii]` | T1 — name, email, address | HMAC (key id 1) | groupable per customer, never readable | +| `[PseudonymousId]` | T2 — session id, client IP | HMAC (key id 2) | separate key id, so T1 and T2 cannot be cross-correlated | +| `[FreeText]` | T4 — chat transcripts, review bodies | erasing | highest re-identification risk, no operational value | + +T3 (financial) has deliberately **no** classification. Amounts and PSP references are not personal +data, are needed verbatim for reconciliation, and the erasing fallback redactor would destroy +them. + +The HMAC key comes from `Logging:Redaction:HmacKey`. It is per-deployment: two environments +produce unrelated hashes. Without configuration a process-lifetime random key is generated — +redaction still holds, only cross-restart correlation is lost. + +Two identifiers stay readable by design: + +- `user_id` (the Auth0 `sub`) — an opaque pseudonymous id; investigations need to pivot from an + audit record to that user's other records. +- `client_ip` is **not** logged; `client_ip_hash` (truncated SHA-256) is, which still groups + requests per client. + +`SecurityAuditLogger` writes each field as its own property with `[PseudonymousId]` on session id +and IP, so audit records are queryable by field instead of parsed out of a delimited string. + +## EventId ranges + +Assigned per area so a record's origin is identifiable without the category string. + +| Range | Area | +|---|---| +| 1000–1099 | `BuildingBlocks.Domain` (MediatR behaviors) | +| 1100–1199 | `EventBus.AzureServiceBus` (processors, outbox, DLQ) | +| 1200–1299 | `ServiceDefaults` (security audit, request pipeline) | +| 2000–2999 | Catalog | +| 3000–3999 | Ordering | +| 4000–4999 | Basket | +| 5000–5999 | Payment | +| 6000–6999 | Invoicing | +| 7000–7999 | Webhooks | +| 8000–8999 | Notification | +| 9000–9999 | UserProfile | +| 10000–10999 | Partners | +| 11000–11999 | Advisory | +| 12000–12999 | Gateways (ApiGateway, BFF) | + +## When to use `[LoggerMessage]` + +Required for anything touching classified data (redaction depends on it), and for the hot paths: +the event bus, the Service Bus processors, the MediatR behaviors, and the cross-service HTTP +clients. Elsewhere plain `logger.LogX` with a constant template is fine — the enrichment and level +policy apply either way. Never build the template with interpolation or concatenation; the +template must be a compile-time constant. + +## Configuration + +Every host's `appsettings.json` carries the same `Logging:LogLevel` shape: + +```json +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning", + "Microsoft.Hosting.Lifetime": "Information", + "OrderSphere": "Information" + } + }, + "OpenTelemetry": { "TracesSampleRatio": 1.0 } +} +``` + +Service-specific overrides (`Yarp`, `Microsoft.EntityFrameworkCore`, +`Microsoft.AspNetCore.Authentication`) are added on top. `appsettings.Development.json` sets +`OrderSphere` to `Debug`. + +`Microsoft.EntityFrameworkCore.Database.Command` is filtered to `Warning` in code +(`ConfigureOpenTelemetry`), so the database story comes from DB spans rather than log spam. + +`OpenTelemetry:TracesSampleRatio` is parsed with `InvariantCulture` and clamped to `[0,1]`. +Both matter: configuration is culture-invariant, but a plain `double.TryParse` uses the host +culture, where `"1.0"` on a de-DE machine parses as `10` and crashes the sampler at startup. +Lower the ratio (0.1–0.2) in production to control cost. + +## Browser logs + +Blazor WASM logs go to the browser console only and are **not exported**. Aspire's Blazor hosting +integration (`ProxyBlazorTelemetry`) is not available in the version this repository uses +(`Aspire.Hosting` 13.5.3 contains no Blazor types, and no official `Aspire.Hosting.Blazor` +package is published), and exposing the dashboard's OTLP endpoint to the browser by hand would +put dashboard credentials in browser-visible configuration. + +The bridge in the meantime: `LoggingHandler` logs the `X-Request-Id` the gateway echoes on failed +and slow calls. A user quoting that id from the console links directly to the server-side records +and the trace. + +## Testing + +`Microsoft.Extensions.Diagnostics.Testing` (`FakeLogger`) is available in the test projects. +Prefer building a real host with `AddServiceDefaults()` and asserting on +`GetFakeLogCollector().GetSnapshot()` — that exercises the actual wiring rather than a class in +isolation. See: + +- `tests/OrderSphere.IntegrationTests/Logging/LogEnrichmentTests.cs` +- `tests/OrderSphere.IntegrationTests/Logging/TracesSampleRatioTests.cs` +- `tests/OrderSphere.Notification.Tests/Logging/LogRedactionTests.cs` — the regression guard that + keeps customer email addresses out of the log stream +- `tests/OrderSphere.EventBus.AzureServiceBus.Tests/MessageProcessingScopeTests.cs` diff --git a/docs/operations.md b/docs/operations.md index 02a856ba..db0a5e48 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -2,7 +2,8 @@ How to observe and operate OrderSphere. The cross-cutting wiring lives in `src/Hosting/OrderSphere.ServiceDefaults` (`Extensions.cs`), applied by every service and gateway. -Deployment is covered separately in [deploy-ordersphere.md](deploy-ordersphere.md). +Deployment is covered separately in [deploy-ordersphere.md](deploy-ordersphere.md); the log +schema, level policy and PII enforcement in [logging.md](logging.md). ## Endpoints exposed by every service @@ -55,10 +56,18 @@ to `10`–`20` depending on volume and cost targets. Log messages must not include personally identifiable information. Enforced by: +- **Redaction on classified log parameters.** `Microsoft.Extensions.Compliance.Redaction` is + active in every host; a `[LoggerMessage]` parameter marked `[DirectPii]`, `[PseudonymousId]` or + `[FreeText]` is replaced before it reaches any sink. This is the primary control — see + [logging.md](logging.md#pii) for the tier-to-redactor mapping and for the important limitation + that redaction does *not* apply to plain `logger.LogX(...)` calls. - `DomainEventLoggingHandler` — logs event type only, never the event payload. -- `LoggingNotificationEmailService` — masks email addresses (`a***@domain.com`). +- `RequestContextEnrichmentMiddleware` — logs `client_ip_hash`, never the raw client IP. - All new log statements must follow the same rule: log IDs and types, not customer data. +The regression guard is `tests/OrderSphere.Notification.Tests/Logging/LogRedactionTests.cs`, +which asserts a customer email address never reaches a sink in plaintext. + ### Where telemetry goes Export is selected by configuration: diff --git a/src/BuildingBlocks/OrderSphere.BuildingBlocks.Domain/Behaviors/LoggingBehavior.cs b/src/BuildingBlocks/OrderSphere.BuildingBlocks.Domain/Behaviors/LoggingBehavior.cs index ef7d565d..f11fb03f 100644 --- a/src/BuildingBlocks/OrderSphere.BuildingBlocks.Domain/Behaviors/LoggingBehavior.cs +++ b/src/BuildingBlocks/OrderSphere.BuildingBlocks.Domain/Behaviors/LoggingBehavior.cs @@ -6,7 +6,7 @@ namespace OrderSphere.BuildingBlocks.Behaviors; -public sealed class LoggingBehavior(ILogger> logger) +public sealed partial class LoggingBehavior(ILogger> logger) : IPipelineBehavior where TRequest : IRequest { @@ -14,7 +14,7 @@ public async Task Handle(TRequest request, RequestHandlerDelegate Handle(TRequest request, RequestHandlerDelegate Handle(TRequest request, RequestHandlerDelegate Handle(TRequest request, RequestHandlerDelegate("outcome", outcome)); } } + + // EventId range 1000-1099 (BuildingBlocks, see docs/logging.md). + + [LoggerMessage(EventId = 1001, Level = LogLevel.Debug, Message = "Handling {requestName}.")] + private static partial void RequestStarting(ILogger logger, string requestName); + + // Debug, not Information: this fires for every query on every API, and the duration it + // reports is already carried by the request span and the ordersphere.mediatr.request.duration + // histogram. An Information record per read would be pure duplication at the highest volume + // point in the system. Failures below stay at Warning/Error, where the level is the signal. + [LoggerMessage(EventId = 1002, Level = LogLevel.Debug, Message = "{requestName} completed in {elapsedMs}ms.")] + private static partial void RequestCompleted(ILogger logger, string requestName, long elapsedMs); + + [LoggerMessage( + EventId = 1003, + Level = LogLevel.Warning, + Message = "{requestName} failed in {elapsedMs}ms: [{errorCode}] {errorDescription}")] + private static partial void RequestFailed( + ILogger logger, string requestName, long elapsedMs, string errorCode, string errorDescription); + + [LoggerMessage(EventId = 1004, Level = LogLevel.Error, Message = "{requestName} threw after {elapsedMs}ms.")] + private static partial void RequestThrew(ILogger logger, Exception exception, string requestName, long elapsedMs); } diff --git a/src/BuildingBlocks/OrderSphere.BuildingBlocks.Domain/Behaviors/ValidationBehavior.cs b/src/BuildingBlocks/OrderSphere.BuildingBlocks.Domain/Behaviors/ValidationBehavior.cs index e16628dd..83655aeb 100644 --- a/src/BuildingBlocks/OrderSphere.BuildingBlocks.Domain/Behaviors/ValidationBehavior.cs +++ b/src/BuildingBlocks/OrderSphere.BuildingBlocks.Domain/Behaviors/ValidationBehavior.cs @@ -1,6 +1,7 @@ using System.Reflection; using FluentValidation; using MediatR; +using Microsoft.Extensions.Logging; using OrderSphere.BuildingBlocks.Primitives; namespace OrderSphere.BuildingBlocks.Behaviors; @@ -11,7 +12,9 @@ namespace OrderSphere.BuildingBlocks.Behaviors; /// are returned as Result.Failure rather than thrown as . /// Non-Result response types fall back to throwing so that existing exception handlers still apply. /// -public sealed class ValidationBehavior(IEnumerable> validators) +public sealed partial class ValidationBehavior( + IEnumerable> validators, + ILogger> logger) : IPipelineBehavior where TRequest : IRequest { @@ -37,6 +40,13 @@ public async Task Handle( var error = Error.ValidationFailure( string.Join("; ", failures.Select(f => f.ErrorMessage))); + // Validation failures previously left no trace at all: the request became a Result + // failure, and only the LoggingBehavior's generic warning showed anything. Logging the + // offending property names (never their values, which are user input) makes a spike of + // client-side validation errors diagnosable. + ValidationFailed(logger, typeof(TRequest).Name, string.Join(", ", + failures.Select(f => f.PropertyName).Distinct())); + // Non-generic Result — return Result.Failure(error). if (typeof(TResponse) == typeof(Result)) return (TResponse)(object)Result.Failure(error); @@ -55,4 +65,10 @@ public async Task Handle( // Fallback for any non-Result handler: preserve existing exception-based behaviour. throw new ValidationException(failures); } + + [LoggerMessage( + EventId = 1005, + Level = LogLevel.Debug, + Message = "{requestName} failed validation on: {properties}")] + private static partial void ValidationFailed(ILogger logger, string requestName, string properties); } diff --git a/src/BuildingBlocks/OrderSphere.BuildingBlocks.Domain/Compliance/OrderSphereDataClassifications.cs b/src/BuildingBlocks/OrderSphere.BuildingBlocks.Domain/Compliance/OrderSphereDataClassifications.cs new file mode 100644 index 00000000..3493489d --- /dev/null +++ b/src/BuildingBlocks/OrderSphere.BuildingBlocks.Domain/Compliance/OrderSphereDataClassifications.cs @@ -0,0 +1,48 @@ +using Microsoft.Extensions.Compliance.Classification; + +namespace OrderSphere.BuildingBlocks.Compliance; + +/// +/// Data classifications for log parameters, mirroring the sensitivity tiers defined in +/// docs/data-classification.md. Applying one of the matching attributes to a +/// [LoggerMessage] parameter makes the redaction pipeline (wired in ServiceDefaults) +/// replace the value before it reaches any sink. +/// +/// Redaction only applies to source-generated log methods whose parameters carry one of these +/// attributes. A plain logger.LogInformation("... {Email}", email) call is NOT redacted — +/// that is why PII must be logged through [LoggerMessage]. See docs/logging.md. +/// +/// +/// Tier T3 (financial) has deliberately no classification here. Payment amounts and PSP +/// references are not personal data, are needed verbatim for reconciliation, and would be +/// destroyed by the erasing fallback redactor if they were classified. +/// +/// +public static class OrderSphereDataClassifications +{ + public const string TaxonomyName = "OrderSphere"; + + /// T1 — directly identifies a natural person: email, name, postal address. + public static DataClassification DirectPii => new(TaxonomyName, nameof(DirectPii)); + + /// T2 — identifies a person only via a join: client IP, session id, Auth0 sub. + public static DataClassification PseudonymousId => new(TaxonomyName, nameof(PseudonymousId)); + + /// T4 — human-authored free text that may incidentally contain PII. + public static DataClassification FreeText => new(TaxonomyName, nameof(FreeText)); +} + +/// Marks a log parameter as T1 direct PII. Redacted with a keyed hash. +[AttributeUsage(AttributeTargets.Parameter | AttributeTargets.Property | AttributeTargets.Field)] +public sealed class DirectPiiAttribute() + : DataClassificationAttribute(OrderSphereDataClassifications.DirectPii); + +/// Marks a log parameter as T2 pseudonymous identifier. Redacted with a keyed hash. +[AttributeUsage(AttributeTargets.Parameter | AttributeTargets.Property | AttributeTargets.Field)] +public sealed class PseudonymousIdAttribute() + : DataClassificationAttribute(OrderSphereDataClassifications.PseudonymousId); + +/// Marks a log parameter as T4 free text with PII risk. Erased entirely. +[AttributeUsage(AttributeTargets.Parameter | AttributeTargets.Property | AttributeTargets.Field)] +public sealed class FreeTextAttribute() + : DataClassificationAttribute(OrderSphereDataClassifications.FreeText); diff --git a/src/BuildingBlocks/OrderSphere.BuildingBlocks.Domain/OrderSphere.BuildingBlocks.csproj b/src/BuildingBlocks/OrderSphere.BuildingBlocks.Domain/OrderSphere.BuildingBlocks.csproj index eb24018d..fb9344b7 100644 --- a/src/BuildingBlocks/OrderSphere.BuildingBlocks.Domain/OrderSphere.BuildingBlocks.csproj +++ b/src/BuildingBlocks/OrderSphere.BuildingBlocks.Domain/OrderSphere.BuildingBlocks.csproj @@ -11,6 +11,11 @@ + + + diff --git a/src/BuildingBlocks/OrderSphere.BuildingBlocks.Domain/Security/AmbientCorrelationContext.cs b/src/BuildingBlocks/OrderSphere.BuildingBlocks.Domain/Security/AmbientCorrelationContext.cs new file mode 100644 index 00000000..f5f2100b --- /dev/null +++ b/src/BuildingBlocks/OrderSphere.BuildingBlocks.Domain/Security/AmbientCorrelationContext.cs @@ -0,0 +1,36 @@ +namespace OrderSphere.BuildingBlocks.Security; + +/// +/// Ambient correlation slot, following the same pattern as . +/// Holds the id that ties together every log record belonging to one logical operation, across +/// HTTP hops and Service Bus message boundaries alike. +/// +/// API requests get it from the X-Request-Id header (generated by the API Gateway when +/// absent); worker message loops get it from the integration event being processed. Because the +/// slot is , the log enricher in ServiceDefaults reads it without +/// needing an HttpContext — the same enricher therefore works in APIs and workers. +/// +/// +/// This is log correlation, distinct from IntegrationEvent.CorrelationId, which is a +/// business identifier used for idempotency. +/// +/// +public static class AmbientCorrelationContext +{ + private static readonly AsyncLocal Current = new(); + + /// The correlation id set by the innermost open , if any. + public static string? Ambient => Current.Value; + + public static IDisposable BeginScope(string correlationId) + { + var previous = Current.Value; + Current.Value = correlationId; + return new Scope(previous); + } + + private sealed class Scope(string? previous) : IDisposable + { + public void Dispose() => Current.Value = previous; + } +} diff --git a/src/BuildingBlocks/OrderSphere.BuildingBlocks.EventBus.AzureServiceBus/EventBusDiagnostics.cs b/src/BuildingBlocks/OrderSphere.BuildingBlocks.EventBus.AzureServiceBus/EventBusDiagnostics.cs index c392d3f0..f560a802 100644 --- a/src/BuildingBlocks/OrderSphere.BuildingBlocks.EventBus.AzureServiceBus/EventBusDiagnostics.cs +++ b/src/BuildingBlocks/OrderSphere.BuildingBlocks.EventBus.AzureServiceBus/EventBusDiagnostics.cs @@ -1,5 +1,6 @@ using System.Diagnostics; using Azure.Messaging.ServiceBus; +using OrderSphere.BuildingBlocks.Security; namespace OrderSphere.BuildingBlocks.EventBus.AzureServiceBus; @@ -22,6 +23,13 @@ public static class EventBusDiagnostics /// W3C trace-context header carried as a Service Bus application property. private const string TraceParentProperty = "traceparent"; + /// + /// Log-correlation id carried alongside the trace context. Distinct from the trace id: it + /// survives sampling and is the value operators quote from a client-facing error. Distinct + /// also from IntegrationEvent.CorrelationId, which is a business idempotency key. + /// + private const string CorrelationIdProperty = "x-request-id"; + // The OutboxDispatcher publishes on a timer, long after the originating request/consume // completed, so the original context is no longer on Activity.Current. It is persisted on the // outbox row and restored here as an ambient parent for the publish span. @@ -34,9 +42,17 @@ public static class EventBusDiagnostics public static IDisposable RestorePublishParent(string? traceParent) { var previous = AmbientPublishParent.Value; - AmbientPublishParent.Value = - ActivityContext.TryParse(traceParent, null, isRemote: true, out var ctx) ? ctx : null; - return new ParentScope(previous); + var parsed = ActivityContext.TryParse(traceParent, null, isRemote: true, out var ctx); + AmbientPublishParent.Value = parsed ? ctx : null; + + // The log-correlation id equals the trace id of the originating operation (the API + // Gateway seeds X-Request-Id from it), so restoring the trace context also restores + // correlation across the outbox boundary — no extra outbox column needed. + var correlationScope = parsed + ? AmbientCorrelationContext.BeginScope(ctx.TraceId.ToString()) + : null; + + return new ParentScope(previous, correlationScope); } /// Starts a producer span for a publish to . @@ -58,6 +74,32 @@ public static void Inject(ServiceBusMessage message) var traceParent = Activity.Current?.Id ?? FormatTraceParent(AmbientPublishParent.Value); if (traceParent is not null) message.ApplicationProperties[TraceParentProperty] = traceParent; + + if (AmbientCorrelationContext.Ambient is { Length: > 0 } correlationId) + message.ApplicationProperties[CorrelationIdProperty] = correlationId; + } + + /// + /// Reads the log-correlation id carried on an inbound message, falling back to the message's + /// trace id so that a message published before this property existed still correlates. + /// + public static string ReadCorrelationId(ServiceBusReceivedMessage message) + { + if (message.ApplicationProperties.TryGetValue(CorrelationIdProperty, out var raw) + && raw is string correlationId + && correlationId.Length > 0) + { + return correlationId; + } + + if (message.ApplicationProperties.TryGetValue(TraceParentProperty, out var rawTrace) + && rawTrace is string traceParent + && ActivityContext.TryParse(traceParent, null, isRemote: true, out var ctx)) + { + return ctx.TraceId.ToString(); + } + + return message.MessageId; } /// Starts a consumer span linked to the producer context carried by the message. @@ -95,8 +137,12 @@ private static void SetMessagingTags(Activity? activity, string destination, str return $"00-{ctx.TraceId}-{ctx.SpanId}-{sampled}"; } - private sealed class ParentScope(ActivityContext? previous) : IDisposable + private sealed class ParentScope(ActivityContext? previous, IDisposable? correlationScope) : IDisposable { - public void Dispose() => AmbientPublishParent.Value = previous; + public void Dispose() + { + correlationScope?.Dispose(); + AmbientPublishParent.Value = previous; + } } } diff --git a/src/BuildingBlocks/OrderSphere.BuildingBlocks.EventBus.AzureServiceBus/MessageProcessingScope.cs b/src/BuildingBlocks/OrderSphere.BuildingBlocks.EventBus.AzureServiceBus/MessageProcessingScope.cs new file mode 100644 index 00000000..d5a52491 --- /dev/null +++ b/src/BuildingBlocks/OrderSphere.BuildingBlocks.EventBus.AzureServiceBus/MessageProcessingScope.cs @@ -0,0 +1,98 @@ +using System.Diagnostics; +using Azure.Messaging.ServiceBus; +using Microsoft.Extensions.Logging; +using OrderSphere.BuildingBlocks.Security; + +namespace OrderSphere.BuildingBlocks.EventBus.AzureServiceBus; + +/// +/// Opens the full ambient context for one inbound Service Bus message: the consumer span, the +/// tenant slot, the log-correlation slot and a log scope carrying the message identifiers. +/// +/// This is the worker-side counterpart to the request middleware in ServiceDefaults. Because it +/// sets the same and +/// slots, the shared log enricher produces the same field set on worker records as on API +/// records, without any worker knowing about logging infrastructure. +/// +/// +/// Tenant is not known until the body is deserialized, so the scope is opened in two steps: +/// construct it when the message arrives, then call once the event is +/// deserialized. Disposal restores every slot in reverse order. +/// +/// +public sealed class MessageProcessingScope : IDisposable +{ + private readonly Activity? _activity; + private readonly IDisposable _correlationScope; + private readonly IDisposable? _logScope; + private IDisposable? _tenantScope; + private bool _disposed; + + private MessageProcessingScope( + Activity? activity, + IDisposable correlationScope, + IDisposable? logScope, + string messageId, + string eventType) + { + _activity = activity; + _correlationScope = correlationScope; + _logScope = logScope; + MessageId = messageId; + EventType = eventType; + } + + public string MessageId { get; } + + public string EventType { get; } + + /// + /// Begins processing scope for received from . + /// + public static MessageProcessingScope Begin( + ILogger logger, + ServiceBusReceivedMessage message, + string queueName) + { + var activity = EventBusDiagnostics.StartProcess(message, queueName); + var correlationScope = AmbientCorrelationContext.BeginScope( + EventBusDiagnostics.ReadCorrelationId(message)); + + var eventType = message.Subject + ?? (message.ApplicationProperties.TryGetValue("EventType", out var raw) ? raw as string : null) + ?? "unknown"; + + var logScope = logger.BeginScope(new Dictionary + { + ["message_id"] = message.MessageId, + ["event_type"] = eventType, + ["queue"] = queueName, + }); + + return new MessageProcessingScope(activity, correlationScope, logScope, message.MessageId, eventType); + } + + /// + /// Opens the ambient tenant slot once the event body has been deserialized. Persistence code + /// reached from here inherits the tenant through the global query filters (ADR 0012). + /// + public void SetTenant(Guid tenantId) + { + _tenantScope?.Dispose(); + _tenantScope = AmbientTenantContext.BeginScope(tenantId); + } + + public void Dispose() + { + if (_disposed) + { + return; + } + + _disposed = true; + _tenantScope?.Dispose(); + _logScope?.Dispose(); + _correlationScope.Dispose(); + _activity?.Dispose(); + } +} diff --git a/src/BuildingBlocks/OrderSphere.BuildingBlocks.EventBus.AzureServiceBus/ProcessorLog.cs b/src/BuildingBlocks/OrderSphere.BuildingBlocks.EventBus.AzureServiceBus/ProcessorLog.cs new file mode 100644 index 00000000..927ab991 --- /dev/null +++ b/src/BuildingBlocks/OrderSphere.BuildingBlocks.EventBus.AzureServiceBus/ProcessorLog.cs @@ -0,0 +1,70 @@ +using Microsoft.Extensions.Logging; + +namespace OrderSphere.BuildingBlocks.EventBus.AzureServiceBus; + +/// +/// Log methods shared by every Service Bus processor. These templates were previously duplicated +/// verbatim across 15 processors (the start/stop lines) and 10 processors (the dead-letter line), +/// which meant a wording or level change had to be made in every one of them. +/// +/// The message id, event type and queue are not repeated as template arguments: they are already +/// on the record as structured fields, put there by . +/// +/// EventId range 1100-1199 (see docs/logging.md). +/// +public static partial class ProcessorLog +{ + [LoggerMessage( + EventId = 1101, + Level = LogLevel.Information, + Message = "{processor} started, listening on queue '{queue}'.")] + public static partial void ProcessorStarted(this ILogger logger, string processor, string queue); + + [LoggerMessage( + EventId = 1102, + Level = LogLevel.Information, + Message = "{processor} stopped.")] + public static partial void ProcessorStopped(this ILogger logger, string processor); + + /// + /// A body that will not deserialize is a producer-side contract break. It stays at Error + /// rather than Warning: the message is parked in the dead-letter queue and needs a human to + /// decide whether it can be replayed or must be discarded. + /// + [LoggerMessage( + EventId = 1103, + Level = LogLevel.Error, + Message = "Message could not be deserialized. Dead-lettering.")] + public static partial void MessageUndeserializable(this ILogger logger); + + [LoggerMessage( + EventId = 1104, + Level = LogLevel.Debug, + Message = "Message received.")] + public static partial void MessageReceived(this ILogger logger); + + [LoggerMessage( + EventId = 1105, + Level = LogLevel.Information, + Message = "Message processed.")] + public static partial void MessageProcessed(this ILogger logger); + + [LoggerMessage( + EventId = 1106, + Level = LogLevel.Information, + Message = "Duplicate message ignored; already processed.")] + public static partial void DuplicateMessageIgnored(this ILogger logger); + + [LoggerMessage( + EventId = 1107, + Level = LogLevel.Error, + Message = "Unhandled exception while processing message. Abandoning.")] + public static partial void MessageProcessingFailed(this ILogger logger, Exception exception); + + [LoggerMessage( + EventId = 1108, + Level = LogLevel.Error, + Message = "Service Bus processor error on entity {entityPath}, source {errorSource}.")] + public static partial void ProcessorError( + this ILogger logger, Exception exception, string entityPath, string errorSource); +} diff --git a/src/Frontend/OrderSphere.Web/Components/AdvisorDrawer.razor b/src/Frontend/OrderSphere.Web/Components/AdvisorDrawer.razor index 1174eece..acbcf32b 100644 --- a/src/Frontend/OrderSphere.Web/Components/AdvisorDrawer.razor +++ b/src/Frontend/OrderSphere.Web/Components/AdvisorDrawer.razor @@ -253,7 +253,7 @@ } catch (Exception ex) { - Logger.LogCritical(ex.Message); + Logger.LogWarning(ex, "Advisor chat request failed; showing the fallback reply."); reply.Text = "Entschuldigung, der Berater ist gerade nicht erreichbar."; } finally diff --git a/src/Frontend/OrderSphere.Web/Services/LoggingHandler.cs b/src/Frontend/OrderSphere.Web/Services/LoggingHandler.cs index 73d3bca4..9a8e9e04 100644 --- a/src/Frontend/OrderSphere.Web/Services/LoggingHandler.cs +++ b/src/Frontend/OrderSphere.Web/Services/LoggingHandler.cs @@ -15,7 +15,9 @@ protected override async Task SendAsync( HttpRequestMessage request, CancellationToken cancellationToken) { - var path = request.RequestUri?.PathAndQuery; + // AbsolutePath, not PathAndQuery: the query string can carry identifiers or filter + // values that are personal data, and it adds nothing to a client-side diagnosis. + var path = request.RequestUri?.AbsolutePath; var sw = Stopwatch.StartNew(); try { @@ -24,13 +26,19 @@ protected override async Task SendAsync( if (!response.IsSuccessStatusCode) { - logger.LogWarning("HTTP {Method} {Path} -> {Status} in {Elapsed}ms", - request.Method, path, (int)response.StatusCode, sw.ElapsedMilliseconds); + // The gateway echoes X-Request-Id, which is the trace id and the correlation_id + // on every server-side record for this call. Surfacing it here is what lets a + // user-reported browser error be traced to the server logs, given that WASM logs + // themselves never leave the browser (see docs/logging.md). + logger.LogWarning("HTTP {Method} {Path} -> {Status} in {Elapsed}ms [correlation {CorrelationId}]", + request.Method, path, (int)response.StatusCode, sw.ElapsedMilliseconds, + ReadCorrelationId(response)); } else if (sw.ElapsedMilliseconds > SlowThresholdMs) { - logger.LogInformation("Slow HTTP {Method} {Path} -> {Status} in {Elapsed}ms", - request.Method, path, (int)response.StatusCode, sw.ElapsedMilliseconds); + logger.LogInformation("Slow HTTP {Method} {Path} -> {Status} in {Elapsed}ms [correlation {CorrelationId}]", + request.Method, path, (int)response.StatusCode, sw.ElapsedMilliseconds, + ReadCorrelationId(response)); } return response; @@ -48,4 +56,9 @@ protected override async Task SendAsync( throw; } } + + private static string ReadCorrelationId(HttpResponseMessage response) => + response.Headers.TryGetValues("X-Request-Id", out var values) + ? values.FirstOrDefault() ?? "-" + : "-"; } diff --git a/src/Gateways/OrderSphere.ApiGateway/Program.cs b/src/Gateways/OrderSphere.ApiGateway/Program.cs index 558f5d37..19d472ca 100644 --- a/src/Gateways/OrderSphere.ApiGateway/Program.cs +++ b/src/Gateways/OrderSphere.ApiGateway/Program.cs @@ -1,3 +1,4 @@ +using System.Diagnostics; using System.Threading.RateLimiting; using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.AspNetCore.RateLimiting; @@ -106,7 +107,11 @@ { if (!context.Request.Headers.ContainsKey("X-Request-Id")) { - context.Request.Headers["X-Request-Id"] = Guid.NewGuid().ToString("N"); + // Seeded from the trace id so the client-visible id, the log correlation_id and the + // trace are one and the same value — including across the outbox, where only the trace + // context is persisted. Same 32-char lowercase hex shape as the previous Guid("N"). + context.Request.Headers["X-Request-Id"] = + Activity.Current?.TraceId.ToString() ?? Guid.NewGuid().ToString("N"); } context.Response.Headers["X-Request-Id"] = context.Request.Headers["X-Request-Id"].ToString(); await next(); diff --git a/src/Gateways/OrderSphere.ApiGateway/appsettings.Development.json b/src/Gateways/OrderSphere.ApiGateway/appsettings.Development.json index aca60d0b..f6f6b37f 100644 --- a/src/Gateways/OrderSphere.ApiGateway/appsettings.Development.json +++ b/src/Gateways/OrderSphere.ApiGateway/appsettings.Development.json @@ -3,6 +3,8 @@ "LogLevel": { "Default": "Information", "Microsoft.AspNetCore": "Information", + "Microsoft.Hosting.Lifetime": "Information", + "OrderSphere": "Debug", "Yarp": "Debug" } } diff --git a/src/Gateways/OrderSphere.ApiGateway/appsettings.json b/src/Gateways/OrderSphere.ApiGateway/appsettings.json index 0e069f4f..7cc77195 100644 --- a/src/Gateways/OrderSphere.ApiGateway/appsettings.json +++ b/src/Gateways/OrderSphere.ApiGateway/appsettings.json @@ -3,6 +3,8 @@ "LogLevel": { "Default": "Information", "Microsoft.AspNetCore": "Warning", + "Microsoft.Hosting.Lifetime": "Information", + "OrderSphere": "Information", "Yarp": "Information" } }, @@ -253,5 +255,8 @@ } } } + }, + "OpenTelemetry": { + "TracesSampleRatio": 1.0 } } diff --git a/src/Gateways/OrderSphere.Bff/Auth/RefreshTokenHandler.cs b/src/Gateways/OrderSphere.Bff/Auth/RefreshTokenHandler.cs index 87e02215..2161f9ea 100644 --- a/src/Gateways/OrderSphere.Bff/Auth/RefreshTokenHandler.cs +++ b/src/Gateways/OrderSphere.Bff/Auth/RefreshTokenHandler.cs @@ -130,7 +130,7 @@ public override async Task ValidatePrincipal(CookieValidatePrincipalContext cont var response = await client.PostAsync(tokenEndpoint, body); if (!response.IsSuccessStatusCode) { - var error = await response.Content.ReadAsStringAsync(); + var error = await OAuthErrorReader.ReadAsync(response.Content); _logger.LogWarning("Token endpoint returned {StatusCode}: {Error}", response.StatusCode, error); return null; } diff --git a/src/Gateways/OrderSphere.Bff/Workers/RealtimeNotificationProcessor.cs b/src/Gateways/OrderSphere.Bff/Workers/RealtimeNotificationProcessor.cs index 290b4997..c10dd575 100644 --- a/src/Gateways/OrderSphere.Bff/Workers/RealtimeNotificationProcessor.cs +++ b/src/Gateways/OrderSphere.Bff/Workers/RealtimeNotificationProcessor.cs @@ -26,7 +26,7 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) _processor.ProcessErrorAsync += OnError; await _processor.StartProcessingAsync(stoppingToken); - logger.LogInformation("RealtimeNotificationProcessor started, listening on queue '{Queue}'.", QueueName); + logger.ProcessorStarted(nameof(RealtimeNotificationProcessor), QueueName); try { @@ -36,13 +36,13 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) finally { await _processor.StopProcessingAsync(CancellationToken.None); - logger.LogInformation("RealtimeNotificationProcessor stopped."); + logger.ProcessorStopped(nameof(RealtimeNotificationProcessor)); } } private async Task OnMessageReceived(ProcessMessageEventArgs args) { - using var activity = EventBusDiagnostics.StartProcess(args.Message, QueueName); + using var messageScope = MessageProcessingScope.Begin(logger, args.Message, QueueName); var messageId = args.Message.MessageId; try @@ -50,7 +50,7 @@ private async Task OnMessageReceived(ProcessMessageEventArgs args) var evt = args.Message.Body.ToObjectFromJson(); if (evt is null) { - logger.LogWarning("Message {MessageId} could not be deserialized. Dead-lettering.", messageId); + logger.MessageUndeserializable(); await args.DeadLetterMessageAsync(args.Message, deadLetterReason: "DeserializationFailed", deadLetterErrorDescription: "Body was not a valid RealtimeNotificationEvent."); @@ -70,14 +70,14 @@ await hubContext.Clients.Group(evt.UserId).SendAsync( args.CancellationToken); logger.LogInformation( - "Pushed {Type} notification to user {UserId}. MessageId: {MessageId}", + "Pushed {Type} notification to user {UserId}.", evt.Type, evt.UserId, messageId); await args.CompleteMessageAsync(args.Message); } catch (Exception ex) { - logger.LogError(ex, "Error processing realtime notification {MessageId}. Abandoning.", messageId); + logger.MessageProcessingFailed(ex); await args.AbandonMessageAsync(args.Message); } } diff --git a/src/Gateways/OrderSphere.Bff/appsettings.Development.json b/src/Gateways/OrderSphere.Bff/appsettings.Development.json index b3a7d9dc..8778ab4a 100644 --- a/src/Gateways/OrderSphere.Bff/appsettings.Development.json +++ b/src/Gateways/OrderSphere.Bff/appsettings.Development.json @@ -2,6 +2,9 @@ "Logging": { "LogLevel": { "Default": "Information", + "Microsoft.AspNetCore": "Warning", + "Microsoft.Hosting.Lifetime": "Information", + "OrderSphere": "Debug", "Microsoft.AspNetCore.Authentication": "Debug", "Yarp": "Debug" } diff --git a/src/Gateways/OrderSphere.Bff/appsettings.Testing.json b/src/Gateways/OrderSphere.Bff/appsettings.Testing.json index 1ebb179c..3514d095 100644 --- a/src/Gateways/OrderSphere.Bff/appsettings.Testing.json +++ b/src/Gateways/OrderSphere.Bff/appsettings.Testing.json @@ -7,5 +7,16 @@ "ConnectionStrings": { "redis": "localhost:6379,abortConnect=false,connectTimeout=100", "azure-service-bus": "Endpoint=sb://fake.servicebus.windows.net/;SharedAccessKeyName=test;SharedAccessKey=dGVzdA==" + }, + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning", + "Microsoft.Hosting.Lifetime": "Information", + "OrderSphere": "Information" + } + }, + "OpenTelemetry": { + "TracesSampleRatio": 1.0 } } diff --git a/src/Gateways/OrderSphere.Bff/appsettings.json b/src/Gateways/OrderSphere.Bff/appsettings.json index d5b5efd9..f0d65198 100644 --- a/src/Gateways/OrderSphere.Bff/appsettings.json +++ b/src/Gateways/OrderSphere.Bff/appsettings.json @@ -3,6 +3,8 @@ "LogLevel": { "Default": "Information", "Microsoft.AspNetCore": "Warning", + "Microsoft.Hosting.Lifetime": "Information", + "OrderSphere": "Information", "Yarp": "Information" } }, @@ -39,5 +41,8 @@ } } } + }, + "OpenTelemetry": { + "TracesSampleRatio": 1.0 } } diff --git a/src/Hosting/OrderSphere.AppHost/appsettings.Development.json b/src/Hosting/OrderSphere.AppHost/appsettings.Development.json index f2c83338..db150c61 100644 --- a/src/Hosting/OrderSphere.AppHost/appsettings.Development.json +++ b/src/Hosting/OrderSphere.AppHost/appsettings.Development.json @@ -2,7 +2,8 @@ "Logging": { "LogLevel": { "Default": "Information", - "Microsoft.AspNetCore": "Warning" + "Microsoft.AspNetCore": "Warning", + "Microsoft.Hosting.Lifetime": "Information" } }, "Parameters": { diff --git a/src/Hosting/OrderSphere.AppHost/appsettings.json b/src/Hosting/OrderSphere.AppHost/appsettings.json index 31c092aa..13796678 100644 --- a/src/Hosting/OrderSphere.AppHost/appsettings.json +++ b/src/Hosting/OrderSphere.AppHost/appsettings.json @@ -3,6 +3,7 @@ "LogLevel": { "Default": "Information", "Microsoft.AspNetCore": "Warning", + "Microsoft.Hosting.Lifetime": "Information", "Aspire.Hosting.Dcp": "Warning" } } diff --git a/src/Hosting/OrderSphere.ServiceDefaults/Authentication/ClientCredentialsTokenHandler.cs b/src/Hosting/OrderSphere.ServiceDefaults/Authentication/ClientCredentialsTokenHandler.cs index bb2bd63f..89b56f64 100644 --- a/src/Hosting/OrderSphere.ServiceDefaults/Authentication/ClientCredentialsTokenHandler.cs +++ b/src/Hosting/OrderSphere.ServiceDefaults/Authentication/ClientCredentialsTokenHandler.cs @@ -2,6 +2,7 @@ using System.Net.Http.Json; using System.Text.Json; using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; namespace Microsoft.Extensions.DependencyInjection; @@ -81,7 +82,7 @@ protected override async Task SendAsync( var response = await client.PostAsync(tokenEndpoint, body, ct); if (!response.IsSuccessStatusCode) { - var error = await response.Content.ReadAsStringAsync(ct); + var error = await OAuthErrorReader.ReadAsync(response.Content, ct); logger.LogWarning( "Client credentials token request failed for {ClientId} ({StatusCode}): {Error}", clientId, (int)response.StatusCode, error); diff --git a/src/Hosting/OrderSphere.ServiceDefaults/Authentication/OAuthErrorReader.cs b/src/Hosting/OrderSphere.ServiceDefaults/Authentication/OAuthErrorReader.cs new file mode 100644 index 00000000..87deb6c7 --- /dev/null +++ b/src/Hosting/OrderSphere.ServiceDefaults/Authentication/OAuthErrorReader.cs @@ -0,0 +1,58 @@ +using System.Text.Json; + +namespace Microsoft.Extensions.Hosting; + +/// +/// Extracts the diagnosable part of a failed OAuth token response. +/// +/// A token endpoint returns RFC 6749 error / error_description fields, but the +/// response on the wire may be anything a proxy, WAF or load balancer put there — an HTML error +/// page, or a body echoing back parts of the request. Logging that verbatim puts unbounded and +/// potentially credential-bearing content into the log stream, so only the two known fields are +/// taken, with a short truncated fallback when the body is not the expected shape. +/// +/// +public static class OAuthErrorReader +{ + private const int FallbackMaxLength = 200; + + public static async Task ReadAsync(HttpContent content, CancellationToken ct = default) + { + string body; + try + { + body = await content.ReadAsStringAsync(ct); + } + catch (Exception) + { + return "(unreadable response body)"; + } + + try + { + using var document = JsonDocument.Parse(body); + if (document.RootElement.ValueKind is JsonValueKind.Object) + { + var error = document.RootElement.TryGetProperty("error", out var e) + ? e.GetString() + : null; + var description = document.RootElement.TryGetProperty("error_description", out var d) + ? d.GetString() + : null; + + if (error is not null || description is not null) + { + return description is null ? error! : $"{error}: {description}"; + } + } + } + catch (JsonException) + { + // Not an OAuth error document — fall through to the truncated fallback. + } + + return body.Length <= FallbackMaxLength + ? body + : string.Concat(body.AsSpan(0, FallbackMaxLength), "... (truncated)"); + } +} diff --git a/src/Hosting/OrderSphere.ServiceDefaults/Extensions.cs b/src/Hosting/OrderSphere.ServiceDefaults/Extensions.cs index c5841fa2..c1b1eda6 100644 --- a/src/Hosting/OrderSphere.ServiceDefaults/Extensions.cs +++ b/src/Hosting/OrderSphere.ServiceDefaults/Extensions.cs @@ -1,4 +1,7 @@ +using System.Globalization; using System.Reflection; +using System.Security.Cryptography; +using OrderSphere.BuildingBlocks.Compliance; using System.Text.Json.Serialization; using Azure.Monitor.OpenTelemetry.AspNetCore; using Microsoft.AspNetCore.Builder; @@ -6,6 +9,7 @@ using Microsoft.AspNetCore.Http; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Diagnostics.HealthChecks; +using Microsoft.Extensions.Compliance.Redaction; using Microsoft.Extensions.Logging; using OpenTelemetry; using OpenTelemetry.Metrics; @@ -48,8 +52,13 @@ public static TBuilder AddServiceDefaults(this TBuilder builder) where // Turn on service discovery by default http.AddServiceDiscovery(); + + // Carry the log correlation id (X-Request-Id) across service hops. + http.AddHttpMessageHandler(); }); + builder.Services.AddTransient(); + // Uncomment the following to restrict the allowed schemes for service discovery. // builder.Services.Configure(options => // { @@ -74,6 +83,9 @@ public static TBuilder ConfigureOpenTelemetry(this TBuilder builder) w logging.IncludeScopes = true; }); + builder.ConfigureLogEnrichment(); + builder.ConfigureLogRedaction(); + // EF Core logs every SQL command at Information by default. Default it to Warning so the // database story comes from traces (DB spans), not log spam — raise the // "Microsoft.EntityFrameworkCore.Database.Command" category where raw SQL is needed. @@ -104,8 +116,17 @@ public static TBuilder ConfigureOpenTelemetry(this TBuilder builder) w // Parent-based ratio sampler; ratio from "OpenTelemetry:TracesSampleRatio" // (default 1.0 = sample everything). Lower it in production to control cost. // Azure Monitor applies its own sampler via APPLICATIONINSIGHTS_SAMPLING_PERCENTAGE. + // + // InvariantCulture is required: configuration values are culture-invariant, but a + // plain double.TryParse uses the host's current culture, where "0.1" on a de-DE + // machine parses as 1 and "1.0" as 10 - both outside the sampler's [0,1] range. + // The value is clamped as well so a typo degrades sampling instead of crashing + // the host at startup. var sampleRatio = double.TryParse( - builder.Configuration["OpenTelemetry:TracesSampleRatio"], out var ratio) ? ratio : 1.0; + builder.Configuration["OpenTelemetry:TracesSampleRatio"], + NumberStyles.Float, + CultureInfo.InvariantCulture, + out var ratio) ? Math.Clamp(ratio, 0d, 1d) : 1.0; tracing.SetSampler(new ParentBasedSampler(new TraceIdRatioBasedSampler(sampleRatio))) .AddSource(builder.Environment.ApplicationName) @@ -131,6 +152,76 @@ public static TBuilder ConfigureOpenTelemetry(this TBuilder builder) w return builder; } + /// + /// Replaces the default ILoggerFactory with ExtendedLoggerFactory and registers the + /// OrderSphere enrichers. Enrichment tags are written into the log-record state, which the + /// OpenTelemetry logger provider reads as attributes — so tenant_id / correlation_id / + /// user_id reach the Aspire dashboard and Application Insights without further wiring. + /// + private static TBuilder ConfigureLogEnrichment(this TBuilder builder) where TBuilder : IHostApplicationBuilder + { + builder.Logging.EnableEnrichment(); + + // OrderSphereLogEnricher is a singleton and reads the user from the accessor; workers + // simply never have an HttpContext and fall through to the ambient slots. + builder.Services.AddHttpContextAccessor(); + builder.Services.AddLogEnricher(); + builder.Services.AddStaticLogEnricher(); + + return builder; + } + + /// + /// Activates redaction for classified [LoggerMessage] parameters. The classifications and + /// their mapping to the sensitivity tiers in docs/data-classification.md live in + /// . + /// + /// Redaction applies only to source-generated log methods whose parameters carry a + /// classification attribute — never to plain logger.LogX(...) calls. + /// + /// + private static TBuilder ConfigureLogRedaction(this TBuilder builder) where TBuilder : IHostApplicationBuilder + { + builder.Logging.EnableRedaction(); + + // Key material is per-deployment: two environments produce unrelated hashes, so a value + // cannot be correlated across them. Absent configuration (local dev, tests) a process- + // lifetime random key is used — redaction still holds, correlation just ends at restart. + var hmacKey = builder.Configuration["Logging:Redaction:HmacKey"] + ?? Convert.ToBase64String(RandomNumberGenerator.GetBytes(32)); + + builder.Services.AddRedaction(redaction => + { + // Direct PII (T1) and pseudonymous identifiers (T2) are hashed rather than erased so + // that "all records for the same customer" stays answerable without storing the value. + // Distinct key ids keep the two tiers from being cross-correlated. + redaction.SetHmacRedactor( + options => + { + options.KeyId = 1; + options.Key = hmacKey; + }, + OrderSphereDataClassifications.DirectPii); + + redaction.SetHmacRedactor( + options => + { + options.KeyId = 2; + options.Key = hmacKey; + }, + OrderSphereDataClassifications.PseudonymousId); + + // Free text (T4) carries the highest re-identification risk per byte and has no + // operational value in a log record, so it is dropped outright. + redaction.SetRedactor(OrderSphereDataClassifications.FreeText); + + // Anything classified but unmapped is erased rather than leaked. + redaction.SetFallbackRedactor(); + }); + + return builder; + } + private static TBuilder AddOpenTelemetryExporters(this TBuilder builder) where TBuilder : IHostApplicationBuilder { var useOtlpExporter = !string.IsNullOrWhiteSpace(builder.Configuration["OTEL_EXPORTER_OTLP_ENDPOINT"]); diff --git a/src/Hosting/OrderSphere.ServiceDefaults/Logging/CorrelationPropagationHandler.cs b/src/Hosting/OrderSphere.ServiceDefaults/Logging/CorrelationPropagationHandler.cs new file mode 100644 index 00000000..0082804a --- /dev/null +++ b/src/Hosting/OrderSphere.ServiceDefaults/Logging/CorrelationPropagationHandler.cs @@ -0,0 +1,31 @@ +using OrderSphere.BuildingBlocks.Security; + +namespace Microsoft.Extensions.Hosting; + +/// +/// Carries the ambient log-correlation id onto every outgoing HTTP call, so that a request +/// entering at the gateway keeps one id across service hops (Ordering to Catalog, Basket to +/// Catalog, and so on). +/// +/// Registered on ConfigureHttpClientDefaults in AddServiceDefaults, which means it +/// applies to every typed client without per-client wiring. W3C trace context is propagated +/// separately by the OpenTelemetry HTTP instrumentation; this header is the human-quotable id +/// that also survives sampling. +/// +/// +internal sealed class CorrelationPropagationHandler : DelegatingHandler +{ + protected override Task SendAsync( + HttpRequestMessage request, + CancellationToken cancellationToken) + { + if (AmbientCorrelationContext.Ambient is { Length: > 0 } correlationId + && !request.Headers.Contains(RequestContextEnrichmentMiddleware.CorrelationHeader)) + { + request.Headers.TryAddWithoutValidation( + RequestContextEnrichmentMiddleware.CorrelationHeader, correlationId); + } + + return base.SendAsync(request, cancellationToken); + } +} diff --git a/src/Hosting/OrderSphere.ServiceDefaults/Logging/OrderSphereLogEnricher.cs b/src/Hosting/OrderSphere.ServiceDefaults/Logging/OrderSphereLogEnricher.cs new file mode 100644 index 00000000..8b774431 --- /dev/null +++ b/src/Hosting/OrderSphere.ServiceDefaults/Logging/OrderSphereLogEnricher.cs @@ -0,0 +1,52 @@ +using System.Security.Claims; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Diagnostics.Enrichment; +using OrderSphere.BuildingBlocks.Security; + +namespace Microsoft.Extensions.Hosting; + +/// +/// Attaches request-scoped identifiers to every log record produced by the process. +/// +/// Tenant and correlation are read from slots rather than from +/// HttpContext, so the same enricher covers API requests and worker message loops. +/// That is the reason worker logs carry the same field set as API logs — the message loop +/// opens the ambient scopes (see MessageProcessingScope) and this enricher picks them up. +/// +/// +/// Registered as a singleton and invoked once per log record, so it must not depend on scoped +/// services such as ICurrentUser. +/// +/// +/// trace_id and span_id are emitted by the OpenTelemetry logger provider itself +/// and are deliberately not duplicated here. +/// +/// +internal sealed class OrderSphereLogEnricher(IHttpContextAccessor httpContextAccessor) : ILogEnricher +{ + public void Enrich(IEnrichmentTagCollector collector) + { + if (AmbientTenantContext.Ambient is { } tenantId) + { + collector.Add("tenant_id", tenantId); + } + + if (AmbientCorrelationContext.Ambient is { Length: > 0 } correlationId) + { + collector.Add("correlation_id", correlationId); + } + + // Auth0 "sub" — an opaque pseudonymous identifier. Logged verbatim by design so that + // operators can trace a single user's requests (documented in docs/operations.md); + // the directly identifying attributes behind it live in UserProfile, not in logs. + var user = httpContextAccessor.HttpContext?.User; + if (user is not null) + { + var userId = user.FindFirstValue("sub") ?? user.FindFirstValue(ClaimTypes.NameIdentifier); + if (!string.IsNullOrEmpty(userId)) + { + collector.Add("user_id", userId); + } + } + } +} diff --git a/src/Hosting/OrderSphere.ServiceDefaults/Logging/OrderSphereStaticLogEnricher.cs b/src/Hosting/OrderSphere.ServiceDefaults/Logging/OrderSphereStaticLogEnricher.cs new file mode 100644 index 00000000..08a41a5a --- /dev/null +++ b/src/Hosting/OrderSphere.ServiceDefaults/Logging/OrderSphereStaticLogEnricher.cs @@ -0,0 +1,34 @@ +using System.Reflection; +using Microsoft.Extensions.Diagnostics.Enrichment; + +namespace Microsoft.Extensions.Hosting; + +/// +/// Attaches process-lifetime constants to every log record. Evaluated once at startup rather +/// than per record. +/// +/// These same values are already on the OpenTelemetry Resource (see +/// ConfigureOpenTelemetry), which covers OTLP and Azure Monitor. They are repeated on the +/// record itself so that a log line stays self-describing in sinks that do not carry resource +/// attributes — the console during local debugging, and captured log output in tests. +/// +/// +internal sealed class OrderSphereStaticLogEnricher(string serviceInstanceId, string buildVersion) + : IStaticLogEnricher +{ + public OrderSphereStaticLogEnricher() + : this( + Environment.MachineName, + Assembly.GetEntryAssembly()? + .GetCustomAttribute()? + .InformationalVersion + ?? "unknown") + { + } + + public void Enrich(IEnrichmentTagCollector collector) + { + collector.Add("service_instance_id", serviceInstanceId); + collector.Add("build_version", buildVersion); + } +} diff --git a/src/Hosting/OrderSphere.ServiceDefaults/Logging/RequestContextEnrichmentMiddleware.cs b/src/Hosting/OrderSphere.ServiceDefaults/Logging/RequestContextEnrichmentMiddleware.cs index 659d13d3..fcc778ea 100644 --- a/src/Hosting/OrderSphere.ServiceDefaults/Logging/RequestContextEnrichmentMiddleware.cs +++ b/src/Hosting/OrderSphere.ServiceDefaults/Logging/RequestContextEnrichmentMiddleware.cs @@ -1,38 +1,85 @@ -using System.Security.Claims; +using System.Diagnostics; +using System.Security.Cryptography; +using System.Text; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Logging; +using OrderSphere.BuildingBlocks.Security; namespace Microsoft.Extensions.Hosting; /// -/// Pushes request-scoped identifiers (user_id, client_ip) into the ILogger scope -/// so every log entry written during that request carries these fields as structured -/// properties — visible as Custom Dimensions in Application Insights. +/// Establishes the ambient correlation id for an HTTP request and pushes the request-local +/// fields that are not process-wide into the log scope. +/// +/// The correlation id is taken from the X-Request-Id header the API Gateway sets +/// (ApiGateway/Program.cs), so a value chosen at the edge survives every downstream hop. +/// Services reached directly fall back to the current trace id, and only mint a new id when +/// there is neither. +/// +/// +/// tenant_id, correlation_id and user_id reach the log record through +/// rather than this scope, so that worker logs carry the +/// same fields without an HttpContext. +/// /// -internal sealed class RequestContextEnrichmentMiddleware(RequestDelegate next, ILogger logger) +internal sealed class RequestContextEnrichmentMiddleware( + RequestDelegate next, + ILogger logger) { + internal const string CorrelationHeader = "X-Request-Id"; + public async Task InvokeAsync(HttpContext context) { - var userId = context.User.FindFirstValue("sub") - ?? context.User.FindFirstValue(ClaimTypes.NameIdentifier) - ?? "-"; + var correlationId = ResolveCorrelationId(context); + + // Echo it back so a client (or the browser dev tools) can quote the id in a bug report. + context.Response.Headers[CorrelationHeader] = correlationId; - var clientIp = context.Connection.RemoteIpAddress?.ToString() ?? "-"; + using var correlationScope = AmbientCorrelationContext.BeginScope(correlationId); + // The raw client IP is personal data under GDPR and would otherwise sit on every single + // record. A truncated keyed hash keeps per-client grouping (rate-limit abuse, error + // clustering) without storing the address itself. + using (logger.BeginScope(new Dictionary + { + ["client_ip_hash"] = HashClientIp(ResolveClientIp(context)), + })) + { + await next(context); + } + } + + private static string ResolveCorrelationId(HttpContext context) + { + if (context.Request.Headers.TryGetValue(CorrelationHeader, out var header) + && !string.IsNullOrWhiteSpace(header)) + { + return header.ToString(); + } + + return Activity.Current?.TraceId.ToString() ?? Guid.NewGuid().ToString("N"); + } + + private static string ResolveClientIp(HttpContext context) + { // X-Forwarded-For takes precedence when running behind a reverse proxy / gateway. if (context.Request.Headers.TryGetValue("X-Forwarded-For", out var forwarded) && !string.IsNullOrWhiteSpace(forwarded)) { - clientIp = forwarded.ToString().Split(',')[0].Trim(); + return forwarded.ToString().Split(',')[0].Trim(); } - using (logger.BeginScope(new Dictionary - { - ["user_id"] = userId, - ["client_ip"] = clientIp, - })) + return context.Connection.RemoteIpAddress?.ToString() ?? "-"; + } + + private static string HashClientIp(string clientIp) + { + if (clientIp == "-") { - await next(context); + return clientIp; } + + var hash = SHA256.HashData(Encoding.UTF8.GetBytes(clientIp)); + return Convert.ToHexStringLower(hash.AsSpan(0, 8)); } } diff --git a/src/Hosting/OrderSphere.ServiceDefaults/OrderSphere.ServiceDefaults.csproj b/src/Hosting/OrderSphere.ServiceDefaults/OrderSphere.ServiceDefaults.csproj index 59d6240c..cb67ad19 100644 --- a/src/Hosting/OrderSphere.ServiceDefaults/OrderSphere.ServiceDefaults.csproj +++ b/src/Hosting/OrderSphere.ServiceDefaults/OrderSphere.ServiceDefaults.csproj @@ -27,6 +27,11 @@ + + + diff --git a/src/Hosting/OrderSphere.ServiceDefaults/Security/SecurityAuditLogger.cs b/src/Hosting/OrderSphere.ServiceDefaults/Security/SecurityAuditLogger.cs index 111dec34..2e6978d1 100644 --- a/src/Hosting/OrderSphere.ServiceDefaults/Security/SecurityAuditLogger.cs +++ b/src/Hosting/OrderSphere.ServiceDefaults/Security/SecurityAuditLogger.cs @@ -1,14 +1,19 @@ using Microsoft.Extensions.Logging; +using OrderSphere.BuildingBlocks.Compliance; using OrderSphere.BuildingBlocks.Security; namespace Microsoft.Extensions.DependencyInjection; /// -/// ISecurityAuditLogger implementation that writes structured log entries via -/// the standard ILogger pipeline. OpenTelemetry (wired in ServiceDefaults) carries -/// these entries to the configured telemetry sink with the originating trace_id attached. +/// ISecurityAuditLogger implementation that writes structured log entries via the standard +/// ILogger pipeline. OpenTelemetry (wired in ServiceDefaults) carries these entries to the +/// configured telemetry sink with the originating trace_id attached. +/// +/// Each field is its own structured property, so an investigation can filter on +/// audit_event or audit_session_id directly instead of parsing a delimited string. +/// /// -internal sealed class SecurityAuditLogger(ILogger logger) +internal sealed partial class SecurityAuditLogger(ILogger logger) : ISecurityAuditLogger { public void Log(SecurityAuditEvent evt) @@ -23,9 +28,9 @@ public void Log(SecurityAuditEvent evt) _ => LogLevel.Information, }; - logger.Log( + SecurityAudit( + logger, level, - "SECURITY_AUDIT | {EventType} | user={UserId} | sid={SessionId} | ip={IpAddress} | {Details} | ts={OccurredAt:o}", evt.Type, evt.UserId ?? "-", evt.SessionId ?? "-", @@ -33,6 +38,33 @@ public void Log(SecurityAuditEvent evt) evt.Details ?? "-", evt.OccurredAt); } + + /// + /// EventId 1201 (ServiceDefaults range 1200-1299, see docs/logging.md). The level is a + /// parameter because it is derived from the event type. + /// + /// The session id and IP are hashed: they identify a person via a join and have no + /// operational value in plaintext, while the hash still groups repeated attempts from one + /// source. The user id stays readable so an investigation can pivot to that user's other + /// records, which carry the same value as the user_id enrichment tag. + /// + /// + /// details is written by OrderSphere code, never by request input; it must not be + /// used to carry user-supplied text. + /// + /// + [LoggerMessage( + EventId = 1201, + Message = "Security audit: {auditEvent}.")] + private static partial void SecurityAudit( + ILogger logger, + LogLevel level, + SecurityAuditEventType auditEvent, + string auditUserId, + [PseudonymousId] string auditSessionId, + [PseudonymousId] string auditIpAddress, + string auditDetails, + DateTimeOffset auditOccurredAt); } /// diff --git a/src/Services/Advisory/OrderSphere.Advisory.Api/Workers/CustomerErasureProcessor.cs b/src/Services/Advisory/OrderSphere.Advisory.Api/Workers/CustomerErasureProcessor.cs index b4bdbe6b..a9328cb4 100644 --- a/src/Services/Advisory/OrderSphere.Advisory.Api/Workers/CustomerErasureProcessor.cs +++ b/src/Services/Advisory/OrderSphere.Advisory.Api/Workers/CustomerErasureProcessor.cs @@ -4,7 +4,6 @@ using OrderSphere.BuildingBlocks.Contracts.Events; using OrderSphere.BuildingBlocks.EventBus.AzureServiceBus; using OrderSphere.BuildingBlocks.EventBus.Inbox; -using OrderSphere.BuildingBlocks.Security; namespace OrderSphere.Advisory.Api.Workers; @@ -34,7 +33,7 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) _processor.ProcessErrorAsync += OnError; await _processor.StartProcessingAsync(stoppingToken); - logger.LogInformation("CustomerErasureProcessor started, listening on queue '{Queue}'.", QueueName); + logger.ProcessorStarted(nameof(CustomerErasureProcessor), QueueName); try { @@ -44,15 +43,14 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) finally { await _processor.StopProcessingAsync(CancellationToken.None); - logger.LogInformation("CustomerErasureProcessor stopped."); + logger.ProcessorStopped(nameof(CustomerErasureProcessor)); } } private async Task OnMessageReceived(ProcessMessageEventArgs args) { - using var activity = EventBusDiagnostics.StartProcess(args.Message, QueueName); - var messageId = args.Message.MessageId; - logger.LogInformation("Received erasure-advisory message {MessageId}.", messageId); + using var messageScope = MessageProcessingScope.Begin(logger, args.Message, QueueName); + logger.MessageReceived(); try { @@ -65,7 +63,7 @@ await args.DeadLetterMessageAsync(args.Message, return; } - using var tenantScope = AmbientTenantContext.BeginScope(evt.TenantId); + messageScope.SetTenant(evt.TenantId); await using var scope = scopeFactory.CreateAsyncScope(); var context = scope.ServiceProvider.GetRequiredService(); @@ -93,7 +91,7 @@ await args.DeadLetterMessageAsync(args.Message, } catch (Exception ex) { - logger.LogError(ex, "Unhandled exception processing erasure-advisory message {MessageId}. Abandoning.", messageId); + logger.MessageProcessingFailed(ex); await args.AbandonMessageAsync(args.Message); } } diff --git a/src/Services/Advisory/OrderSphere.Advisory.Api/appsettings.Development.json b/src/Services/Advisory/OrderSphere.Advisory.Api/appsettings.Development.json index 0c208ae9..339d732b 100644 --- a/src/Services/Advisory/OrderSphere.Advisory.Api/appsettings.Development.json +++ b/src/Services/Advisory/OrderSphere.Advisory.Api/appsettings.Development.json @@ -2,7 +2,9 @@ "Logging": { "LogLevel": { "Default": "Information", - "Microsoft.AspNetCore": "Warning" + "Microsoft.AspNetCore": "Warning", + "Microsoft.Hosting.Lifetime": "Information", + "OrderSphere": "Debug" } } } diff --git a/src/Services/Advisory/OrderSphere.Advisory.Api/appsettings.json b/src/Services/Advisory/OrderSphere.Advisory.Api/appsettings.json index 10f68b8c..71df9b7a 100644 --- a/src/Services/Advisory/OrderSphere.Advisory.Api/appsettings.json +++ b/src/Services/Advisory/OrderSphere.Advisory.Api/appsettings.json @@ -2,8 +2,13 @@ "Logging": { "LogLevel": { "Default": "Information", - "Microsoft.AspNetCore": "Warning" + "Microsoft.AspNetCore": "Warning", + "Microsoft.Hosting.Lifetime": "Information", + "OrderSphere": "Information" } }, - "AllowedHosts": "*" + "AllowedHosts": "*", + "OpenTelemetry": { + "TracesSampleRatio": 1.0 + } } diff --git a/src/Services/Advisory/OrderSphere.Mcp.Server/Program.cs b/src/Services/Advisory/OrderSphere.Mcp.Server/Program.cs index 79465f10..ee2b412d 100644 --- a/src/Services/Advisory/OrderSphere.Mcp.Server/Program.cs +++ b/src/Services/Advisory/OrderSphere.Mcp.Server/Program.cs @@ -54,6 +54,10 @@ app.UseAuthorization(); } +// Outside the auth branch: the MCP surface is logged either way. When auth is on it runs after +// UseAuthentication, so the log scope carries the authenticated user. +app.UseOrderSphereRequestLogging(); + app.MapDefaultEndpoints(); app.MapMcp("/mcp").RequireRateLimiting(RateLimitingExtensions.McpPolicy); diff --git a/src/Services/Advisory/OrderSphere.Mcp.Server/appsettings.Development.json b/src/Services/Advisory/OrderSphere.Mcp.Server/appsettings.Development.json index 0c208ae9..339d732b 100644 --- a/src/Services/Advisory/OrderSphere.Mcp.Server/appsettings.Development.json +++ b/src/Services/Advisory/OrderSphere.Mcp.Server/appsettings.Development.json @@ -2,7 +2,9 @@ "Logging": { "LogLevel": { "Default": "Information", - "Microsoft.AspNetCore": "Warning" + "Microsoft.AspNetCore": "Warning", + "Microsoft.Hosting.Lifetime": "Information", + "OrderSphere": "Debug" } } } diff --git a/src/Services/Advisory/OrderSphere.Mcp.Server/appsettings.json b/src/Services/Advisory/OrderSphere.Mcp.Server/appsettings.json index 10f68b8c..71df9b7a 100644 --- a/src/Services/Advisory/OrderSphere.Mcp.Server/appsettings.json +++ b/src/Services/Advisory/OrderSphere.Mcp.Server/appsettings.json @@ -2,8 +2,13 @@ "Logging": { "LogLevel": { "Default": "Information", - "Microsoft.AspNetCore": "Warning" + "Microsoft.AspNetCore": "Warning", + "Microsoft.Hosting.Lifetime": "Information", + "OrderSphere": "Information" } }, - "AllowedHosts": "*" + "AllowedHosts": "*", + "OpenTelemetry": { + "TracesSampleRatio": 1.0 + } } diff --git a/src/Services/Basket/OrderSphere.Basket.Api/appsettings.json b/src/Services/Basket/OrderSphere.Basket.Api/appsettings.json new file mode 100644 index 00000000..71df9b7a --- /dev/null +++ b/src/Services/Basket/OrderSphere.Basket.Api/appsettings.json @@ -0,0 +1,14 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning", + "Microsoft.Hosting.Lifetime": "Information", + "OrderSphere": "Information" + } + }, + "AllowedHosts": "*", + "OpenTelemetry": { + "TracesSampleRatio": 1.0 + } +} diff --git a/src/Services/Catalog/OrderSphere.Catalog.Api/appsettings.json b/src/Services/Catalog/OrderSphere.Catalog.Api/appsettings.json index b9e4acd7..c32082ed 100644 --- a/src/Services/Catalog/OrderSphere.Catalog.Api/appsettings.json +++ b/src/Services/Catalog/OrderSphere.Catalog.Api/appsettings.json @@ -3,6 +3,8 @@ "LogLevel": { "Default": "Information", "Microsoft.AspNetCore": "Warning", + "Microsoft.Hosting.Lifetime": "Information", + "OrderSphere": "Information", "Microsoft.EntityFrameworkCore": "Warning" } }, @@ -21,5 +23,8 @@ "Blob": { "Endpoint": "", "ContainerName": "product-images" + }, + "OpenTelemetry": { + "TracesSampleRatio": 1.0 } } diff --git a/src/Services/Invoicing/OrderSphere.Invoicing.Api/Workers/CustomerErasureProcessor.cs b/src/Services/Invoicing/OrderSphere.Invoicing.Api/Workers/CustomerErasureProcessor.cs index 719afcc4..f6382d23 100644 --- a/src/Services/Invoicing/OrderSphere.Invoicing.Api/Workers/CustomerErasureProcessor.cs +++ b/src/Services/Invoicing/OrderSphere.Invoicing.Api/Workers/CustomerErasureProcessor.cs @@ -3,7 +3,6 @@ using OrderSphere.BuildingBlocks.Contracts.Events; using OrderSphere.BuildingBlocks.EventBus.AzureServiceBus; using OrderSphere.BuildingBlocks.EventBus.Inbox; -using OrderSphere.BuildingBlocks.Security; using OrderSphere.Invoicing.Infrastructure.Persistence; namespace OrderSphere.Invoicing.Api.Workers; @@ -33,7 +32,7 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) _processor.ProcessErrorAsync += OnError; await _processor.StartProcessingAsync(stoppingToken); - logger.LogInformation("CustomerErasureProcessor started, listening on queue '{Queue}'.", QueueName); + logger.ProcessorStarted(nameof(CustomerErasureProcessor), QueueName); try { @@ -43,15 +42,14 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) finally { await _processor.StopProcessingAsync(CancellationToken.None); - logger.LogInformation("CustomerErasureProcessor stopped."); + logger.ProcessorStopped(nameof(CustomerErasureProcessor)); } } private async Task OnMessageReceived(ProcessMessageEventArgs args) { - using var activity = EventBusDiagnostics.StartProcess(args.Message, QueueName); - var messageId = args.Message.MessageId; - logger.LogInformation("Received erasure-invoicing message {MessageId}.", messageId); + using var messageScope = MessageProcessingScope.Begin(logger, args.Message, QueueName); + logger.MessageReceived(); try { @@ -64,7 +62,7 @@ await args.DeadLetterMessageAsync(args.Message, return; } - using var tenantScope = AmbientTenantContext.BeginScope(evt.TenantId); + messageScope.SetTenant(evt.TenantId); await using var scope = scopeFactory.CreateAsyncScope(); var context = scope.ServiceProvider.GetRequiredService(); @@ -92,7 +90,7 @@ await args.DeadLetterMessageAsync(args.Message, } catch (Exception ex) { - logger.LogError(ex, "Unhandled exception processing erasure-invoicing message {MessageId}. Abandoning.", messageId); + logger.MessageProcessingFailed(ex); await args.AbandonMessageAsync(args.Message); } } diff --git a/src/Services/Invoicing/OrderSphere.Invoicing.Api/Workers/InvoiceProcessor.cs b/src/Services/Invoicing/OrderSphere.Invoicing.Api/Workers/InvoiceProcessor.cs index 54cd8997..52e0be21 100644 --- a/src/Services/Invoicing/OrderSphere.Invoicing.Api/Workers/InvoiceProcessor.cs +++ b/src/Services/Invoicing/OrderSphere.Invoicing.Api/Workers/InvoiceProcessor.cs @@ -4,7 +4,6 @@ using OrderSphere.BuildingBlocks.EventBus; using OrderSphere.BuildingBlocks.EventBus.AzureServiceBus; using OrderSphere.BuildingBlocks.EventBus.Inbox; -using OrderSphere.BuildingBlocks.Security; using OrderSphere.Invoicing.Application.Features.Invoice.GenerateInvoice; using AppItemDto = OrderSphere.Invoicing.Application.Models.InvoiceItemDto; using ContractItemDto = OrderSphere.BuildingBlocks.Contracts.Events.InvoiceItemDto; @@ -32,7 +31,7 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) _processor.ProcessErrorAsync += OnError; await _processor.StartProcessingAsync(stoppingToken); - logger.LogInformation("InvoiceProcessor started, listening on queue '{Queue}'.", InputQueue); + logger.ProcessorStarted(nameof(InvoiceProcessor), InputQueue); try { @@ -42,15 +41,14 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) finally { await _processor.StopProcessingAsync(CancellationToken.None); - logger.LogInformation("InvoiceProcessor stopped."); + logger.ProcessorStopped(nameof(InvoiceProcessor)); } } private async Task OnMessageReceived(ProcessMessageEventArgs args) { - using var activity = EventBusDiagnostics.StartProcess(args.Message, InputQueue); - var messageId = args.Message.MessageId; - logger.LogInformation("Received invoice-generation message {MessageId}.", messageId); + using var messageScope = MessageProcessingScope.Begin(logger, args.Message, InputQueue); + logger.MessageReceived(); try { @@ -63,7 +61,7 @@ await args.DeadLetterMessageAsync(args.Message, return; } - using var tenantScope = AmbientTenantContext.BeginScope(evt.TenantId); + messageScope.SetTenant(evt.TenantId); await using var scope = scopeFactory.CreateAsyncScope(); var inboxStore = scope.ServiceProvider.GetRequiredService(); @@ -88,7 +86,10 @@ await args.DeadLetterMessageAsync(args.Message, if (result.IsFailure) { - logger.LogError("Invoice generation failed for order {OrderId}: {Error}.", evt.OrderId, result.Error); + // Result failure, no exception: the message is abandoned and retried. + // Code and description as separate fields so failures group by code. + logger.LogWarning("Invoice generation failed for order {OrderId}: [{ErrorCode}] {ErrorDescription}", + evt.OrderId, result.Error.Code, result.Error.Description); await args.AbandonMessageAsync(args.Message); return; } @@ -113,7 +114,7 @@ await args.DeadLetterMessageAsync(args.Message, } catch (Exception ex) { - logger.LogError(ex, "Unhandled exception processing invoice-generation message {MessageId}. Abandoning.", messageId); + logger.MessageProcessingFailed(ex); await args.AbandonMessageAsync(args.Message); } } diff --git a/src/Services/Invoicing/OrderSphere.Invoicing.Api/appsettings.json b/src/Services/Invoicing/OrderSphere.Invoicing.Api/appsettings.json index 03389288..9f07ef99 100644 --- a/src/Services/Invoicing/OrderSphere.Invoicing.Api/appsettings.json +++ b/src/Services/Invoicing/OrderSphere.Invoicing.Api/appsettings.json @@ -3,11 +3,16 @@ "LogLevel": { "Default": "Information", "Microsoft.AspNetCore": "Warning", + "Microsoft.Hosting.Lifetime": "Information", + "OrderSphere": "Information", "Microsoft.EntityFrameworkCore": "Warning" } }, "AllowedHosts": "*", "InvoiceBlob": { "Endpoint": "" + }, + "OpenTelemetry": { + "TracesSampleRatio": 1.0 } } diff --git a/src/Services/Notification/OrderSphere.Notification.Worker/Channels/PushNotificationChannel.cs b/src/Services/Notification/OrderSphere.Notification.Worker/Channels/PushNotificationChannel.cs index 6a5b0ac3..13933896 100644 --- a/src/Services/Notification/OrderSphere.Notification.Worker/Channels/PushNotificationChannel.cs +++ b/src/Services/Notification/OrderSphere.Notification.Worker/Channels/PushNotificationChannel.cs @@ -12,9 +12,7 @@ public sealed class PushNotificationChannel(ILogger log public Task SendOrderConfirmationAsync(OrderPlacedIntegrationEvent evt, CancellationToken ct) { - logger.LogInformation( - "[Push] Order confirmation for order {OrderId} would be pushed to customer {Email}. (No push provider configured.)", - evt.OrderId, evt.CustomerEmail); + logger.PushNotificationStubbed(evt.OrderId, evt.CustomerEmail); return Task.CompletedTask; } } diff --git a/src/Services/Notification/OrderSphere.Notification.Worker/Channels/SmsNotificationChannel.cs b/src/Services/Notification/OrderSphere.Notification.Worker/Channels/SmsNotificationChannel.cs index b079cfae..a00c87df 100644 --- a/src/Services/Notification/OrderSphere.Notification.Worker/Channels/SmsNotificationChannel.cs +++ b/src/Services/Notification/OrderSphere.Notification.Worker/Channels/SmsNotificationChannel.cs @@ -12,9 +12,7 @@ public sealed class SmsNotificationChannel(ILogger logge public Task SendOrderConfirmationAsync(OrderPlacedIntegrationEvent evt, CancellationToken ct) { - logger.LogInformation( - "[SMS] Order confirmation for order {OrderId} would be sent to customer {Email}. (No SMS provider configured.)", - evt.OrderId, evt.CustomerEmail); + logger.SmsNotificationStubbed(evt.OrderId, evt.CustomerEmail); return Task.CompletedTask; } } diff --git a/src/Services/Notification/OrderSphere.Notification.Worker/Clients/FallbackUserProfileClient.cs b/src/Services/Notification/OrderSphere.Notification.Worker/Clients/FallbackUserProfileClient.cs index 40038ada..72a181d4 100644 --- a/src/Services/Notification/OrderSphere.Notification.Worker/Clients/FallbackUserProfileClient.cs +++ b/src/Services/Notification/OrderSphere.Notification.Worker/Clients/FallbackUserProfileClient.cs @@ -8,7 +8,7 @@ public sealed class FallbackUserProfileClient(ILogger { public Task GetNotificationPreferencesAsync(string customerEmail, CancellationToken ct) { - logger.LogDebug("UserProfile URL not configured; using default notification preferences for {Email}.", customerEmail); + logger.NotificationPreferencesDefaulted(customerEmail); return Task.FromResult(NotificationPreferences.Default); } } diff --git a/src/Services/Notification/OrderSphere.Notification.Worker/Clients/HttpUserProfileClient.cs b/src/Services/Notification/OrderSphere.Notification.Worker/Clients/HttpUserProfileClient.cs index 4e39735d..ef709b3e 100644 --- a/src/Services/Notification/OrderSphere.Notification.Worker/Clients/HttpUserProfileClient.cs +++ b/src/Services/Notification/OrderSphere.Notification.Worker/Clients/HttpUserProfileClient.cs @@ -17,9 +17,7 @@ public async Task GetNotificationPreferencesAsync( if (!response.IsSuccessStatusCode) { - logger.LogWarning( - "UserProfile returned {Status} for email {Email}. Using default preferences.", - (int)response.StatusCode, customerEmail); + logger.NotificationPreferencesUnavailable((int)response.StatusCode, customerEmail); return NotificationPreferences.Default; } @@ -30,7 +28,7 @@ public async Task GetNotificationPreferencesAsync( } catch (Exception ex) { - logger.LogWarning(ex, "Failed to fetch notification preferences for {Email}. Using defaults.", customerEmail); + logger.NotificationPreferencesFetchFailed(ex, customerEmail); return NotificationPreferences.Default; } } diff --git a/src/Services/Notification/OrderSphere.Notification.Worker/Email/LoggingNotificationEmailService.cs b/src/Services/Notification/OrderSphere.Notification.Worker/Email/LoggingNotificationEmailService.cs index 7597b14b..cf7e230f 100644 --- a/src/Services/Notification/OrderSphere.Notification.Worker/Email/LoggingNotificationEmailService.cs +++ b/src/Services/Notification/OrderSphere.Notification.Worker/Email/LoggingNotificationEmailService.cs @@ -7,26 +7,13 @@ internal sealed class LoggingNotificationEmailService(ILogger +/// Source-generated log methods for the Notification worker. +/// +/// This service is the only one that handles customer email addresses in its hot path, so every +/// log statement that touches one lives here: redaction applies to classified +/// [LoggerMessage] parameters only, never to a plain logger.LogInformation(...) +/// call. Marking the parameter makes the pipeline replace the +/// address with a keyed hash before it reaches any sink, so records stay groupable per customer +/// without carrying the address. +/// +/// EventId range 8000-8999 (see docs/logging.md). +/// +internal static partial class Log +{ + [LoggerMessage( + EventId = 8001, + Level = LogLevel.Information, + Message = "Invoice-ready email sent for invoice {invoiceNumber}.")] + public static partial void InvoiceReadyEmailSent( + this ILogger logger, + string invoiceNumber, + [DirectPii] string recipient); + + [LoggerMessage( + EventId = 8002, + Level = LogLevel.Information, + Message = "Confirmation email sent for order {orderId}.")] + public static partial void OrderConfirmationEmailSent( + this ILogger logger, + Guid orderId, + [DirectPii] string recipient); + + [LoggerMessage( + EventId = 8003, + Level = LogLevel.Warning, + Message = "Failed to send invoice-ready email for invoice {invoiceNumber}.")] + public static partial void InvoiceReadyEmailFailed( + this ILogger logger, + Exception exception, + string invoiceNumber); + + [LoggerMessage( + EventId = 8004, + Level = LogLevel.Warning, + Message = "Failed to send confirmation email for order {orderId}.")] + public static partial void OrderConfirmationEmailFailed( + this ILogger logger, + Exception exception, + Guid orderId); + + [LoggerMessage( + EventId = 8005, + Level = LogLevel.Information, + Message = "[DEV] Order confirmation email suppressed for order {orderId}, tracking {trackingNumber}.")] + public static partial void OrderConfirmationEmailSuppressed( + this ILogger logger, + Guid orderId, + string trackingNumber, + [DirectPii] string recipient); + + [LoggerMessage( + EventId = 8006, + Level = LogLevel.Information, + Message = "[DEV] Invoice-ready email suppressed for invoice {invoiceNumber}, order {orderId}.")] + public static partial void InvoiceReadyEmailSuppressed( + this ILogger logger, + string invoiceNumber, + Guid orderId, + [DirectPii] string recipient); + + [LoggerMessage( + EventId = 8007, + Level = LogLevel.Information, + Message = "[SMS] Order confirmation for order {orderId} would be sent. (No SMS provider configured.)")] + public static partial void SmsNotificationStubbed( + this ILogger logger, + Guid orderId, + [DirectPii] string recipient); + + [LoggerMessage( + EventId = 8008, + Level = LogLevel.Information, + Message = "[Push] Order confirmation for order {orderId} would be pushed. (No push provider configured.)")] + public static partial void PushNotificationStubbed( + this ILogger logger, + Guid orderId, + [DirectPii] string recipient); + + [LoggerMessage( + EventId = 8009, + Level = LogLevel.Warning, + Message = "UserProfile returned {status} for a customer lookup. Using default preferences.")] + public static partial void NotificationPreferencesUnavailable( + this ILogger logger, + int status, + [DirectPii] string customerEmail); + + [LoggerMessage( + EventId = 8010, + Level = LogLevel.Warning, + Message = "Failed to fetch notification preferences. Using defaults.")] + public static partial void NotificationPreferencesFetchFailed( + this ILogger logger, + Exception exception, + [DirectPii] string customerEmail); + + [LoggerMessage( + EventId = 8011, + Level = LogLevel.Debug, + Message = "UserProfile URL not configured; using default notification preferences.")] + public static partial void NotificationPreferencesDefaulted( + this ILogger logger, + [DirectPii] string customerEmail); +} diff --git a/src/Services/Notification/OrderSphere.Notification.Worker/OrderSphere.Notification.Worker.csproj b/src/Services/Notification/OrderSphere.Notification.Worker/OrderSphere.Notification.Worker.csproj index c472c30b..6e471944 100644 --- a/src/Services/Notification/OrderSphere.Notification.Worker/OrderSphere.Notification.Worker.csproj +++ b/src/Services/Notification/OrderSphere.Notification.Worker/OrderSphere.Notification.Worker.csproj @@ -9,6 +9,9 @@ + + diff --git a/src/Services/Notification/OrderSphere.Notification.Worker/Program.cs b/src/Services/Notification/OrderSphere.Notification.Worker/Program.cs index 99b635c2..2f1257b9 100644 --- a/src/Services/Notification/OrderSphere.Notification.Worker/Program.cs +++ b/src/Services/Notification/OrderSphere.Notification.Worker/Program.cs @@ -86,6 +86,10 @@ app.UseAuthentication(); app.UseAuthorization(); +// DLQ admin endpoints are a real HTTP surface; log them like any other. +// Placed after auth so the log scope carries the authenticated user. +app.UseOrderSphereRequestLogging(); + // Admin DLQ surface — the gateway forwards /api/v1/admin/notification/dlq/** here. app.MapDlqAdminEndpoints("api/v1/admin/notification/dlq", "AdminPolicy"); diff --git a/src/Services/Notification/OrderSphere.Notification.Worker/Workers/InvoiceGeneratedProcessor.cs b/src/Services/Notification/OrderSphere.Notification.Worker/Workers/InvoiceGeneratedProcessor.cs index 34f8348e..72b7400d 100644 --- a/src/Services/Notification/OrderSphere.Notification.Worker/Workers/InvoiceGeneratedProcessor.cs +++ b/src/Services/Notification/OrderSphere.Notification.Worker/Workers/InvoiceGeneratedProcessor.cs @@ -26,7 +26,7 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) _processor.ProcessErrorAsync += OnError; await _processor.StartProcessingAsync(stoppingToken); - logger.LogInformation("InvoiceGeneratedProcessor started, listening on queue '{Queue}'.", QueueName); + logger.ProcessorStarted(nameof(InvoiceGeneratedProcessor), QueueName); try { @@ -36,15 +36,14 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) finally { await _processor.StopProcessingAsync(CancellationToken.None); - logger.LogInformation("InvoiceGeneratedProcessor stopped."); + logger.ProcessorStopped(nameof(InvoiceGeneratedProcessor)); } } private async Task OnMessageReceived(ProcessMessageEventArgs args) { - using var activity = EventBusDiagnostics.StartProcess(args.Message, QueueName); - var messageId = args.Message.MessageId; - logger.LogInformation("Received invoice-ready message {MessageId}.", messageId); + using var messageScope = MessageProcessingScope.Begin(logger, args.Message, QueueName); + logger.MessageReceived(); try { @@ -76,7 +75,7 @@ await args.DeadLetterMessageAsync(args.Message, } catch (Exception ex) { - logger.LogError(ex, "Unhandled exception processing invoice-ready message {MessageId}. Abandoning.", messageId); + logger.MessageProcessingFailed(ex); await args.AbandonMessageAsync(args.Message); } } diff --git a/src/Services/Notification/OrderSphere.Notification.Worker/Workers/NotificationProcessor.cs b/src/Services/Notification/OrderSphere.Notification.Worker/Workers/NotificationProcessor.cs index 9922e4b8..244c7b8d 100644 --- a/src/Services/Notification/OrderSphere.Notification.Worker/Workers/NotificationProcessor.cs +++ b/src/Services/Notification/OrderSphere.Notification.Worker/Workers/NotificationProcessor.cs @@ -27,7 +27,7 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) _processor.ProcessErrorAsync += OnError; await _processor.StartProcessingAsync(stoppingToken); - logger.LogInformation("NotificationProcessor started, listening on queue '{Queue}'.", QueueName); + logger.ProcessorStarted(nameof(NotificationProcessor), QueueName); try { @@ -37,22 +37,21 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) finally { await _processor.StopProcessingAsync(CancellationToken.None); - logger.LogInformation("NotificationProcessor stopped."); + logger.ProcessorStopped(nameof(NotificationProcessor)); } } private async Task OnMessageReceived(ProcessMessageEventArgs args) { - using var activity = EventBusDiagnostics.StartProcess(args.Message, QueueName); - var messageId = args.Message.MessageId; - logger.LogInformation("Received notification message {MessageId}.", messageId); + using var messageScope = MessageProcessingScope.Begin(logger, args.Message, QueueName); + logger.MessageReceived(); try { var evt = args.Message.Body.ToObjectFromJson(); if (evt is null) { - logger.LogError("Message {MessageId} could not be deserialized. Dead-lettering.", messageId); + logger.MessageUndeserializable(); await args.DeadLetterMessageAsync(args.Message, deadLetterReason: "DeserializationFailed", deadLetterErrorDescription: "Body was not a valid OrderPlacedIntegrationEvent."); @@ -69,7 +68,7 @@ await args.DeadLetterMessageAsync(args.Message, } catch (Exception ex) { - logger.LogError(ex, "Unhandled exception processing notification message {MessageId}. Abandoning.", messageId); + logger.MessageProcessingFailed(ex); await args.AbandonMessageAsync(args.Message); } } diff --git a/src/Services/Notification/OrderSphere.Notification.Worker/appsettings.json b/src/Services/Notification/OrderSphere.Notification.Worker/appsettings.json new file mode 100644 index 00000000..71df9b7a --- /dev/null +++ b/src/Services/Notification/OrderSphere.Notification.Worker/appsettings.json @@ -0,0 +1,14 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning", + "Microsoft.Hosting.Lifetime": "Information", + "OrderSphere": "Information" + } + }, + "AllowedHosts": "*", + "OpenTelemetry": { + "TracesSampleRatio": 1.0 + } +} diff --git a/src/Services/Ordering/OrderSphere.Ordering.Api/appsettings.json b/src/Services/Ordering/OrderSphere.Ordering.Api/appsettings.json new file mode 100644 index 00000000..71df9b7a --- /dev/null +++ b/src/Services/Ordering/OrderSphere.Ordering.Api/appsettings.json @@ -0,0 +1,14 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning", + "Microsoft.Hosting.Lifetime": "Information", + "OrderSphere": "Information" + } + }, + "AllowedHosts": "*", + "OpenTelemetry": { + "TracesSampleRatio": 1.0 + } +} diff --git a/src/Services/Ordering/OrderSphere.Ordering.Application/Features/Checkout/CheckoutCartCommandHandler.cs b/src/Services/Ordering/OrderSphere.Ordering.Application/Features/Checkout/CheckoutCartCommandHandler.cs index a155423e..377408e9 100644 --- a/src/Services/Ordering/OrderSphere.Ordering.Application/Features/Checkout/CheckoutCartCommandHandler.cs +++ b/src/Services/Ordering/OrderSphere.Ordering.Application/Features/Checkout/CheckoutCartCommandHandler.cs @@ -49,7 +49,9 @@ public async Task> Handle(CheckoutCartCommand request, Cancellation var cartResult = await basketClient.GetCartAsync(request.CustomerId.Value, cancellationToken); if (cartResult.IsFailure) { - logger.LogError("Cart not found for customer {CustomerId} via Basket service", request.CustomerId); + // An empty or expired cart is a normal user outcome, returned as a Result + // failure — not an error condition for the service. + logger.LogWarning("Cart not found for customer {CustomerId} via Basket service", request.CustomerId); return Result.Failure(CheckoutCartErrors.CartNotFoundError); } diff --git a/src/Services/Ordering/OrderSphere.Ordering.Application/Features/Coupon/ValidateCouponQueryHandler.cs b/src/Services/Ordering/OrderSphere.Ordering.Application/Features/Coupon/ValidateCouponQueryHandler.cs index ed0dc984..7fe9cd06 100644 --- a/src/Services/Ordering/OrderSphere.Ordering.Application/Features/Coupon/ValidateCouponQueryHandler.cs +++ b/src/Services/Ordering/OrderSphere.Ordering.Application/Features/Coupon/ValidateCouponQueryHandler.cs @@ -25,7 +25,9 @@ public async Task> Handle(ValidateCouponQuery reques if (coupon is null) { - logger.LogInformation("Coupon code not found: {Code}", request.Code); + // Warning, matching AddToCartCommandHandler: both are lookup misses driven by + // client input that are returned as a Result failure (see docs/logging.md). + logger.LogWarning("Coupon code not found: {Code}", request.Code); return Result.Failure(CouponErrors.InvalidCode); } diff --git a/src/Services/Ordering/OrderSphere.Ordering.Worker/Program.cs b/src/Services/Ordering/OrderSphere.Ordering.Worker/Program.cs index 10edbbd0..e15cab60 100644 --- a/src/Services/Ordering/OrderSphere.Ordering.Worker/Program.cs +++ b/src/Services/Ordering/OrderSphere.Ordering.Worker/Program.cs @@ -60,7 +60,13 @@ builder.Services.AddHostedService(); builder.Services.AddHostedService(); -builder.Services.AddMediatR(cfg => cfg.RegisterServicesFromAssembly(typeof(Program).Assembly)); +builder.Services.AddMediatR(cfg => +{ + cfg.RegisterServicesFromAssembly(typeof(Program).Assembly); + // The only MediatR host that was missing these; every *.Application does register them. + cfg.AddOpenBehavior(typeof(LoggingBehavior<,>)); + cfg.AddOpenBehavior(typeof(ValidationBehavior<,>)); +}); builder.Services.AddTransient(typeof(INotificationHandler<>), typeof(DomainEventLoggingHandler<>)); // DLQ admin surface: admin-protected dead-letter reader/replay for this worker's queues, plus the @@ -80,6 +86,10 @@ app.UseAuthentication(); app.UseAuthorization(); +// DLQ admin endpoints are a real HTTP surface; log them like any other. +// Placed after auth so the log scope carries the authenticated user. +app.UseOrderSphereRequestLogging(); + // Admin DLQ surface — the gateway forwards /api/v1/admin/ordering/dlq/** here. app.MapDlqAdminEndpoints("api/v1/admin/ordering/dlq", "AdminPolicy"); diff --git a/src/Services/Ordering/OrderSphere.Ordering.Worker/Workers/CustomerErasureProcessor.cs b/src/Services/Ordering/OrderSphere.Ordering.Worker/Workers/CustomerErasureProcessor.cs index e684c462..e92fdf16 100644 --- a/src/Services/Ordering/OrderSphere.Ordering.Worker/Workers/CustomerErasureProcessor.cs +++ b/src/Services/Ordering/OrderSphere.Ordering.Worker/Workers/CustomerErasureProcessor.cs @@ -3,7 +3,6 @@ using OrderSphere.BuildingBlocks.Contracts.Events; using OrderSphere.BuildingBlocks.EventBus.AzureServiceBus; using OrderSphere.BuildingBlocks.EventBus.Inbox; -using OrderSphere.BuildingBlocks.Security; using OrderSphere.BuildingBlocks.StronglyTypedIds; using OrderSphere.Ordering.Infrastructure.Persistence; @@ -34,7 +33,7 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) _processor.ProcessErrorAsync += OnError; await _processor.StartProcessingAsync(stoppingToken); - logger.LogInformation("CustomerErasureProcessor started, listening on queue '{Queue}'.", QueueName); + logger.ProcessorStarted(nameof(CustomerErasureProcessor), QueueName); try { @@ -44,15 +43,14 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) finally { await _processor.StopProcessingAsync(CancellationToken.None); - logger.LogInformation("CustomerErasureProcessor stopped."); + logger.ProcessorStopped(nameof(CustomerErasureProcessor)); } } private async Task OnMessageReceived(ProcessMessageEventArgs args) { - using var activity = EventBusDiagnostics.StartProcess(args.Message, QueueName); - var messageId = args.Message.MessageId; - logger.LogInformation("Received erasure-ordering message {MessageId}.", messageId); + using var messageScope = MessageProcessingScope.Begin(logger, args.Message, QueueName); + logger.MessageReceived(); try { @@ -65,7 +63,7 @@ await args.DeadLetterMessageAsync(args.Message, return; } - using var tenantScope = AmbientTenantContext.BeginScope(evt.TenantId); + messageScope.SetTenant(evt.TenantId); await using var scope = scopeFactory.CreateAsyncScope(); var context = scope.ServiceProvider.GetRequiredService(); @@ -95,7 +93,7 @@ await args.DeadLetterMessageAsync(args.Message, } catch (Exception ex) { - logger.LogError(ex, "Unhandled exception processing erasure-ordering message {MessageId}. Abandoning.", messageId); + logger.MessageProcessingFailed(ex); await args.AbandonMessageAsync(args.Message); } } diff --git a/src/Services/Ordering/OrderSphere.Ordering.Worker/Workers/OrderHistoryProjector.cs b/src/Services/Ordering/OrderSphere.Ordering.Worker/Workers/OrderHistoryProjector.cs index 63b59d19..a66fcf14 100644 --- a/src/Services/Ordering/OrderSphere.Ordering.Worker/Workers/OrderHistoryProjector.cs +++ b/src/Services/Ordering/OrderSphere.Ordering.Worker/Workers/OrderHistoryProjector.cs @@ -2,7 +2,6 @@ using OrderSphere.BuildingBlocks.Contracts.Events; using OrderSphere.BuildingBlocks.EventBus.AzureServiceBus; using OrderSphere.BuildingBlocks.EventBus.Inbox; -using OrderSphere.BuildingBlocks.Security; using OrderSphere.Ordering.Domain.Entities; using OrderSphere.Ordering.Infrastructure.Persistence; @@ -36,7 +35,7 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) _processor.ProcessErrorAsync += OnError; await _processor.StartProcessingAsync(stoppingToken); - logger.LogInformation("OrderHistoryProjector started, listening on queue '{Queue}'.", QueueName); + logger.ProcessorStarted(nameof(OrderHistoryProjector), QueueName); try { @@ -46,29 +45,28 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) finally { await _processor.StopProcessingAsync(CancellationToken.None); - logger.LogInformation("OrderHistoryProjector stopped."); + logger.ProcessorStopped(nameof(OrderHistoryProjector)); } } private async Task OnMessageReceived(ProcessMessageEventArgs args) { - using var activity = EventBusDiagnostics.StartProcess(args.Message, QueueName); - var messageId = args.Message.MessageId; - logger.LogInformation("Received order-history message {MessageId}", messageId); + using var messageScope = MessageProcessingScope.Begin(logger, args.Message, QueueName); + logger.MessageReceived(); try { var evt = args.Message.Body.ToObjectFromJson(); if (evt is null) { - logger.LogError("Message {MessageId} could not be deserialized. Dead-lettering.", messageId); + logger.MessageUndeserializable(); await args.DeadLetterMessageAsync(args.Message, deadLetterReason: "DeserializationFailed", deadLetterErrorDescription: "Body was not a valid OrderStatusChangedIntegrationEvent."); return; } - using var tenantScope = AmbientTenantContext.BeginScope(evt.TenantId); + messageScope.SetTenant(evt.TenantId); await using var scope = scopeFactory.CreateAsyncScope(); var context = scope.ServiceProvider.GetRequiredService(); @@ -80,7 +78,7 @@ await args.DeadLetterMessageAsync(args.Message, } catch (Exception ex) { - logger.LogError(ex, "Unhandled exception projecting order-history {MessageId}. Abandoning.", messageId); + logger.MessageProcessingFailed(ex); await args.AbandonMessageAsync(args.Message); } } diff --git a/src/Services/Ordering/OrderSphere.Ordering.Worker/Workers/OrderProcessor.cs b/src/Services/Ordering/OrderSphere.Ordering.Worker/Workers/OrderProcessor.cs index f8896fd5..8b804754 100644 --- a/src/Services/Ordering/OrderSphere.Ordering.Worker/Workers/OrderProcessor.cs +++ b/src/Services/Ordering/OrderSphere.Ordering.Worker/Workers/OrderProcessor.cs @@ -3,7 +3,6 @@ using Microsoft.EntityFrameworkCore; using OrderSphere.BuildingBlocks.Contracts.Events; using OrderSphere.BuildingBlocks.EventBus.AzureServiceBus; -using OrderSphere.BuildingBlocks.Security; using OrderSphere.BuildingBlocks.StronglyTypedIds; using OrderSphere.BuildingBlocks.ValueObjects; using OrderSphere.Ordering.Application.Abstractions; @@ -36,7 +35,7 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) _processor.ProcessErrorAsync += OnError; await _processor.StartProcessingAsync(stoppingToken); - logger.LogInformation("OrderProcessor started, listening on queue '{Queue}'.", QueueName); + logger.ProcessorStarted(nameof(OrderProcessor), QueueName); try { @@ -46,29 +45,28 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) finally { await _processor.StopProcessingAsync(CancellationToken.None); - logger.LogInformation("OrderProcessor stopped."); + logger.ProcessorStopped(nameof(OrderProcessor)); } } private async Task OnMessageReceived(ProcessMessageEventArgs args) { - using var activity = EventBusDiagnostics.StartProcess(args.Message, QueueName); - var messageId = args.Message.MessageId; - logger.LogInformation("Received message {MessageId}", messageId); + using var messageScope = MessageProcessingScope.Begin(logger, args.Message, QueueName); + logger.MessageReceived(); try { var evt = args.Message.Body.ToObjectFromJson(); if (evt is null) { - logger.LogError("Message {MessageId} could not be deserialized. Dead-lettering.", messageId); + logger.MessageUndeserializable(); await args.DeadLetterMessageAsync(args.Message, deadLetterReason: "DeserializationFailed", deadLetterErrorDescription: "Body was not a valid CheckoutCartEvent."); return; } - using var tenantScope = AmbientTenantContext.BeginScope(evt.TenantId); + messageScope.SetTenant(evt.TenantId); await using var scope = scopeFactory.CreateAsyncScope(); var context = scope.ServiceProvider.GetRequiredService(); @@ -78,19 +76,21 @@ await args.DeadLetterMessageAsync(args.Message, if (result.IsSuccess) { await args.CompleteMessageAsync(args.Message); - logger.LogInformation("Message {MessageId} processed. CorrelationId: {CorrelationId}", - messageId, evt.CorrelationId); + // CorrelationId here is the business idempotency key on the event, distinct + // from the log correlation_id supplied by enrichment. + logger.LogInformation("Message processed. Event correlation {EventCorrelationId}", + evt.CorrelationId); } else { - logger.LogWarning("ProcessOrder returned failure for message {MessageId}: {Error}. Abandoning.", - messageId, result.ErrorMessage); + logger.LogWarning("ProcessOrder returned failure: {Error}. Abandoning.", + result.ErrorMessage); await args.AbandonMessageAsync(args.Message); } } catch (Exception ex) { - logger.LogError(ex, "Unhandled exception processing message {MessageId}. Abandoning.", messageId); + logger.MessageProcessingFailed(ex); await args.AbandonMessageAsync(args.Message); } } diff --git a/src/Services/Ordering/OrderSphere.Ordering.Worker/Workers/PaymentRefundProcessor.cs b/src/Services/Ordering/OrderSphere.Ordering.Worker/Workers/PaymentRefundProcessor.cs index 934b812b..7970fa62 100644 --- a/src/Services/Ordering/OrderSphere.Ordering.Worker/Workers/PaymentRefundProcessor.cs +++ b/src/Services/Ordering/OrderSphere.Ordering.Worker/Workers/PaymentRefundProcessor.cs @@ -3,7 +3,6 @@ using OrderSphere.BuildingBlocks.Contracts.Events; using OrderSphere.BuildingBlocks.EventBus.AzureServiceBus; using OrderSphere.BuildingBlocks.EventBus.Inbox; -using OrderSphere.BuildingBlocks.Security; using OrderSphere.BuildingBlocks.StronglyTypedIds; using OrderSphere.Ordering.Domain.Enums; using OrderSphere.Ordering.Infrastructure.Persistence; @@ -36,7 +35,7 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) _processor.ProcessErrorAsync += OnError; await _processor.StartProcessingAsync(stoppingToken); - logger.LogInformation("PaymentRefundProcessor started, listening on queue '{Queue}'.", QueueName); + logger.ProcessorStarted(nameof(PaymentRefundProcessor), QueueName); try { @@ -46,29 +45,28 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) finally { await _processor.StopProcessingAsync(CancellationToken.None); - logger.LogInformation("PaymentRefundProcessor stopped."); + logger.ProcessorStopped(nameof(PaymentRefundProcessor)); } } private async Task OnMessageReceived(ProcessMessageEventArgs args) { - using var activity = EventBusDiagnostics.StartProcess(args.Message, QueueName); - var messageId = args.Message.MessageId; - logger.LogInformation("Received payment refund message {MessageId}", messageId); + using var messageScope = MessageProcessingScope.Begin(logger, args.Message, QueueName); + logger.MessageReceived(); try { var evt = args.Message.Body.ToObjectFromJson(); if (evt is null) { - logger.LogError("Message {MessageId} could not be deserialized. Dead-lettering.", messageId); + logger.MessageUndeserializable(); await args.DeadLetterMessageAsync(args.Message, deadLetterReason: "DeserializationFailed", deadLetterErrorDescription: "Body was not a valid PaymentRefundedIntegrationEvent."); return; } - using var tenantScope = AmbientTenantContext.BeginScope(evt.TenantId); + messageScope.SetTenant(evt.TenantId); await using var scope = scopeFactory.CreateAsyncScope(); var context = scope.ServiceProvider.GetRequiredService(); @@ -80,7 +78,7 @@ await args.DeadLetterMessageAsync(args.Message, } catch (Exception ex) { - logger.LogError(ex, "Unhandled exception processing payment refund {MessageId}. Abandoning.", messageId); + logger.MessageProcessingFailed(ex); await args.AbandonMessageAsync(args.Message); } } diff --git a/src/Services/Ordering/OrderSphere.Ordering.Worker/Workers/PaymentResultProcessor.cs b/src/Services/Ordering/OrderSphere.Ordering.Worker/Workers/PaymentResultProcessor.cs index 9615ca58..2225c04e 100644 --- a/src/Services/Ordering/OrderSphere.Ordering.Worker/Workers/PaymentResultProcessor.cs +++ b/src/Services/Ordering/OrderSphere.Ordering.Worker/Workers/PaymentResultProcessor.cs @@ -5,7 +5,6 @@ using OrderSphere.BuildingBlocks.EventBus.AzureServiceBus; using OrderSphere.BuildingBlocks.EventBus.Inbox; using OrderSphere.BuildingBlocks.Primitives; -using OrderSphere.BuildingBlocks.Security; using OrderSphere.BuildingBlocks.StronglyTypedIds; using OrderSphere.Ordering.Application.Abstractions; using OrderSphere.Ordering.Domain.Entities; @@ -37,7 +36,7 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) _processor.ProcessErrorAsync += OnError; await _processor.StartProcessingAsync(stoppingToken); - logger.LogInformation("PaymentResultProcessor started, listening on queue '{Queue}'.", QueueName); + logger.ProcessorStarted(nameof(PaymentResultProcessor), QueueName); try { @@ -47,29 +46,28 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) finally { await _processor.StopProcessingAsync(CancellationToken.None); - logger.LogInformation("PaymentResultProcessor stopped."); + logger.ProcessorStopped(nameof(PaymentResultProcessor)); } } private async Task OnMessageReceived(ProcessMessageEventArgs args) { - using var activity = EventBusDiagnostics.StartProcess(args.Message, QueueName); - var messageId = args.Message.MessageId; - logger.LogInformation("Received payment result message {MessageId}", messageId); + using var messageScope = MessageProcessingScope.Begin(logger, args.Message, QueueName); + logger.MessageReceived(); try { var evt = args.Message.Body.ToObjectFromJson(); if (evt is null) { - logger.LogError("Message {MessageId} could not be deserialized. Dead-lettering.", messageId); + logger.MessageUndeserializable(); await args.DeadLetterMessageAsync(args.Message, deadLetterReason: "DeserializationFailed", deadLetterErrorDescription: "Body was not a valid PaymentProcessedIntegrationEvent."); return; } - using var tenantScope = AmbientTenantContext.BeginScope(evt.TenantId); + messageScope.SetTenant(evt.TenantId); await using var scope = scopeFactory.CreateAsyncScope(); var context = scope.ServiceProvider.GetRequiredService(); @@ -92,7 +90,7 @@ await args.DeadLetterMessageAsync(args.Message, } catch (Exception ex) { - logger.LogError(ex, "Unhandled exception processing payment result {MessageId}. Abandoning.", messageId); + logger.MessageProcessingFailed(ex); await args.AbandonMessageAsync(args.Message); } } diff --git a/src/Services/Ordering/OrderSphere.Ordering.Worker/appsettings.json b/src/Services/Ordering/OrderSphere.Ordering.Worker/appsettings.json new file mode 100644 index 00000000..71df9b7a --- /dev/null +++ b/src/Services/Ordering/OrderSphere.Ordering.Worker/appsettings.json @@ -0,0 +1,14 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning", + "Microsoft.Hosting.Lifetime": "Information", + "OrderSphere": "Information" + } + }, + "AllowedHosts": "*", + "OpenTelemetry": { + "TracesSampleRatio": 1.0 + } +} diff --git a/src/Services/Partners/OrderSphere.Partners.Api/appsettings.Development.json b/src/Services/Partners/OrderSphere.Partners.Api/appsettings.Development.json index 0c208ae9..339d732b 100644 --- a/src/Services/Partners/OrderSphere.Partners.Api/appsettings.Development.json +++ b/src/Services/Partners/OrderSphere.Partners.Api/appsettings.Development.json @@ -2,7 +2,9 @@ "Logging": { "LogLevel": { "Default": "Information", - "Microsoft.AspNetCore": "Warning" + "Microsoft.AspNetCore": "Warning", + "Microsoft.Hosting.Lifetime": "Information", + "OrderSphere": "Debug" } } } diff --git a/src/Services/Partners/OrderSphere.Partners.Api/appsettings.json b/src/Services/Partners/OrderSphere.Partners.Api/appsettings.json index 0c208ae9..a59cc85c 100644 --- a/src/Services/Partners/OrderSphere.Partners.Api/appsettings.json +++ b/src/Services/Partners/OrderSphere.Partners.Api/appsettings.json @@ -2,7 +2,12 @@ "Logging": { "LogLevel": { "Default": "Information", - "Microsoft.AspNetCore": "Warning" + "Microsoft.AspNetCore": "Warning", + "Microsoft.Hosting.Lifetime": "Information", + "OrderSphere": "Information" } + }, + "OpenTelemetry": { + "TracesSampleRatio": 1.0 } } diff --git a/src/Services/Payment/OrderSphere.Payment.Api/appsettings.json b/src/Services/Payment/OrderSphere.Payment.Api/appsettings.json index 10f68b8c..71df9b7a 100644 --- a/src/Services/Payment/OrderSphere.Payment.Api/appsettings.json +++ b/src/Services/Payment/OrderSphere.Payment.Api/appsettings.json @@ -2,8 +2,13 @@ "Logging": { "LogLevel": { "Default": "Information", - "Microsoft.AspNetCore": "Warning" + "Microsoft.AspNetCore": "Warning", + "Microsoft.Hosting.Lifetime": "Information", + "OrderSphere": "Information" } }, - "AllowedHosts": "*" + "AllowedHosts": "*", + "OpenTelemetry": { + "TracesSampleRatio": 1.0 + } } diff --git a/src/Services/Payment/OrderSphere.Payment.Worker/Program.cs b/src/Services/Payment/OrderSphere.Payment.Worker/Program.cs index e739dbbf..64ebd8c0 100644 --- a/src/Services/Payment/OrderSphere.Payment.Worker/Program.cs +++ b/src/Services/Payment/OrderSphere.Payment.Worker/Program.cs @@ -59,6 +59,10 @@ app.UseAuthentication(); app.UseAuthorization(); +// DLQ admin endpoints are a real HTTP surface; log them like any other. +// Placed after auth so the log scope carries the authenticated user. +app.UseOrderSphereRequestLogging(); + // Admin DLQ surface — the gateway forwards /api/v1/admin/payment/dlq/** here. app.MapDlqAdminEndpoints("api/v1/admin/payment/dlq", "AdminPolicy"); diff --git a/src/Services/Payment/OrderSphere.Payment.Worker/Workers/CustomerErasureProcessor.cs b/src/Services/Payment/OrderSphere.Payment.Worker/Workers/CustomerErasureProcessor.cs index 5d2f0568..de8fda52 100644 --- a/src/Services/Payment/OrderSphere.Payment.Worker/Workers/CustomerErasureProcessor.cs +++ b/src/Services/Payment/OrderSphere.Payment.Worker/Workers/CustomerErasureProcessor.cs @@ -3,7 +3,6 @@ using OrderSphere.BuildingBlocks.Contracts.Events; using OrderSphere.BuildingBlocks.EventBus.AzureServiceBus; using OrderSphere.BuildingBlocks.EventBus.Inbox; -using OrderSphere.BuildingBlocks.Security; using OrderSphere.Payment.Infrastructure.Persistence; namespace OrderSphere.Payment.Worker.Workers; @@ -33,7 +32,7 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) _processor.ProcessErrorAsync += OnError; await _processor.StartProcessingAsync(stoppingToken); - logger.LogInformation("CustomerErasureProcessor started, listening on queue '{Queue}'.", QueueName); + logger.ProcessorStarted(nameof(CustomerErasureProcessor), QueueName); try { @@ -43,15 +42,14 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) finally { await _processor.StopProcessingAsync(CancellationToken.None); - logger.LogInformation("CustomerErasureProcessor stopped."); + logger.ProcessorStopped(nameof(CustomerErasureProcessor)); } } private async Task OnMessageReceived(ProcessMessageEventArgs args) { - using var activity = EventBusDiagnostics.StartProcess(args.Message, QueueName); - var messageId = args.Message.MessageId; - logger.LogInformation("Received erasure-payment message {MessageId}.", messageId); + using var messageScope = MessageProcessingScope.Begin(logger, args.Message, QueueName); + logger.MessageReceived(); try { @@ -64,7 +62,7 @@ await args.DeadLetterMessageAsync(args.Message, return; } - using var tenantScope = AmbientTenantContext.BeginScope(evt.TenantId); + messageScope.SetTenant(evt.TenantId); await using var scope = scopeFactory.CreateAsyncScope(); var context = scope.ServiceProvider.GetRequiredService(); @@ -93,7 +91,7 @@ await args.DeadLetterMessageAsync(args.Message, } catch (Exception ex) { - logger.LogError(ex, "Unhandled exception processing erasure-payment message {MessageId}. Abandoning.", messageId); + logger.MessageProcessingFailed(ex); await args.AbandonMessageAsync(args.Message); } } diff --git a/src/Services/Payment/OrderSphere.Payment.Worker/Workers/OrderConfirmationFailedProcessor.cs b/src/Services/Payment/OrderSphere.Payment.Worker/Workers/OrderConfirmationFailedProcessor.cs index d186e78f..84d84035 100644 --- a/src/Services/Payment/OrderSphere.Payment.Worker/Workers/OrderConfirmationFailedProcessor.cs +++ b/src/Services/Payment/OrderSphere.Payment.Worker/Workers/OrderConfirmationFailedProcessor.cs @@ -5,7 +5,6 @@ using OrderSphere.BuildingBlocks.Contracts.Events; using OrderSphere.BuildingBlocks.EventBus.AzureServiceBus; using OrderSphere.BuildingBlocks.EventBus.Inbox; -using OrderSphere.BuildingBlocks.Security; using OrderSphere.BuildingBlocks.StronglyTypedIds; using OrderSphere.Payment.Domain.Enums; using OrderSphere.Payment.Infrastructure.Persistence; @@ -40,7 +39,7 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) _processor.ProcessErrorAsync += OnError; await _processor.StartProcessingAsync(stoppingToken); - logger.LogInformation("OrderConfirmationFailedProcessor started, listening on queue '{Queue}'.", QueueName); + logger.ProcessorStarted(nameof(OrderConfirmationFailedProcessor), QueueName); try { @@ -50,29 +49,28 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) finally { await _processor.StopProcessingAsync(CancellationToken.None); - logger.LogInformation("OrderConfirmationFailedProcessor stopped."); + logger.ProcessorStopped(nameof(OrderConfirmationFailedProcessor)); } } private async Task OnMessageReceived(ProcessMessageEventArgs args) { - using var activity = EventBusDiagnostics.StartProcess(args.Message, QueueName); - var messageId = args.Message.MessageId; - logger.LogInformation("Received order-confirmation-failed message {MessageId}", messageId); + using var messageScope = MessageProcessingScope.Begin(logger, args.Message, QueueName); + logger.MessageReceived(); try { var evt = args.Message.Body.ToObjectFromJson(); if (evt is null) { - logger.LogError("Message {MessageId} could not be deserialized. Dead-lettering.", messageId); + logger.MessageUndeserializable(); await args.DeadLetterMessageAsync(args.Message, deadLetterReason: "DeserializationFailed", deadLetterErrorDescription: "Body was not a valid OrderConfirmationFailedIntegrationEvent."); return; } - using var tenantScope = AmbientTenantContext.BeginScope(evt.TenantId); + messageScope.SetTenant(evt.TenantId); await using var scope = scopeFactory.CreateAsyncScope(); var context = scope.ServiceProvider.GetRequiredService(); @@ -85,7 +83,7 @@ await args.DeadLetterMessageAsync(args.Message, } catch (Exception ex) { - logger.LogError(ex, "Unhandled exception processing order-confirmation-failed {MessageId}. Abandoning.", messageId); + logger.MessageProcessingFailed(ex); await args.AbandonMessageAsync(args.Message); } } @@ -109,7 +107,7 @@ internal async Task ProcessConfirmationFailureAsync( if (payment is null) { // No capture to reverse — nothing to refund. Mark processed to avoid endless retry. - logger.LogError("No payment found for order {OrderId} on confirmation failure; cannot refund.", evt.OrderId); + logger.LogWarning("No payment found for order {OrderId} on confirmation failure; nothing to refund.", evt.OrderId); await inboxStore.MarkAsProcessedAsync(evt.Id, nameof(OrderConfirmationFailedIntegrationEvent), ct); return; } diff --git a/src/Services/Payment/OrderSphere.Payment.Worker/Workers/PaymentProcessor.cs b/src/Services/Payment/OrderSphere.Payment.Worker/Workers/PaymentProcessor.cs index af1f3ab9..bfe7a406 100644 --- a/src/Services/Payment/OrderSphere.Payment.Worker/Workers/PaymentProcessor.cs +++ b/src/Services/Payment/OrderSphere.Payment.Worker/Workers/PaymentProcessor.cs @@ -6,7 +6,6 @@ using OrderSphere.BuildingBlocks.Contracts.Events; using OrderSphere.BuildingBlocks.EventBus.AzureServiceBus; using OrderSphere.BuildingBlocks.EventBus.Inbox; -using OrderSphere.BuildingBlocks.Security; using OrderSphere.BuildingBlocks.StronglyTypedIds; using OrderSphere.Payment.Domain.Entities; using OrderSphere.Payment.Infrastructure.Persistence; @@ -35,7 +34,7 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) _processor.ProcessErrorAsync += OnError; await _processor.StartProcessingAsync(stoppingToken); - logger.LogInformation("PaymentProcessor started, listening on queue '{Queue}'.", QueueName); + logger.ProcessorStarted(nameof(PaymentProcessor), QueueName); try { @@ -45,29 +44,29 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) finally { await _processor.StopProcessingAsync(CancellationToken.None); - logger.LogInformation("PaymentProcessor stopped."); + logger.ProcessorStopped(nameof(PaymentProcessor)); } } private async Task OnMessageReceived(ProcessMessageEventArgs args) { - using var activity = EventBusDiagnostics.StartProcess(args.Message, QueueName); + using var messageScope = MessageProcessingScope.Begin(logger, args.Message, QueueName); var messageId = args.Message.MessageId; - logger.LogInformation("Received payment request message {MessageId}", messageId); + logger.MessageReceived(); try { var evt = args.Message.Body.ToObjectFromJson(); if (evt is null) { - logger.LogError("Message {MessageId} could not be deserialized. Dead-lettering.", messageId); + logger.MessageUndeserializable(); await args.DeadLetterMessageAsync(args.Message, deadLetterReason: "DeserializationFailed", deadLetterErrorDescription: "Body was not a valid PaymentRequestedIntegrationEvent."); return; } - using var tenantScope = AmbientTenantContext.BeginScope(evt.TenantId); + messageScope.SetTenant(evt.TenantId); await using var scope = scopeFactory.CreateAsyncScope(); var context = scope.ServiceProvider.GetRequiredService(); @@ -100,12 +99,12 @@ await args.DeadLetterMessageAsync(args.Message, await context.SaveChangesAsync(args.CancellationToken); await args.CompleteMessageAsync(args.Message); - logger.LogInformation("Payment message {MessageId} processed. OrderId: {OrderId}, Succeeded: {Succeeded}", + logger.LogInformation("Payment message processed. OrderId: {OrderId}, Succeeded: {Succeeded}", messageId, evt.OrderId, succeeded); } catch (Exception ex) { - logger.LogError(ex, "Unhandled exception processing payment message {MessageId}. Abandoning.", messageId); + logger.MessageProcessingFailed(ex); await args.AbandonMessageAsync(args.Message); } } diff --git a/src/Services/Payment/OrderSphere.Payment.Worker/Workers/RefundRequestedProcessor.cs b/src/Services/Payment/OrderSphere.Payment.Worker/Workers/RefundRequestedProcessor.cs index 52e9eb74..251c2c29 100644 --- a/src/Services/Payment/OrderSphere.Payment.Worker/Workers/RefundRequestedProcessor.cs +++ b/src/Services/Payment/OrderSphere.Payment.Worker/Workers/RefundRequestedProcessor.cs @@ -5,7 +5,6 @@ using OrderSphere.BuildingBlocks.Contracts.Events; using OrderSphere.BuildingBlocks.EventBus.AzureServiceBus; using OrderSphere.BuildingBlocks.EventBus.Inbox; -using OrderSphere.BuildingBlocks.Security; using OrderSphere.BuildingBlocks.StronglyTypedIds; using OrderSphere.Payment.Domain.Enums; using OrderSphere.Payment.Infrastructure.Persistence; @@ -40,7 +39,7 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) _processor.ProcessErrorAsync += OnError; await _processor.StartProcessingAsync(stoppingToken); - logger.LogInformation("RefundRequestedProcessor started, listening on queue '{Queue}'.", QueueName); + logger.ProcessorStarted(nameof(RefundRequestedProcessor), QueueName); try { @@ -50,29 +49,28 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) finally { await _processor.StopProcessingAsync(CancellationToken.None); - logger.LogInformation("RefundRequestedProcessor stopped."); + logger.ProcessorStopped(nameof(RefundRequestedProcessor)); } } private async Task OnMessageReceived(ProcessMessageEventArgs args) { - using var activity = EventBusDiagnostics.StartProcess(args.Message, QueueName); - var messageId = args.Message.MessageId; - logger.LogInformation("Received refund-requested message {MessageId}", messageId); + using var messageScope = MessageProcessingScope.Begin(logger, args.Message, QueueName); + logger.MessageReceived(); try { var evt = args.Message.Body.ToObjectFromJson(); if (evt is null) { - logger.LogError("Message {MessageId} could not be deserialized. Dead-lettering.", messageId); + logger.MessageUndeserializable(); await args.DeadLetterMessageAsync(args.Message, deadLetterReason: "DeserializationFailed", deadLetterErrorDescription: "Body was not a valid RefundRequestedIntegrationEvent."); return; } - using var tenantScope = AmbientTenantContext.BeginScope(evt.TenantId); + messageScope.SetTenant(evt.TenantId); await using var scope = scopeFactory.CreateAsyncScope(); var context = scope.ServiceProvider.GetRequiredService(); @@ -85,7 +83,7 @@ await args.DeadLetterMessageAsync(args.Message, } catch (Exception ex) { - logger.LogError(ex, "Unhandled exception processing refund-requested {MessageId}. Abandoning.", messageId); + logger.MessageProcessingFailed(ex); await args.AbandonMessageAsync(args.Message); } } @@ -109,7 +107,7 @@ internal async Task ProcessRefundRequestAsync( if (payment is null) { // No capture on record — nothing to refund. Mark processed to avoid endless retry. - logger.LogError("No payment found for order {OrderId} on refund request {ReturnRequestId}; cannot refund.", + logger.LogWarning("No payment found for order {OrderId} on refund request {ReturnRequestId}; nothing to refund.", evt.OrderId, evt.ReturnRequestId); await inboxStore.MarkAsProcessedAsync(evt.Id, nameof(RefundRequestedIntegrationEvent), ct); return; diff --git a/src/Services/Payment/OrderSphere.Payment.Worker/appsettings.Development.json b/src/Services/Payment/OrderSphere.Payment.Worker/appsettings.Development.json index e3eb69fd..567fcb47 100644 --- a/src/Services/Payment/OrderSphere.Payment.Worker/appsettings.Development.json +++ b/src/Services/Payment/OrderSphere.Payment.Worker/appsettings.Development.json @@ -1,5 +1,13 @@ { "Payment": { "BypassProviders": true + }, + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning", + "Microsoft.Hosting.Lifetime": "Information", + "OrderSphere": "Debug" + } } } diff --git a/src/Services/Payment/OrderSphere.Payment.Worker/appsettings.json b/src/Services/Payment/OrderSphere.Payment.Worker/appsettings.json index 027a2a4d..dd8ab8fe 100644 --- a/src/Services/Payment/OrderSphere.Payment.Worker/appsettings.json +++ b/src/Services/Payment/OrderSphere.Payment.Worker/appsettings.json @@ -2,10 +2,15 @@ "Logging": { "LogLevel": { "Default": "Information", - "Microsoft.Hosting.Lifetime": "Information" + "Microsoft.AspNetCore": "Warning", + "Microsoft.Hosting.Lifetime": "Information", + "OrderSphere": "Information" } }, "Payment": { "BypassProviders": false + }, + "OpenTelemetry": { + "TracesSampleRatio": 1.0 } } diff --git a/src/Services/UserProfile/OrderSphere.UserProfile.Api/appsettings.json b/src/Services/UserProfile/OrderSphere.UserProfile.Api/appsettings.json new file mode 100644 index 00000000..71df9b7a --- /dev/null +++ b/src/Services/UserProfile/OrderSphere.UserProfile.Api/appsettings.json @@ -0,0 +1,14 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning", + "Microsoft.Hosting.Lifetime": "Information", + "OrderSphere": "Information" + } + }, + "AllowedHosts": "*", + "OpenTelemetry": { + "TracesSampleRatio": 1.0 + } +} diff --git a/src/Services/Webhooks/OrderSphere.Webhooks.Api/appsettings.Development.json b/src/Services/Webhooks/OrderSphere.Webhooks.Api/appsettings.Development.json index 0c208ae9..339d732b 100644 --- a/src/Services/Webhooks/OrderSphere.Webhooks.Api/appsettings.Development.json +++ b/src/Services/Webhooks/OrderSphere.Webhooks.Api/appsettings.Development.json @@ -2,7 +2,9 @@ "Logging": { "LogLevel": { "Default": "Information", - "Microsoft.AspNetCore": "Warning" + "Microsoft.AspNetCore": "Warning", + "Microsoft.Hosting.Lifetime": "Information", + "OrderSphere": "Debug" } } } diff --git a/src/Services/Webhooks/OrderSphere.Webhooks.Api/appsettings.json b/src/Services/Webhooks/OrderSphere.Webhooks.Api/appsettings.json index 0c208ae9..a59cc85c 100644 --- a/src/Services/Webhooks/OrderSphere.Webhooks.Api/appsettings.json +++ b/src/Services/Webhooks/OrderSphere.Webhooks.Api/appsettings.json @@ -2,7 +2,12 @@ "Logging": { "LogLevel": { "Default": "Information", - "Microsoft.AspNetCore": "Warning" + "Microsoft.AspNetCore": "Warning", + "Microsoft.Hosting.Lifetime": "Information", + "OrderSphere": "Information" } + }, + "OpenTelemetry": { + "TracesSampleRatio": 1.0 } } diff --git a/src/Services/Webhooks/OrderSphere.Webhooks.Worker/Program.cs b/src/Services/Webhooks/OrderSphere.Webhooks.Worker/Program.cs index 528550f1..2ed9936c 100644 --- a/src/Services/Webhooks/OrderSphere.Webhooks.Worker/Program.cs +++ b/src/Services/Webhooks/OrderSphere.Webhooks.Worker/Program.cs @@ -46,6 +46,10 @@ app.UseAuthentication(); app.UseAuthorization(); +// DLQ admin endpoints are a real HTTP surface; log them like any other. +// Placed after auth so the log scope carries the authenticated user. +app.UseOrderSphereRequestLogging(); + // Admin DLQ surface — the gateway forwards /api/v1/admin/webhooks/dlq/** here. app.MapDlqAdminEndpoints("api/v1/admin/webhooks/dlq", "AdminPolicy"); diff --git a/src/Services/Webhooks/OrderSphere.Webhooks.Worker/Workers/WebhookEventProcessor.cs b/src/Services/Webhooks/OrderSphere.Webhooks.Worker/Workers/WebhookEventProcessor.cs index bcdf322a..a3b1d8aa 100644 --- a/src/Services/Webhooks/OrderSphere.Webhooks.Worker/Workers/WebhookEventProcessor.cs +++ b/src/Services/Webhooks/OrderSphere.Webhooks.Worker/Workers/WebhookEventProcessor.cs @@ -44,9 +44,9 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) private async Task ProcessMessageAsync(ProcessMessageEventArgs args) { - using var activity = EventBusDiagnostics.StartProcess(args.Message, QueueName); + using var messageScope = MessageProcessingScope.Begin(logger, args.Message, QueueName); var messageId = args.Message.MessageId; - logger.LogInformation("Received webhook event message {MessageId}.", messageId); + logger.MessageReceived(); using var scope = scopeFactory.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); @@ -60,7 +60,7 @@ private async Task ProcessMessageAsync(ProcessMessageEventArgs args) if (eventType is null) { logger.LogWarning( - "Message {MessageId} has an unknown or missing EventType. Dead-lettering.", messageId); + "Message has an unknown or missing EventType. Dead-lettering."); await args.DeadLetterMessageAsync(args.Message, deadLetterReason: "UnknownEventType", deadLetterErrorDescription: "Could not determine event type from message properties or body.", @@ -76,7 +76,7 @@ await args.DeadLetterMessageAsync(args.Message, catch (Exception ex) { logger.LogError(ex, - "Message {MessageId} body could not be deserialized. Dead-lettering.", messageId); + "Message body could not be deserialized. Dead-lettering."); await args.DeadLetterMessageAsync(args.Message, deadLetterReason: "DeserializationFailed", deadLetterErrorDescription: ex.Message, @@ -97,7 +97,7 @@ await args.DeadLetterMessageAsync(args.Message, if (webhookEventType is null) { logger.LogWarning( - "Message {MessageId} has event type '{EventType}' with no webhook mapping. Dead-lettering.", + "Message has event type '{EventType}' with no webhook mapping. Dead-lettering.", messageId, eventType); await args.DeadLetterMessageAsync(args.Message, deadLetterReason: "UnknownEventType", @@ -147,8 +147,7 @@ await args.DeadLetterMessageAsync(args.Message, } catch (Exception ex) { - logger.LogError(ex, - "Unhandled exception processing webhook event message {MessageId}. Abandoning.", messageId); + logger.MessageProcessingFailed(ex); await args.AbandonMessageAsync(args.Message, cancellationToken: args.CancellationToken); } } diff --git a/src/Services/Webhooks/OrderSphere.Webhooks.Worker/appsettings.Development.json b/src/Services/Webhooks/OrderSphere.Webhooks.Worker/appsettings.Development.json index b2dcdb67..339d732b 100644 --- a/src/Services/Webhooks/OrderSphere.Webhooks.Worker/appsettings.Development.json +++ b/src/Services/Webhooks/OrderSphere.Webhooks.Worker/appsettings.Development.json @@ -2,7 +2,9 @@ "Logging": { "LogLevel": { "Default": "Information", - "Microsoft.Hosting.Lifetime": "Information" + "Microsoft.AspNetCore": "Warning", + "Microsoft.Hosting.Lifetime": "Information", + "OrderSphere": "Debug" } } } diff --git a/src/Services/Webhooks/OrderSphere.Webhooks.Worker/appsettings.json b/src/Services/Webhooks/OrderSphere.Webhooks.Worker/appsettings.json index b2dcdb67..a59cc85c 100644 --- a/src/Services/Webhooks/OrderSphere.Webhooks.Worker/appsettings.json +++ b/src/Services/Webhooks/OrderSphere.Webhooks.Worker/appsettings.json @@ -2,7 +2,12 @@ "Logging": { "LogLevel": { "Default": "Information", - "Microsoft.Hosting.Lifetime": "Information" + "Microsoft.AspNetCore": "Warning", + "Microsoft.Hosting.Lifetime": "Information", + "OrderSphere": "Information" } + }, + "OpenTelemetry": { + "TracesSampleRatio": 1.0 } } diff --git a/tests/OrderSphere.Domain.Tests/OrderSphere.Domain.Tests.csproj b/tests/OrderSphere.Domain.Tests/OrderSphere.Domain.Tests.csproj index 3214f5be..63cfb403 100644 --- a/tests/OrderSphere.Domain.Tests/OrderSphere.Domain.Tests.csproj +++ b/tests/OrderSphere.Domain.Tests/OrderSphere.Domain.Tests.csproj @@ -15,6 +15,7 @@ + all diff --git a/tests/OrderSphere.Domain.Tests/Security/AmbientCorrelationContextTests.cs b/tests/OrderSphere.Domain.Tests/Security/AmbientCorrelationContextTests.cs new file mode 100644 index 00000000..ca27b5ed --- /dev/null +++ b/tests/OrderSphere.Domain.Tests/Security/AmbientCorrelationContextTests.cs @@ -0,0 +1,60 @@ +using FluentAssertions; +using OrderSphere.BuildingBlocks.Security; +using Xunit; + +namespace OrderSphere.Domain.Tests.Security; + +public sealed class AmbientCorrelationContextTests +{ + [Fact] + public void Ambient_is_null_outside_any_scope() + { + AmbientCorrelationContext.Ambient.Should().BeNull(); + } + + [Fact] + public void Scope_sets_and_restores_the_ambient_value() + { + using (AmbientCorrelationContext.BeginScope("outer")) + { + AmbientCorrelationContext.Ambient.Should().Be("outer"); + } + + AmbientCorrelationContext.Ambient.Should().BeNull(); + } + + [Fact] + public void Nested_scope_restores_the_previous_value_not_null() + { + // Matters for a worker handling a message that itself triggers a nested operation: + // the inner scope must not leak, and must not erase the outer one either. + using (AmbientCorrelationContext.BeginScope("outer")) + { + using (AmbientCorrelationContext.BeginScope("inner")) + { + AmbientCorrelationContext.Ambient.Should().Be("inner"); + } + + AmbientCorrelationContext.Ambient.Should().Be("outer"); + } + } + + [Fact] + public async Task Scope_does_not_leak_into_a_parallel_flow() + { + var observed = "unset"; + + var other = Task.Run(async () => + { + await Task.Yield(); + observed = AmbientCorrelationContext.Ambient ?? "none"; + }); + + using (AmbientCorrelationContext.BeginScope("mine")) + { + await other; + } + + observed.Should().Be("none"); + } +} diff --git a/tests/OrderSphere.EventBus.AzureServiceBus.Tests/MessageProcessingScopeTests.cs b/tests/OrderSphere.EventBus.AzureServiceBus.Tests/MessageProcessingScopeTests.cs new file mode 100644 index 00000000..56f77fba --- /dev/null +++ b/tests/OrderSphere.EventBus.AzureServiceBus.Tests/MessageProcessingScopeTests.cs @@ -0,0 +1,108 @@ +using System.Diagnostics; +using Azure.Messaging.ServiceBus; +using FluentAssertions; +using Microsoft.Extensions.Logging.Abstractions; +using OrderSphere.BuildingBlocks.EventBus.AzureServiceBus; +using OrderSphere.BuildingBlocks.Security; +using Xunit; + +namespace OrderSphere.EventBus.AzureServiceBus.Tests; + +public sealed class MessageProcessingScopeTests +{ + private static ServiceBusReceivedMessage Message( + string messageId = "m-1", + string? subject = "OrderPlacedIntegrationEvent", + IDictionary? properties = null) => + ServiceBusModelFactory.ServiceBusReceivedMessage( + messageId: messageId, + subject: subject, + properties: properties); + + [Fact] + public void Begin_opens_the_correlation_slot_from_the_message() + { + var message = Message(properties: new Dictionary { ["x-request-id"] = "corr-42" }); + + using (MessageProcessingScope.Begin(NullLogger.Instance, message, "orders")) + { + AmbientCorrelationContext.Ambient.Should().Be("corr-42"); + } + + AmbientCorrelationContext.Ambient.Should().BeNull(); + } + + [Fact] + public void Begin_falls_back_to_the_trace_id_when_no_correlation_property_is_present() + { + // Messages published through the outbox carry only traceparent; the trace id is the + // correlation id by construction (the gateway seeds X-Request-Id from it). + var traceId = ActivityTraceId.CreateRandom().ToString(); + var spanId = ActivitySpanId.CreateRandom().ToString(); + var message = Message(properties: new Dictionary + { + ["traceparent"] = $"00-{traceId}-{spanId}-01", + }); + + using (MessageProcessingScope.Begin(NullLogger.Instance, message, "orders")) + { + AmbientCorrelationContext.Ambient.Should().Be(traceId); + } + } + + [Fact] + public void Begin_falls_back_to_the_message_id_when_nothing_is_carried() + { + using (MessageProcessingScope.Begin(NullLogger.Instance, Message(messageId: "m-99"), "orders")) + { + AmbientCorrelationContext.Ambient.Should().Be("m-99"); + } + } + + [Fact] + public void SetTenant_opens_the_tenant_slot_and_disposal_restores_it() + { + var tenantId = Guid.NewGuid(); + + using (var scope = MessageProcessingScope.Begin(NullLogger.Instance, Message(), "orders")) + { + AmbientTenantContext.Ambient.Should().BeNull(); + scope.SetTenant(tenantId); + AmbientTenantContext.Ambient.Should().Be(tenantId); + } + + AmbientTenantContext.Ambient.Should().BeNull(); + } + + [Fact] + public void EventType_comes_from_the_subject_and_falls_back_to_the_application_property() + { + using (var fromSubject = MessageProcessingScope.Begin(NullLogger.Instance, Message(), "orders")) + { + fromSubject.EventType.Should().Be("OrderPlacedIntegrationEvent"); + } + + var message = Message(subject: null, properties: new Dictionary + { + ["EventType"] = "PaymentSucceededIntegrationEvent", + }); + + using (var fromProperty = MessageProcessingScope.Begin(NullLogger.Instance, message, "payments")) + { + fromProperty.EventType.Should().Be("PaymentSucceededIntegrationEvent"); + } + } + + [Fact] + public void Dispose_is_idempotent() + { + var scope = MessageProcessingScope.Begin(NullLogger.Instance, Message(), "orders"); + scope.SetTenant(Guid.NewGuid()); + + scope.Dispose(); + var act = scope.Dispose; + + act.Should().NotThrow(); + AmbientTenantContext.Ambient.Should().BeNull(); + } +} diff --git a/tests/OrderSphere.EventBus.AzureServiceBus.Tests/OrderSphere.EventBus.AzureServiceBus.Tests.csproj b/tests/OrderSphere.EventBus.AzureServiceBus.Tests/OrderSphere.EventBus.AzureServiceBus.Tests.csproj index e478e639..0113f5e1 100644 --- a/tests/OrderSphere.EventBus.AzureServiceBus.Tests/OrderSphere.EventBus.AzureServiceBus.Tests.csproj +++ b/tests/OrderSphere.EventBus.AzureServiceBus.Tests/OrderSphere.EventBus.AzureServiceBus.Tests.csproj @@ -17,6 +17,7 @@ + all diff --git a/tests/OrderSphere.IntegrationTests/Logging/LogEnrichmentTests.cs b/tests/OrderSphere.IntegrationTests/Logging/LogEnrichmentTests.cs new file mode 100644 index 00000000..e368b4ac --- /dev/null +++ b/tests/OrderSphere.IntegrationTests/Logging/LogEnrichmentTests.cs @@ -0,0 +1,78 @@ +using FluentAssertions; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Testing; +using OrderSphere.BuildingBlocks.Security; +using Xunit; + +namespace OrderSphere.IntegrationTests.Logging; + +/// +/// Verifies the enrichment wiring in AddServiceDefaults end-to-end: a log record written +/// anywhere in a service must carry the ambient tenant and correlation id without the call site +/// mentioning them. These run against a plain host (no HTTP), which is exactly the worker case — +/// the point being that worker records get the same fields as API records. +/// +public sealed class LogEnrichmentTests +{ + private static IHost BuildHost() + { + var builder = Host.CreateApplicationBuilder(); + builder.AddServiceDefaults(); + builder.Logging.AddFakeLogging(); + return builder.Build(); + } + + private static IReadOnlyDictionary TagsOf(FakeLogRecord record) => + record.StructuredState?.ToDictionary(kvp => kvp.Key, kvp => kvp.Value) + ?? new Dictionary(); + + [Fact] + public void Record_carries_ambient_tenant_and_correlation() + { + using var host = BuildHost(); + var logger = host.Services.GetRequiredService>(); + var tenantId = Guid.NewGuid(); + + using (AmbientTenantContext.BeginScope(tenantId)) + using (AmbientCorrelationContext.BeginScope("abc123")) + { + logger.LogInformation("Order {OrderId} processed", 42); + } + + var tags = TagsOf(host.Services.GetFakeLogCollector().GetSnapshot().Single()); + + tags.Should().Contain("tenant_id", tenantId.ToString()); + tags.Should().Contain("correlation_id", "abc123"); + // The call site's own template arguments survive alongside the enrichment. + tags.Should().Contain("OrderId", "42"); + } + + [Fact] + public void Record_omits_tenant_and_correlation_when_no_scope_is_open() + { + using var host = BuildHost(); + var logger = host.Services.GetRequiredService>(); + + logger.LogInformation("Startup complete"); + + var tags = TagsOf(host.Services.GetFakeLogCollector().GetSnapshot().Single()); + + tags.Should().NotContainKey("tenant_id"); + tags.Should().NotContainKey("correlation_id"); + } + + [Fact] + public void Static_enricher_stamps_build_version_on_every_record() + { + using var host = BuildHost(); + var logger = host.Services.GetRequiredService>(); + + logger.LogInformation("Anything"); + + TagsOf(host.Services.GetFakeLogCollector().GetSnapshot().Single()) + .Should().ContainKey("build_version") + .And.ContainKey("service_instance_id"); + } +} diff --git a/tests/OrderSphere.IntegrationTests/Logging/TracesSampleRatioTests.cs b/tests/OrderSphere.IntegrationTests/Logging/TracesSampleRatioTests.cs new file mode 100644 index 00000000..b9aed8e6 --- /dev/null +++ b/tests/OrderSphere.IntegrationTests/Logging/TracesSampleRatioTests.cs @@ -0,0 +1,62 @@ +using System.Globalization; +using FluentAssertions; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Hosting; +using Xunit; + +namespace OrderSphere.IntegrationTests.Logging; + +/// +/// The sampler ratio is read from configuration in ConfigureOpenTelemetry. Configuration values +/// are culture-invariant, so parsing them with the ambient culture is wrong: on a de-DE host +/// "1.0" parses as 10 and "0.1" as 1, both outside the sampler's [0,1] range, which throws at +/// startup. This was latent until the key was actually set in appsettings. +/// +public sealed class TracesSampleRatioTests +{ + private static IHost BuildHost(CultureInfo culture, string? ratio) + { + var previous = CultureInfo.CurrentCulture; + CultureInfo.CurrentCulture = culture; + try + { + var builder = Host.CreateApplicationBuilder(); + if (ratio is not null) + { + builder.Configuration.AddInMemoryCollection( + new Dictionary { ["OpenTelemetry:TracesSampleRatio"] = ratio }); + } + + builder.AddServiceDefaults(); + return builder.Build(); + } + finally + { + CultureInfo.CurrentCulture = previous; + } + } + + [Theory] + [InlineData("de-DE", "1.0")] + [InlineData("de-DE", "0.1")] + [InlineData("en-US", "0.1")] + [InlineData("fr-FR", "0.05")] + public void Ratio_is_parsed_culture_invariantly(string cultureName, string ratio) + { + var act = () => BuildHost(CultureInfo.GetCultureInfo(cultureName), ratio).Dispose(); + + act.Should().NotThrow(); + } + + [Theory] + [InlineData("2.5")] + [InlineData("-1")] + [InlineData("not-a-number")] + public void An_out_of_range_or_unparsable_ratio_does_not_crash_the_host(string ratio) + { + // A typo in configuration must degrade sampling, not take the service down at startup. + var act = () => BuildHost(CultureInfo.InvariantCulture, ratio).Dispose(); + + act.Should().NotThrow(); + } +} diff --git a/tests/OrderSphere.IntegrationTests/OrderSphere.IntegrationTests.csproj b/tests/OrderSphere.IntegrationTests/OrderSphere.IntegrationTests.csproj index df8bd7d7..18000d17 100644 --- a/tests/OrderSphere.IntegrationTests/OrderSphere.IntegrationTests.csproj +++ b/tests/OrderSphere.IntegrationTests/OrderSphere.IntegrationTests.csproj @@ -19,6 +19,7 @@ + diff --git a/tests/OrderSphere.Notification.Tests/Logging/LogRedactionTests.cs b/tests/OrderSphere.Notification.Tests/Logging/LogRedactionTests.cs new file mode 100644 index 00000000..57a04c21 --- /dev/null +++ b/tests/OrderSphere.Notification.Tests/Logging/LogRedactionTests.cs @@ -0,0 +1,74 @@ +using FluentAssertions; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Testing; +using Xunit; + +namespace OrderSphere.Notification.Tests.Logging; + +/// +/// Regression guard for the PII leak this overhaul closed: the Notification worker used to log +/// raw customer email addresses at Information. The classified [LoggerMessage] parameters must +/// never produce the address in the record — neither in a structured tag nor in the rendered +/// message. +/// +public sealed class LogRedactionTests +{ + private const string CustomerEmail = "anna.beispiel@example.com"; + + private static IHost BuildHost() + { + var builder = Host.CreateApplicationBuilder(); + builder.AddServiceDefaults(); + builder.Logging.AddFakeLogging(); + return builder.Build(); + } + + [Fact] + public void Classified_email_never_reaches_the_sink_in_plaintext() + { + using var host = BuildHost(); + var logger = host.Services.GetRequiredService>(); + + Worker.Log.OrderConfirmationEmailSent(logger, Guid.NewGuid(), CustomerEmail); + + var record = host.Services.GetFakeLogCollector().GetSnapshot().Single(); + + record.Message.Should().NotContain(CustomerEmail); + record.Message.Should().NotContain("example.com"); + record.StructuredState!.Select(kvp => kvp.Value) + .Should().NotContain(v => v != null && v.Contains("example.com")); + } + + [Fact] + public void Redaction_is_stable_so_records_for_one_customer_still_group() + { + using var host = BuildHost(); + var logger = host.Services.GetRequiredService>(); + + Worker.Log.OrderConfirmationEmailSent(logger, Guid.NewGuid(), CustomerEmail); + Worker.Log.OrderConfirmationEmailSent(logger, Guid.NewGuid(), CustomerEmail); + Worker.Log.OrderConfirmationEmailSent(logger, Guid.NewGuid(), "other@example.com"); + + var recipients = host.Services.GetFakeLogCollector().GetSnapshot() + .Select(r => r.StructuredState!.Single(kvp => kvp.Key == "recipient").Value) + .ToList(); + + recipients[0].Should().Be(recipients[1], "the same address must hash to the same value"); + recipients[2].Should().NotBe(recipients[0], "different addresses must stay distinguishable"); + } + + [Fact] + public void Unclassified_arguments_are_untouched() + { + using var host = BuildHost(); + var logger = host.Services.GetRequiredService>(); + var orderId = Guid.NewGuid(); + + Worker.Log.OrderConfirmationEmailSent(logger, orderId, CustomerEmail); + + host.Services.GetFakeLogCollector().GetSnapshot().Single() + .StructuredState!.Should().Contain(kvp => kvp.Key == "orderId" && kvp.Value == orderId.ToString()); + } +} diff --git a/tests/OrderSphere.Notification.Tests/OrderSphere.Notification.Tests.csproj b/tests/OrderSphere.Notification.Tests/OrderSphere.Notification.Tests.csproj index 891eed59..d451e9fd 100644 --- a/tests/OrderSphere.Notification.Tests/OrderSphere.Notification.Tests.csproj +++ b/tests/OrderSphere.Notification.Tests/OrderSphere.Notification.Tests.csproj @@ -1,5 +1,9 @@ + + + + false true @@ -12,6 +16,7 @@ + diff --git a/tests/OrderSphere.UserProfile.Tests/Behaviors/ValidationBehaviorTests.cs b/tests/OrderSphere.UserProfile.Tests/Behaviors/ValidationBehaviorTests.cs index d0b10dbb..24e119d7 100644 --- a/tests/OrderSphere.UserProfile.Tests/Behaviors/ValidationBehaviorTests.cs +++ b/tests/OrderSphere.UserProfile.Tests/Behaviors/ValidationBehaviorTests.cs @@ -1,6 +1,7 @@ using FluentAssertions; using FluentValidation; using MediatR; +using Microsoft.Extensions.Logging.Abstractions; using OrderSphere.BuildingBlocks.Behaviors; using OrderSphere.BuildingBlocks.Primitives; using Xunit; @@ -66,7 +67,7 @@ private sealed class CallTracker public async Task Handle_NoValidators_InvokesNext() { var tracker = new CallTracker(Result.Success()); - var behavior = new ValidationBehavior([]); + var behavior = new ValidationBehavior([], NullLogger>.Instance); await behavior.Handle(new VoidCommand("x"), tracker.Delegate, CancellationToken.None); @@ -78,7 +79,7 @@ public async Task Handle_NoValidators_InvokesNext() public async Task Handle_ValidationPasses_InvokesNext() { var tracker = new CallTracker(Result.Success()); - var behavior = new ValidationBehavior([new VoidCommandValidator()]); + var behavior = new ValidationBehavior([new VoidCommandValidator()], NullLogger>.Instance); await behavior.Handle(new VoidCommand("non-empty"), tracker.Delegate, CancellationToken.None); @@ -90,7 +91,7 @@ public async Task Handle_ValidationPasses_InvokesNext() public async Task Handle_ValidationFails_NonGenericResult_ReturnsFailureWithoutThrowing() { var tracker = new CallTracker(Result.Success()); - var behavior = new ValidationBehavior([new VoidCommandValidator()]); + var behavior = new ValidationBehavior([new VoidCommandValidator()], NullLogger>.Instance); var result = await behavior.Handle(new VoidCommand(""), tracker.Delegate, CancellationToken.None); @@ -101,7 +102,7 @@ public async Task Handle_ValidationFails_NonGenericResult_ReturnsFailureWithoutT [Fact] public async Task Handle_ValidationFails_NonGenericResult_ErrorCodeIsValidationInvalid() { - var behavior = new ValidationBehavior([new VoidCommandValidator()]); + var behavior = new ValidationBehavior([new VoidCommandValidator()], NullLogger>.Instance); RequestHandlerDelegate next = _ => Task.FromResult(Result.Success()); var result = await behavior.Handle(new VoidCommand(""), next, CancellationToken.None); @@ -114,7 +115,7 @@ public async Task Handle_ValidationFails_NonGenericResult_ErrorCodeIsValidationI public async Task Handle_ValidationFails_GenericResultT_ReturnsFailureWithoutThrowing() { var tracker = new CallTracker>(Result.Success("ok")); - var behavior = new ValidationBehavior>([new DtoCommandValidator()]); + var behavior = new ValidationBehavior>([new DtoCommandValidator()], NullLogger>>.Instance); var result = await behavior.Handle(new DtoCommand(""), tracker.Delegate, CancellationToken.None); @@ -125,7 +126,7 @@ public async Task Handle_ValidationFails_GenericResultT_ReturnsFailureWithoutThr [Fact] public async Task Handle_ValidationFails_GenericResultT_ErrorCodeIsValidationInvalid() { - var behavior = new ValidationBehavior>([new DtoCommandValidator()]); + var behavior = new ValidationBehavior>([new DtoCommandValidator()], NullLogger>>.Instance); RequestHandlerDelegate> next = _ => Task.FromResult(Result.Success("ok")); var result = await behavior.Handle(new DtoCommand(""), next, CancellationToken.None); @@ -136,7 +137,7 @@ public async Task Handle_ValidationFails_GenericResultT_ErrorCodeIsValidationInv [Fact] public async Task Handle_ValidationFails_GenericResultT_ValueAccessThrows() { - var behavior = new ValidationBehavior>([new DtoCommandValidator()]); + var behavior = new ValidationBehavior>([new DtoCommandValidator()], NullLogger>>.Instance); RequestHandlerDelegate> next = _ => Task.FromResult(Result.Success("ok")); var result = await behavior.Handle(new DtoCommand(""), next, CancellationToken.None); @@ -149,7 +150,7 @@ public async Task Handle_ValidationFails_GenericResultT_ValueAccessThrows() [Fact] public async Task Handle_ValidationFails_NonResultResponseType_ThrowsValidationException() { - var behavior = new ValidationBehavior([new PlainCommandValidator()]); + var behavior = new ValidationBehavior([new PlainCommandValidator()], NullLogger>.Instance); RequestHandlerDelegate next = _ => Task.FromResult("ok"); await behavior.Invoking(b => b.Handle(new PlainCommand(""), next, CancellationToken.None)) @@ -160,7 +161,7 @@ await behavior.Invoking(b => b.Handle(new PlainCommand(""), next, CancellationTo [Fact] public async Task Handle_ValidationFails_ErrorDescriptionContainsValidatorMessage() { - var behavior = new ValidationBehavior([new VoidCommandValidator()]); + var behavior = new ValidationBehavior([new VoidCommandValidator()], NullLogger>.Instance); RequestHandlerDelegate next = _ => Task.FromResult(Result.Success()); var result = await behavior.Handle(new VoidCommand(""), next, CancellationToken.None); From bdc115f639ec5cc168939e92cbe54832c7b8738a Mon Sep 17 00:00:00 2001 From: Moritz Waldau Date: Mon, 7 Sep 2026 23:36:12 +0200 Subject: [PATCH 2/2] Update logging --- .github/workflows/release-deploy.yml | 12 +- .mcp.json | 24 +- Directory.Packages.props | 5 + aspire.config.json | 5 + docs/logging.md | 182 ++++- docs/operations.md | 17 +- docs/seq/create-ordersphere-dashboard.ps1 | 172 +++++ .../Diagnostics/BackgroundOperationScope.cs | 62 ++ .../Security/AmbientTenantContext.cs | 27 +- .../Security/ISecurityAuditLogger.cs | 16 +- .../Dlq/DlqDepthMonitor.cs | 3 + .../EventBusDiagnostics.cs | 48 +- .../Outbox/OutboxCleanupService.cs | 3 + .../Outbox/OutboxDispatcher.cs | 14 +- .../Outbox/OutboxMessageConfiguration.cs | 2 + .../ProcessorLog.cs | 11 + .../IntegrationEvent.cs | 23 +- .../Outbox/OutboxMessage.cs | 54 ++ .../OrderSphere.ApiGateway/Program.cs | 25 +- .../OrderSphere.ApiGateway/appsettings.json | 7 + .../Auth/BackchannelLogoutEndpoint.cs | 18 +- .../OrderSphere.Bff/Auth/RedisTicketStore.cs | 9 +- .../Auth/RefreshTokenHandler.cs | 3 +- .../OrderSphere.Bff/Logging/BffLog.cs | 69 ++ src/Gateways/OrderSphere.Bff/Program.cs | 4 +- .../Workers/RealtimeNotificationProcessor.cs | 11 +- src/Hosting/OrderSphere.AppHost/AppHost.cs | 58 +- .../OrderSphere.AppHost.csproj | 1 + .../OidcAuthenticationExtensions.cs | 15 +- .../OrderSphere.ServiceDefaults/Extensions.cs | 15 + .../Logging/OrderSphereLogEnricher.cs | 29 +- .../RequestContextEnrichmentMiddleware.cs | 57 +- .../OrderSphere.ServiceDefaults.csproj | 6 +- .../SchedulingExtensions.cs | 40 +- .../Security/HttpContextTenantContext.cs | 20 +- .../Security/SecurityAuditLogger.cs | 10 +- .../Security/TenantClaimResolver.cs | 39 + .../OrderSphere.Advisory.Api/Program.cs | 8 + .../Workers/CustomerErasureProcessor.cs | 6 +- .../appsettings.Development.json | 10 + .../CatalogClient/GrpcCatalogClient.cs | 4 +- .../appsettings.Development.json | 10 + .../OrderingClient/HttpOrderingClient.cs | 6 +- .../Workers/CustomerErasureProcessor.cs | 6 +- .../Workers/InvoiceProcessor.cs | 6 +- .../appsettings.Development.json | 10 + .../Properties/launchSettings.json | 10 + .../Workers/InvoiceGeneratedProcessor.cs | 8 +- .../Workers/NotificationProcessor.cs | 8 +- .../appsettings.Development.json | 10 + .../appsettings.Development.json | 10 + .../Checkout/CheckoutCartCommandHandler.cs | 5 +- .../CatalogClient/HttpBasketClient.cs | 4 +- .../CatalogClient/HttpCatalogClient.cs | 18 +- ...7184512_AddOutboxCorrelationId.Designer.cs | 665 ++++++++++++++++++ .../20260907184512_AddOutboxCorrelationId.cs | 33 + .../OrderingDbContextModelSnapshot.cs | 6 +- .../Persistence/OrderingDbContext.cs | 8 +- .../Properties/launchSettings.json | 10 + .../Workers/CustomerErasureProcessor.cs | 6 +- .../Workers/OrderHistoryProjector.cs | 4 +- .../Workers/OrderProcessor.cs | 11 +- .../Workers/PaymentRefundProcessor.cs | 6 +- .../Workers/PaymentResultProcessor.cs | 6 +- .../appsettings.Development.json | 10 + .../appsettings.Development.json | 10 + ...7184525_AddOutboxCorrelationId.Designer.cs | 215 ++++++ .../20260907184525_AddOutboxCorrelationId.cs | 29 + .../PaymentDbContextModelSnapshot.cs | 6 +- .../Persistence/PaymentDbContext.cs | 8 +- .../Providers/StripePaymentProvider.cs | 7 +- .../Properties/launchSettings.json | 10 + .../Workers/CustomerErasureProcessor.cs | 6 +- .../OrderConfirmationFailedProcessor.cs | 6 +- .../Workers/PaymentProcessor.cs | 10 +- .../Workers/RefundRequestedProcessor.cs | 6 +- .../appsettings.Development.json | 10 + ...7184537_AddOutboxCorrelationId.Designer.cs | 327 +++++++++ .../20260907184537_AddOutboxCorrelationId.cs | 29 + .../UserProfileDbContextModelSnapshot.cs | 6 +- .../Persistence/UserProfileDbContext.cs | 8 +- .../Properties/launchSettings.json | 10 + .../Workers/WebhookDeliveryProcessor.cs | 25 +- .../Workers/WebhookEventProcessor.cs | 60 +- .../Security/AmbientTenantContextTests.cs | 72 ++ .../EventBusDiagnosticsTests.cs | 111 +++ .../MessageProcessingScopeTests.cs | 8 +- .../Api/RequestTenantScopeTests.cs | 112 +++ .../Api/TestAuthHandler.cs | 13 + .../Logging/LogEnrichmentTests.cs | 46 ++ .../Persistence/TenantBackfillHazardTests.cs | 82 +++ 91 files changed, 3000 insertions(+), 252 deletions(-) create mode 100644 aspire.config.json create mode 100644 docs/seq/create-ordersphere-dashboard.ps1 create mode 100644 src/BuildingBlocks/OrderSphere.BuildingBlocks.Domain/Diagnostics/BackgroundOperationScope.cs create mode 100644 src/Gateways/OrderSphere.Bff/Logging/BffLog.cs create mode 100644 src/Hosting/OrderSphere.ServiceDefaults/Security/TenantClaimResolver.cs create mode 100644 src/Services/Basket/OrderSphere.Basket.Api/appsettings.Development.json create mode 100644 src/Services/Catalog/OrderSphere.Catalog.Api/appsettings.Development.json create mode 100644 src/Services/Invoicing/OrderSphere.Invoicing.Api/appsettings.Development.json create mode 100644 src/Services/Notification/OrderSphere.Notification.Worker/Properties/launchSettings.json create mode 100644 src/Services/Notification/OrderSphere.Notification.Worker/appsettings.Development.json create mode 100644 src/Services/Ordering/OrderSphere.Ordering.Api/appsettings.Development.json create mode 100644 src/Services/Ordering/OrderSphere.Ordering.Infrastructure/Migrations/20260907184512_AddOutboxCorrelationId.Designer.cs create mode 100644 src/Services/Ordering/OrderSphere.Ordering.Infrastructure/Migrations/20260907184512_AddOutboxCorrelationId.cs create mode 100644 src/Services/Ordering/OrderSphere.Ordering.Worker/Properties/launchSettings.json create mode 100644 src/Services/Ordering/OrderSphere.Ordering.Worker/appsettings.Development.json create mode 100644 src/Services/Payment/OrderSphere.Payment.Api/appsettings.Development.json create mode 100644 src/Services/Payment/OrderSphere.Payment.Infrastructure/Migrations/20260907184525_AddOutboxCorrelationId.Designer.cs create mode 100644 src/Services/Payment/OrderSphere.Payment.Infrastructure/Migrations/20260907184525_AddOutboxCorrelationId.cs create mode 100644 src/Services/Payment/OrderSphere.Payment.Worker/Properties/launchSettings.json create mode 100644 src/Services/UserProfile/OrderSphere.UserProfile.Api/appsettings.Development.json create mode 100644 src/Services/UserProfile/OrderSphere.UserProfile.Infrastructure/Migrations/20260907184537_AddOutboxCorrelationId.Designer.cs create mode 100644 src/Services/UserProfile/OrderSphere.UserProfile.Infrastructure/Migrations/20260907184537_AddOutboxCorrelationId.cs create mode 100644 src/Services/Webhooks/OrderSphere.Webhooks.Worker/Properties/launchSettings.json create mode 100644 tests/OrderSphere.Domain.Tests/Security/AmbientTenantContextTests.cs create mode 100644 tests/OrderSphere.EventBus.AzureServiceBus.Tests/EventBusDiagnosticsTests.cs create mode 100644 tests/OrderSphere.IntegrationTests/Api/RequestTenantScopeTests.cs create mode 100644 tests/OrderSphere.Webhooks.Tests/Persistence/TenantBackfillHazardTests.cs diff --git a/.github/workflows/release-deploy.yml b/.github/workflows/release-deploy.yml index 1747b8fa..f8c298d9 100644 --- a/.github/workflows/release-deploy.yml +++ b/.github/workflows/release-deploy.yml @@ -151,14 +151,20 @@ jobs: ORDERING_WORKER_SECRET: ${{ secrets.ORDERING_WORKER_SECRET }} NOTIFICATION_WORKER_SECRET: ${{ secrets.NOTIFICATION_WORKER_SECRET }} PAYMENT_WORKER_SECRET: ${{ secrets.PAYMENT_WORKER_SECRET }} + LOGGING_REDACTION_HMAC_KEY: ${{ secrets.LOGGING_REDACTION_HMAC_KEY }} run: | azd env new "$AZURE_ENV_NAME" \ --location "$AZURE_LOCATION" \ --subscription "$AZURE_SUBSCRIPTION_ID" azd env set AZURE_RESOURCE_GROUP "$AZURE_RESOURCE_GROUP" - # Seed the infra parameters azd would otherwise prompt for. The 4 client + # Seed the infra parameters azd would otherwise prompt for. The client # secrets come from GitHub secrets (ephemeral, masked in logs); the two # non-secret parameters are fixed for DEV. Underscore key form matches azd. + # + # logging_redaction_hmac_key must stay STABLE across deployments of the same + # environment: rotating it re-hashes every classified log value, so records + # written before the rotation no longer group with records written after + # (see docs/logging.md § PII). Rotate deliberately, not per release. jq -n \ --arg oidc "https://ordersphere-dev.eu.auth0.com/" \ --arg bypass "true" \ @@ -166,13 +172,15 @@ jobs: --arg ordering "$ORDERING_WORKER_SECRET" \ --arg notification "$NOTIFICATION_WORKER_SECRET" \ --arg payment "$PAYMENT_WORKER_SECRET" \ + --arg hmac "$LOGGING_REDACTION_HMAC_KEY" \ '{infra:{parameters:{ oidc_authority:$oidc, payment_bypass_providers:$bypass, bff_client_secret:$bff, ordering_worker_secret:$ordering, notification_worker_secret:$notification, - payment_worker_secret:$payment + payment_worker_secret:$payment, + logging_redaction_hmac_key:$hmac }}}' > ".azure/$AZURE_ENV_NAME/config.json" - name: Provision infrastructure diff --git a/.mcp.json b/.mcp.json index 4c0ee1c7..8a5d875c 100644 --- a/.mcp.json +++ b/.mcp.json @@ -2,15 +2,31 @@ "mcpServers": { "aspire": { "command": "aspire", - "args": ["agent", "mcp"] + "args": [ + "agent", + "mcp" + ] }, "playwright": { "command": "npx", - "args": ["@playwright/mcp@latest"] + "args": [ + "@playwright/mcp@latest" + ] }, "postgres": { "command": "npx", - "args": ["-y", "@modelcontextprotocol/server-postgres", "${ORDERSPHERE_PG_READONLY}"] + "args": [ + "-y", + "@modelcontextprotocol/server-postgres", + "${ORDERSPHERE_PG_READONLY}" + ] + }, + "seq": { + "command": "seqcli", + "args": [ + "mcp", + "run" + ] } } -} +} \ No newline at end of file diff --git a/Directory.Packages.props b/Directory.Packages.props index 524bbd0c..1653aee5 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -54,6 +54,11 @@ + + + diff --git a/aspire.config.json b/aspire.config.json new file mode 100644 index 00000000..debb0b8a --- /dev/null +++ b/aspire.config.json @@ -0,0 +1,5 @@ +{ + "appHost": { + "path": "src/Hosting/OrderSphere.AppHost/OrderSphere.AppHost.csproj" + } +} \ No newline at end of file diff --git a/docs/logging.md b/docs/logging.md index c8373ff3..bf8050bd 100644 --- a/docs/logging.md +++ b/docs/logging.md @@ -7,11 +7,15 @@ Binding conventions for log output across all OrderSphere services. Companion to ## Pipeline `Microsoft.Extensions.Logging` with the OpenTelemetry logger provider. There is no Serilog and -none is planned: the OTel provider is already the single export path to the Aspire dashboard and -Azure Monitor, and it attaches `trace_id` / `span_id` to every record. +none is planned: the OTel provider is already the single export path to every sink — the Aspire +dashboard and Seq locally, Azure Monitor in production — and it attaches `trace_id` / `span_id` +to every record. Adding a sink means adding an exporter, not a second logging pipeline +(see [Viewing logs locally](#viewing-logs-locally)). Two extensions sit on top, both wired centrally in -`src/Hosting/OrderSphere.ServiceDefaults/Extensions.cs` and therefore active in all 22 hosts: +`src/Hosting/OrderSphere.ServiceDefaults/Extensions.cs` and therefore active in all 16 hosts that +call `AddServiceDefaults()` (the Blazor WASM client is not one of them — see +[Browser logs](#browser-logs)): | Concern | Package | Entry point | |---|---|---| @@ -32,15 +36,37 @@ Every record carries these without the call site doing anything: | `service.name`, `service.version`, `deployment.environment` | OTel `Resource` | all | | `trace_id`, `span_id` | OTel logger provider | all (when a trace is active) | | `service_instance_id`, `build_version` | `OrderSphereStaticLogEnricher` | all | -| `tenant_id` | `AmbientTenantContext` via `OrderSphereLogEnricher` | when a tenant scope is open | -| `correlation_id` | `AmbientCorrelationContext` via `OrderSphereLogEnricher` | requests and message loops | +| `tenant_id` | `AmbientTenantContext` via `OrderSphereLogEnricher` | requests with an `org_id` claim, and message loops | +| `correlation_id` | `AmbientCorrelationContext` via `OrderSphereLogEnricher` | requests, message loops and background loops | | `user_id` | `IHttpContextAccessor` (`sub` claim) via `OrderSphereLogEnricher` | authenticated HTTP requests | | `client_ip_hash` | `RequestContextEnrichmentMiddleware` | HTTP requests | | `message_id`, `event_type`, `queue` | `MessageProcessingScope` | Service Bus message loops | The enricher reads `AsyncLocal` slots rather than `HttpContext`. That is the whole reason worker records carry the same fields as API records: the message loop opens the ambient scopes and the -same singleton enricher picks them up, with no worker code aware of logging infrastructure. +same singleton enricher picks them up, with no worker code aware of logging infrastructure. The +one exception is records written after the ambient scopes have unwound but while the request is +still being handled — see *Unhandled exceptions correlate too* under Correlation. + +Three scopes fill those slots, one per kind of work: + +| Opened by | Covers | Tenant source | +|---|---|---| +| `RequestContextEnrichmentMiddleware` | HTTP requests, after authentication | `org_id` claim (ADR 0012) | +| `MessageProcessingScope` | Service Bus message loops | `TenantId` on the inbound event | +| `BackgroundOperationScope` | one iteration of a timer-driven loop | none — background work is cross-tenant | + +`tenant_id` is **absent**, not `Guid.Empty`, when a request carries no `org_id` (anonymous +traffic, or a deployment with Auth0 Organizations not enabled) and on background loops. An +all-zero GUID would be indistinguishable from a real single-organisation tenant, so the field is +omitted instead. `ITenantContext` still resolves `TenantId.Default` for persistence, so EF +stamping and the tenant query filter are unaffected by that choice. + +The request scope is not only a logging concern: `ITenantContext`, EF audit stamping, the tenant +query filter and `IntegrationEvent.TenantId`'s default all read the same slot. That is why a +command handler never assigns `TenantId` on an event it stages — the middleware's scope is open +for the whole of endpoint execution, so the default is already correct. An explicit assignment is +a smell. ### Naming @@ -57,19 +83,46 @@ One value, end to end: (`ApiGateway/Program.cs`). Client-visible id, `correlation_id` and `trace_id` are the same string. 2. `RequestContextEnrichmentMiddleware` opens `AmbientCorrelationContext` from that header and - echoes it on the response. + echoes it on the response. It also stashes the id on `HttpContext.Items`; see the note on + unhandled exceptions below. 3. `CorrelationPropagationHandler`, registered on `ConfigureHttpClientDefaults`, puts it on every outgoing service-to-service call. 4. `EventBusDiagnostics.Inject` writes it onto the Service Bus message as `x-request-id`. 5. `MessageProcessingScope` reads it back on the consuming side, falling back to the message's `traceparent` trace id and finally to the message id. -6. Across the outbox — where only `traceparent` is persisted on the row — - `EventBusDiagnostics.RestorePublishParent` reopens the correlation scope from the restored - trace id. Because step 1 seeds from the trace id, this is the same value; no outbox column and - no schema change were needed. +6. Across the outbox, the row persists the correlation id in its own `CorrelationId` column + alongside `TraceParent`, and `EventBusDiagnostics.RestorePublishParent` reopens the scope from + it. +7. Timer-driven work has nothing to inherit, so `BackgroundOperationScope` starts a fresh trace + per iteration and seeds the correlation id from it — the same shape as step 1. + +Step 6 used to derive the correlation id from the restored trace id instead, on the grounds that +step 1 seeds one from the other. That equality does not hold: the gateway honours a +client-supplied `X-Request-Id`, and step 5 falls back to the message id — after either, the +derivation silently substituted a different id at the outbox boundary and split the chain in two. +The column is nullable, and rows written before it existed still fall back to the trace id, which +is what they were correlated by. `IntegrationEvent.CorrelationId` is unrelated: it is a business idempotency key. Where it is -logged it is named `EventCorrelationId` to keep the two apart. +logged it is named `EventCorrelationId` to keep the two apart. `OutboxMessage.CorrelationId` is +the log-correlation id described above. + +Two consequences worth stating, because both were gaps until recently: + +- **A client may choose the correlation id.** It is caller-controlled input that ends up in a + structured log field, so it is length-capped when persisted. Do not render it as markup. +- **Background loops correlate too.** The outbox dispatcher's own failure records, the webhook + delivery loop, the scheduled jobs and the DLQ monitor each open a scope per iteration. Without + it, the records most wanted when a flow has stalled were the ones a `correlation_id` query could + not return. +- **Unhandled exceptions correlate too.** `UseExceptionHandler()` must sit outside + `UseOrderSphereRequestLogging()` — it can only catch what runs inside it — so an exception has + already unwound past the enrichment middleware, disposing both ambient scopes, by the time + `ExceptionHandlerMiddleware` logs it. The `HttpContext` outlives the scopes and is the same + instance in both, so `OrderSphereLogEnricher` falls back to `HttpContext.Items` for records + written after the unwind. Without that fallback the unhandled 500 — the record an operator + reaches for first — was the single record on the request path with no `correlation_id`. + `tests/OrderSphere.IntegrationTests/Logging/LogEnrichmentTests.cs` pins this. ## Levels @@ -126,7 +179,20 @@ them. The HMAC key comes from `Logging:Redaction:HmacKey`. It is per-deployment: two environments produce unrelated hashes. Without configuration a process-lifetime random key is generated — -redaction still holds, only cross-restart correlation is lost. +redaction still holds, only cross-restart correlation is lost, and each replica hashes the same +input differently, so the grouping the HMAC redactor was chosen for stops working across a scaled +deployment. + +The AppHost injects it into every project as the `logging-redaction-hmac-key` parameter. Deployed +environments take the value from `infra.parameters` in `.github/workflows/release-deploy.yml`; +locally it comes from user-secrets on the AppHost, like every other secret parameter (see +*Secret rotation* in +[architecture.md](architecture.md#internal-service-to-service-authentication)). Set it once per +clone — any stable value will do, the point is only that it does not change between restarts: + +```bash +dotnet user-secrets set "Parameters:logging-redaction-hmac-key" "$(openssl rand -base64 32)" --project src/Hosting/OrderSphere.AppHost +``` Two identifiers stay readable by design: @@ -197,6 +263,96 @@ Both matter: configuration is culture-invariant, but a plain `double.TryParse` u culture, where `"1.0"` on a de-DE machine parses as `10` and crashes the sampler at startup. Lower the ratio (0.1–0.2) in production to control cost. +## Viewing logs locally + +Two sinks receive every record in local development. Both are fed from the same OpenTelemetry +pipeline; adding Seq did not change or replace the dashboard export. + +| Sink | Wiring | Use it for | +|---|---|---| +| Aspire dashboard | `OTEL_EXPORTER_OTLP_ENDPOINT`, injected by the AppHost | resource state, metrics, a quick look at one service | +| Seq (`http://localhost:`) | `AddSeq` in the AppHost, `AddSeqEndpoint` in ServiceDefaults | querying and filtering logs and traces | + +The dashboard has no query language and discards its data when the AppHost restarts. Seq indexes +every property, including the enrichment fields, and `WithDataVolume()` keeps the data across +restarts. Reach it from the resource list in the dashboard. + +Because the enrichment tags are real log-record attributes rather than text in the message, they +are directly queryable: + +``` +correlation_id = '0af7651916cd43dd8448eb211c80319c' +tenant_id = '...' and @Level in ['Warning', 'Error'] +event_type like 'Order%' and @Exception is not null +queue = 'payment-requested' and @Level = 'Error' +``` + +The first of those is the point of the correlation chain above: one expression returns the log +records of a whole checkout across the gateway, the APIs, the outbox and the workers. + +Two names in the field table above do **not** work verbatim as Seq filters, because Seq separates +OTLP resource attributes and trace identifiers from the event's own properties. Both forms fail by +returning zero rows rather than an error, which is the failure mode worth knowing about: + +| Field table name | In a Seq filter | +|---|---| +| `service.name`, `service.version`, `deployment.environment` | `@Resource.service.name`, and so on | +| `trace_id`, `span_id` | `TraceId`, `SpanId` | + +Everything the enrichers add — `correlation_id`, `tenant_id`, `user_id`, `client_ip_hash`, +`build_version`, `service_instance_id` and the `MessageProcessingScope` fields — is a plain event +property and is queried under exactly the name in the table. + +Wiring notes: + +- The Seq resource is added in **run mode only**. Production telemetry goes to Application + Insights; a developer-tool container has no place in the published manifest. +- `AddSeqEndpoint` registers an *additional* OTLP exporter (logs and traces) rather than + replacing `UseOtlpExporter()`. It activates only when the `seq` connection string is present, + so tests and production are unaffected. +- Its health check is switched off deliberately. A developer-tooling sink must never be able to + report a service as unready and stall the Aspire `WaitFor` chains. +- Seq takes logs and traces over OTLP, **not metrics** — those stay in the Aspire dashboard. + +### Querying Seq from an agent + +Seq 2026.1 ships a first-party MCP server, delivered through the `seqcli` client rather than +built into the server. It gives an agent read access to the same queries a developer would run. + +```bash +dotnet tool install --global seqcli +seqcli mcp install --agent +``` + +It reads `SEQCLI_CONNECTION_SERVERURL` and `SEQCLI_CONNECTION_APIKEY`. The local Seq container +runs with `SEQ_FIRSTRUN_NOAUTHENTICATION`, so no API key is needed against it; a hosted instance +needs one with Read permission. `seqcli mcp install --help` lists the supported agents. + +The AppHost pins the image to `2026.1` for this reason — Aspire 13.5.3 still defaults to +`datalust/seq:2025.2`, which predates the MCP release. + +### OrderSphere dashboard + +[`docs/seq/create-ordersphere-dashboard.ps1`](seq/create-ordersphere-dashboard.ps1) builds an +"OrderSphere" dashboard covering the fields above: request/error/warning/exception counts, +events and warnings by service, Service Bus queue and integration-event volume, tenant and +correlation-chain breakdowns, and a live feed of recent warnings and errors. Seq's data volume +persists it across AppHost restarts, so this is a one-time setup per environment (or after a +volume reset): + +```powershell +./docs/seq/create-ordersphere-dashboard.ps1 -ServerUrl http://localhost: +``` + +The port comes from the `seq` resource's http endpoint in the Aspire dashboard. Re-running the +script updates the existing dashboard in place rather than creating a duplicate. Every chart +query is a plain Seq query and can be run standalone with `seqcli query -q "..."` — see the +script's comments for the two rough edges this ran into: `ChartQuery.SignalExpression` 500s on +`POST /api/dashboards/` on this Seq build (worked around by inlining the built-in signals' +filter text instead), and a nested OTel resource attribute like `service.name` must be grouped +by as `@Resource.service.name` (dot path) — `@ra['service.name']` parses but silently returns +null. + ## Browser logs Blazor WASM logs go to the browser console only and are **not exported**. Aspire's Blazor hosting diff --git a/docs/operations.md b/docs/operations.md index db0a5e48..17e9f251 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -335,16 +335,23 @@ customMetrics ### Dead-letter admin: inspection and replay Each message-consuming host (Ordering.Worker, Payment.Worker, Notification.Worker, Webhooks.Worker, -Invoicing.Api) exposes an admin-protected dead-letter surface for the queues it owns, fronted by the -API Gateway under `/api/v1/admin/{slug}/dlq`: +Invoicing.Api, Advisory.Api) exposes an admin-protected dead-letter surface for the queues it owns, +fronted by the API Gateway under `/api/v1/admin/{slug}/dlq`. The owned queues are the arguments to +each host's `AddDlqAdmin(...)` call: | Slug | Host | Owned queues | |---|---|---| -| `ordering` | ordersphere-ordering-worker | orders, payment-results, payment-refunds, order-history | -| `payment` | ordersphere-payment-worker | payment-requests, order-confirmation-failed, refund-requested | +| `ordering` | ordersphere-ordering-worker | orders, payment-results, payment-refunds, order-history, erasure-ordering | +| `payment` | ordersphere-payment-worker | payment-requests, order-confirmation-failed, refund-requested, erasure-payment | | `notification` | ordersphere-notification-worker | notification-orders, invoice-ready | | `webhooks` | ordersphere-webhooks-worker | webhook-events | -| `invoicing` | ordersphere-invoicing | invoice-generation | +| `invoicing` | ordersphere-invoicing | invoice-generation, erasure-invoicing | +| `advisory` | ordersphere-advisory | erasure-advisory | + +The `erasure-*` queues are the GDPR erasure fan-out (D1): `UserProfile` stages one +`CustomerErasureRequestedIntegrationEvent`, and each PII-holding service consumes it from its own +queue. A message dead-lettered there means one service did not complete an erasure request, so it +is the set to check first when an erasure is reported as incomplete. Endpoints (all require a bearer token with the `admin` role): diff --git a/docs/seq/create-ordersphere-dashboard.ps1 b/docs/seq/create-ordersphere-dashboard.ps1 new file mode 100644 index 00000000..e5500265 --- /dev/null +++ b/docs/seq/create-ordersphere-dashboard.ps1 @@ -0,0 +1,172 @@ +<# +.SYNOPSIS + Creates (or updates) the "OrderSphere" Seq dashboard described in docs/logging.md. + +.DESCRIPTION + Builds every chart from a plain-text Seq query via the (undocumented but stable) + `api/dashboards/query/template` endpoint, which parses "select ... from stream where ... + group by ... order by ... limit ..." into the structured ChartQuery JSON the dashboard + entity needs - this avoids hand-writing that JSON and keeps every query readable and + independently testable with `seqcli query -q "..."`. + + Idempotent: if a dashboard titled "OrderSphere" already exists it is replaced in place + (same id, next version) instead of creating a duplicate. + +.PARAMETER ServerUrl + Base URL of the Seq server, e.g. http://localhost:51238. Find it from the Aspire dashboard's + resource list (the "seq" resource's http endpoint) - Aspire assigns the port dynamically per + run. Defaults to the seqcli connection profile's serverUrl if not given. + +.EXAMPLE + ./create-ordersphere-dashboard.ps1 -ServerUrl http://localhost:51238 +#> +param( + [string]$ServerUrl +) + +$ErrorActionPreference = "Stop" + +if (-not $ServerUrl) { + $seqCliConfigPath = Join-Path $env:USERPROFILE "SeqCli.json" + if (Test-Path $seqCliConfigPath) { + $ServerUrl = (Get-Content $seqCliConfigPath -Raw | ConvertFrom-Json).connection.serverUrl + } + if (-not $ServerUrl) { + throw "No -ServerUrl given and no seqcli connection profile found. Pass -ServerUrl explicitly (see the 'seq' resource's http endpoint in the Aspire dashboard)." + } +} +$server = $ServerUrl.TrimEnd('/') + +# Exact filter text of the built-in signals (GET /api/signals/), inlined because setting +# ChartQuery.SignalExpression via POST /api/dashboards/ returns a 500 on Seq 2026.1/17114 - +# observed and worked around, not investigated further upstream. +$ERR_FILTER = "@Level in ['f', 'fa', 'fat', 'ftl', 'fata', 'fatl', 'fatal', 'c', 'cr', 'cri', 'crt', 'crit', 'critical', 'alert', 'emerg', 'panic', 'e', 'er', 'err', 'eror', 'erro', 'error'] ci" +$WARN_FILTER = "@Level in ['w', 'wa', 'war', 'wrn', 'warn', 'warning'] ci" +$EXC_FILTER = "@Exception is not null" +$LOGS_FILTER = "not(has(@Start))" + +function New-ChartQuery { + param( + [string]$Query, + [string]$Type, + [string]$Palette = "Default", + [bool]$FillToZero = $false, + [bool]$BarOverlaySum = $false + ) + $uri = "$server/api/dashboards/query/template?q=" + [System.Uri]::EscapeDataString($Query) + $cq = Invoke-RestMethod -Uri $uri -Method Get + + $cq.DisplayStyle.Type = $Type + $cq.DisplayStyle.LineFillToZeroY = $FillToZero + $cq.DisplayStyle.BarOverlaySum = $BarOverlaySum + $cq.DisplayStyle.Palette = $Palette + return $cq +} + +function New-Chart { + param( + [string]$Title, + [string]$Description, + [array]$Queries, + [int]$Width, + [int]$Height + ) + return [ordered]@{ + Id = $null + Title = $Title + Description = $Description + SignalExpression = $null + Queries = $Queries + DisplayStyle = [ordered]@{ WidthColumns = $Width; HeightRows = $Height } + } +} + +$charts = @() + +# --- Row A: KPI strip ---------------------------------------------------- +$charts += New-Chart -Title "Total Events" -Description "All log records and spans in the selected window." -Width 2 -Height 1 -Queries @( + (New-ChartQuery -Query "select count(*) as count from stream" -Type "Value") +) +$charts += New-Chart -Title "Errors" -Description "Error, Critical or Fatal level records (built-in Errors signal filter)." -Width 2 -Height 1 -Queries @( + (New-ChartQuery -Query "select count(*) as count from stream where $ERR_FILTER" -Type "Value") +) +$charts += New-Chart -Title "Warnings" -Description "Includes every Result failure - the normal level for `"not found`", `"declined`"." -Width 2 -Height 1 -Queries @( + (New-ChartQuery -Query "select count(*) as count from stream where $WARN_FILTER" -Type "Value") +) +$charts += New-Chart -Title "Exceptions" -Description "Records carrying a captured exception." -Width 2 -Height 1 -Queries @( + (New-ChartQuery -Query "select count(*) as count from stream where $EXC_FILTER" -Type "Value") +) +$charts += New-Chart -Title "Active Services" -Description "Distinct service.name values reporting in the window." -Width 2 -Height 1 -Queries @( + (New-ChartQuery -Query "select count(distinct(@Resource.service.name)) as count from stream" -Type "Value") +) +$charts += New-Chart -Title "Distinct Traces" -Description "Distinct W3C trace ids - one per request/message flow." -Width 2 -Height 1 -Queries @( + (New-ChartQuery -Query "select count(distinct(@TraceId)) as count from stream" -Type "Value") +) + +# --- Row B: trends --------------------------------------------------------- +$charts += New-Chart -Title "Events Over Time by Level" -Description "Log volume over time, split by level." -Width 8 -Height 2 -Queries @( + (New-ChartQuery -Query "select count(*) as count from stream group by @Level" -Type "Line" -FillToZero $false) +) +$charts += New-Chart -Title "Errors & Exceptions Over Time" -Description "Same rule as the built-in Overview dashboard: any exception, or an Error/Fatal/Critical level record." -Width 4 -Height 2 -Queries @( + (New-ChartQuery -Query "select count(*) as count from stream where $EXC_FILTER or $ERR_FILTER" -Type "Bar" -Palette "Reds" -BarOverlaySum $true) +) + +# --- Row C: per-service breakdown ------------------------------------------ +$charts += New-Chart -Title "Events by Service" -Description "Volume per OrderSphere service (service.name resource attribute)." -Width 6 -Height 2 -Queries @( + (New-ChartQuery -Query "select count(*) as count from stream group by @Resource.service.name order by count desc" -Type "Bar") +) +$charts += New-Chart -Title "Warnings by Service" -Description "Where the expected-failure volume (Result failures, declined operations) is concentrated." -Width 6 -Height 2 -Queries @( + (New-ChartQuery -Query "select count(*) as count from stream where $WARN_FILTER group by @Resource.service.name order by count desc" -Type "Bar") +) + +# --- Row D: Service Bus / workers ------------------------------------------- +$charts += New-Chart -Title "Messages by Queue" -Description "Service Bus queue activity from MessageProcessingScope-tagged records." -Width 6 -Height 2 -Queries @( + (New-ChartQuery -Query "select count(*) as count from stream where queue is not null group by queue order by count desc" -Type "Bar") +) +$charts += New-Chart -Title "Integration Events Processed" -Description "Per-event-type volume for records inside an actual message-processing scope (message_id set). Empty until Service Bus traffic flows." -Width 6 -Height 2 -Queries @( + (New-ChartQuery -Query "select count(*) as count from stream where message_id is not null group by event_type order by count desc" -Type "Bar") +) + +# --- Row E: multi-tenancy and correlation ----------------------------------- +$charts += New-Chart -Title "Events by Tenant" -Description "Log volume per tenant_id. Empty until an authenticated, tenant-scoped request runs." -Width 6 -Height 2 -Queries @( + (New-ChartQuery -Query "select count(*) as count from stream where tenant_id is not null group by tenant_id order by count desc limit 20" -Type "Table") +) +$charts += New-Chart -Title "Top Correlation Chains" -Description "Requests/flows producing the most log records - a chatty or looping request stands out here." -Width 6 -Height 2 -Queries @( + (New-ChartQuery -Query "select count(*) as count from stream where correlation_id is not null group by correlation_id order by count desc limit 20" -Type "Table") +) + +# --- Row F: diagnostics ------------------------------------------------------ +$charts += New-Chart -Title "Top Message Templates" -Description "Noisiest log statements: spans excluded (Logs-signal rule), Azure Service Bus SDK receive-loop/link-management chatter filtered out." -Width 12 -Height 2 -Queries @( + (New-ChartQuery -Query "select count(*) as count from stream where $LOGS_FILTER and @MessageTemplate not like '%ReceiveBatchAsync%' and @MessageTemplate not like '%MessagePeekAsync%' and @MessageTemplate not like '%anagement link%' ci group by @MessageTemplate order by count desc limit 15" -Type "Table") +) +$charts += New-Chart -Title "Recent Warnings & Errors" -Description "Live feed - the last 20 Warning/Error/Fatal records across every service." -Width 12 -Height 2 -Queries @( + (New-ChartQuery -Query "select @Timestamp, @Level, @Resource.service.name, @MessageTemplate from stream where $WARN_FILTER or $ERR_FILTER order by @Timestamp desc limit 20" -Type "Table") +) + +# Idempotent: replace the existing "OrderSphere" dashboard in place rather than duplicating it. +# The list endpoint's Links.Self carries the current ?version=N, reused verbatim for the PUT - +# the server bumps the version itself. PUT requires the body's Id to match the target resource, +# hence setting it conditionally below rather than in the object literal (POST must NOT send one). +$dashboardList = Invoke-RestMethod -Uri "$server/api/dashboards?shared=true" -Method Get +$existing = $dashboardList | Where-Object { $_.Title -eq "OrderSphere" } | Select-Object -First 1 + +$dashboard = [ordered]@{ + Id = if ($existing) { $existing.Id } else { $null } + OwnerId = $null + Title = "OrderSphere" + IsProtected = $false + SignalExpression = $null + Charts = $charts +} + +$json = $dashboard | ConvertTo-Json -Depth 12 + +if ($existing) { + $result = Invoke-RestMethod -Uri "$server/$($existing.Links.Self)" -Method Put -Body $json -ContentType "application/json" + Write-Output "Updated existing dashboard: $($result.Id)" +} else { + $result = Invoke-RestMethod -Uri "$server/api/dashboards/" -Method Post -Body $json -ContentType "application/json" + Write-Output "Created dashboard: $($result.Id)" +} + +Write-Output "Open it at: $server/#/dashboards/$($result.Id)" diff --git a/src/BuildingBlocks/OrderSphere.BuildingBlocks.Domain/Diagnostics/BackgroundOperationScope.cs b/src/BuildingBlocks/OrderSphere.BuildingBlocks.Domain/Diagnostics/BackgroundOperationScope.cs new file mode 100644 index 00000000..16007a66 --- /dev/null +++ b/src/BuildingBlocks/OrderSphere.BuildingBlocks.Domain/Diagnostics/BackgroundOperationScope.cs @@ -0,0 +1,62 @@ +using System.Diagnostics; +using OrderSphere.BuildingBlocks.Security; + +namespace OrderSphere.BuildingBlocks.Diagnostics; + +/// +/// Gives one iteration of a timer-driven loop its own trace and log-correlation id. +/// +/// Timer-driven work has no inbound request or message to inherit context from, so without this +/// its records carry no trace_id, no correlation_id and no way to tell one +/// iteration from the next. That is the gap this closes: the outbox dispatcher's own failure +/// records, the webhook delivery loop, the scheduled jobs and the DLQ monitor are exactly the +/// records an operator reaches for when a flow has stalled, and they were the ones a +/// correlation_id query could not return. +/// +/// +/// The correlation id is the new trace's id, matching how the API Gateway seeds +/// X-Request-Id, so background records read the same way as request records. This starts a +/// fresh root trace on purpose — an iteration is its own unit of work, not a continuation of +/// whatever produced the rows it happens to pick up. Per-item context (an outbox row's originating +/// trace, a message's tenant) is restored inside the iteration and nests under this scope. +/// +/// +public static class BackgroundOperationScope +{ + /// + /// ActivitySource name. Registered in ServiceDefaults via tracing.AddSource(...); + /// without that registration + /// returns null and only the correlation id survives. + /// + public const string SourceName = "OrderSphere.Background"; + + private static readonly ActivitySource Source = new(SourceName); + + /// + /// Opens the scope for one iteration. Dispose at the end of the iteration so the next one + /// gets a fresh id rather than inheriting this one. + /// + /// + /// Span name, and the unit of work being started — e.g. "outbox-dispatch". Use a + /// constant: it becomes a span name and must not carry per-iteration values. + /// + public static IDisposable Begin(string operationName) + { + var activity = Source.StartActivity(operationName, ActivityKind.Internal); + + // Fall back to a fresh id when nothing is listening (source not registered, or sampled + // out) so records still group per iteration even with no trace to hang them on. + var correlationId = activity?.TraceId.ToString() ?? Guid.NewGuid().ToString("N"); + + return new Scope(activity, AmbientCorrelationContext.BeginScope(correlationId)); + } + + private sealed class Scope(Activity? activity, IDisposable correlationScope) : IDisposable + { + public void Dispose() + { + correlationScope.Dispose(); + activity?.Dispose(); + } + } +} diff --git a/src/BuildingBlocks/OrderSphere.BuildingBlocks.Domain/Security/AmbientTenantContext.cs b/src/BuildingBlocks/OrderSphere.BuildingBlocks.Domain/Security/AmbientTenantContext.cs index 8b33b0b9..1edffede 100644 --- a/src/BuildingBlocks/OrderSphere.BuildingBlocks.Domain/Security/AmbientTenantContext.cs +++ b/src/BuildingBlocks/OrderSphere.BuildingBlocks.Domain/Security/AmbientTenantContext.cs @@ -1,14 +1,27 @@ namespace OrderSphere.BuildingBlocks.Security; /// -/// Ambient tenant slot for worker/background processes, which have no -/// (ADR 0012). The consuming message loop must -/// call with the TenantId carried on the integration event being -/// processed before invoking any persistence code; disposing the scope restores the previous value -/// so it never leaks onto an unrelated message on the same thread/continuation. +/// Ambient tenant slot for the whole system (ADR 0012). Two callers open it, and between them +/// they cover every path on which tenant-scoped data is read or written: +/// +/// RequestContextEnrichmentMiddleware (ServiceDefaults) opens it per HTTP request +/// from the Auth0 org_id claim, after authentication and for the whole of endpoint +/// execution. +/// MessageProcessingScope.SetTenant opens it per Service Bus message from the +/// TenantId carried on the integration event, before any persistence code runs. +/// +/// Disposing a scope restores the previous value, so it never leaks onto an unrelated request or +/// message on the same thread/continuation. +/// +/// Because the slot is , work that severs the execution context — +/// Task.Run, Task.Factory.StartNew, ExecutionContext.SuppressFlow — silently +/// drops the tenant back to TenantId.Default. Do not start detached work between an +/// endpoint and the code that persists or publishes. +/// /// is read by the shared ITenantContext implementation (see -/// HttpContextTenantContext in ServiceDefaults) in preference to any HTTP claim, so the same -/// registration works for both API requests and worker message loops. +/// HttpContextTenantContext in ServiceDefaults) in preference to any HTTP claim, and by +/// IntegrationEvent.TenantId, so one scope covers logging, EF stamping, the tenant query +/// filter and event construction alike. /// public static class AmbientTenantContext { diff --git a/src/BuildingBlocks/OrderSphere.BuildingBlocks.Domain/Security/ISecurityAuditLogger.cs b/src/BuildingBlocks/OrderSphere.BuildingBlocks.Domain/Security/ISecurityAuditLogger.cs index c450d2cd..d956e7d9 100644 --- a/src/BuildingBlocks/OrderSphere.BuildingBlocks.Domain/Security/ISecurityAuditLogger.cs +++ b/src/BuildingBlocks/OrderSphere.BuildingBlocks.Domain/Security/ISecurityAuditLogger.cs @@ -16,12 +16,26 @@ public interface ISecurityAuditLogger /// All fields except are optional so callers include /// only the context that is available at the call site. /// +/// HTTP method of the request that triggered the event, when it +/// originated from one. Its own field rather than part of so an +/// investigation can filter on it. +/// Route template or path of the triggering request. Carries no query +/// string: query values are caller-supplied and may contain personal data. +/// +/// Short, OrderSphere-authored description of the occurrence. It must be a value chosen from the +/// call site's own vocabulary — never request input, never an exception message, never +/// user-supplied text. Exception detail belongs on the accompanying +/// logger.LogWarning(ex, ...) call, which carries the type and stack trace; request context +/// belongs in and . +/// public sealed record SecurityAuditEvent( SecurityAuditEventType Type, string? UserId = null, string? SessionId = null, string? IpAddress = null, - string? Details = null) + string? Details = null, + string? RequestMethod = null, + string? RequestPath = null) { /// UTC timestamp of the event. Defaults to now if not supplied. public DateTimeOffset OccurredAt { get; init; } = DateTimeOffset.UtcNow; diff --git a/src/BuildingBlocks/OrderSphere.BuildingBlocks.EventBus.AzureServiceBus/Dlq/DlqDepthMonitor.cs b/src/BuildingBlocks/OrderSphere.BuildingBlocks.EventBus.AzureServiceBus/Dlq/DlqDepthMonitor.cs index 8e7cbfbc..9ea1d1e3 100644 --- a/src/BuildingBlocks/OrderSphere.BuildingBlocks.EventBus.AzureServiceBus/Dlq/DlqDepthMonitor.cs +++ b/src/BuildingBlocks/OrderSphere.BuildingBlocks.EventBus.AzureServiceBus/Dlq/DlqDepthMonitor.cs @@ -1,5 +1,6 @@ using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; +using OrderSphere.BuildingBlocks.Diagnostics; namespace OrderSphere.BuildingBlocks.EventBus.AzureServiceBus.Dlq; @@ -20,6 +21,8 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) do { + using var operation = BackgroundOperationScope.Begin("dlq-depth-poll"); + try { var depths = await admin.GetDepthsAsync(stoppingToken); diff --git a/src/BuildingBlocks/OrderSphere.BuildingBlocks.EventBus.AzureServiceBus/EventBusDiagnostics.cs b/src/BuildingBlocks/OrderSphere.BuildingBlocks.EventBus.AzureServiceBus/EventBusDiagnostics.cs index f560a802..4ac85d54 100644 --- a/src/BuildingBlocks/OrderSphere.BuildingBlocks.EventBus.AzureServiceBus/EventBusDiagnostics.cs +++ b/src/BuildingBlocks/OrderSphere.BuildingBlocks.EventBus.AzureServiceBus/EventBusDiagnostics.cs @@ -31,26 +31,42 @@ public static class EventBusDiagnostics private const string CorrelationIdProperty = "x-request-id"; // The OutboxDispatcher publishes on a timer, long after the originating request/consume - // completed, so the original context is no longer on Activity.Current. It is persisted on the - // outbox row and restored here as an ambient parent for the publish span. + // completed, so the original context is no longer on Activity.Current. Both the trace context + // and the log-correlation id are persisted on the outbox row and restored here — the trace as + // an ambient parent for the publish span, the correlation id as an ambient scope. private static readonly AsyncLocal AmbientPublishParent = new(); /// - /// Restores the originating trace context (captured when the outbox row was written) so the - /// publish span and everything downstream join the original trace. Dispose to clear it. + /// Restores the originating trace context and log-correlation id (both captured when the + /// outbox row was written) so the publish span and everything downstream rejoin the original + /// trace and the original correlation_id. Dispose to clear both. /// - public static IDisposable RestorePublishParent(string? traceParent) + /// + /// The persisted log-correlation id. Pass only for rows written before + /// the column existed; the trace id is then used, which is what those rows were correlated by. + /// + public static IDisposable RestorePublishParent(string? traceParent, string? correlationId) { var previous = AmbientPublishParent.Value; var parsed = ActivityContext.TryParse(traceParent, null, isRemote: true, out var ctx); AmbientPublishParent.Value = parsed ? ctx : null; - // The log-correlation id equals the trace id of the originating operation (the API - // Gateway seeds X-Request-Id from it), so restoring the trace context also restores - // correlation across the outbox boundary — no extra outbox column needed. - var correlationScope = parsed - ? AmbientCorrelationContext.BeginScope(ctx.TraceId.ToString()) - : null; + // Prefer the id that was actually ambient when the row was written. Deriving it from the + // trace id is only correct while correlation_id == trace_id, which the gateway breaks by + // honouring a client-supplied X-Request-Id and a consumer breaks by falling back to the + // message id — so the derivation is now the legacy fallback, not the rule. + // + // This also opens a scope when there is no usable trace context at all, which the + // previous version did not: without it Inject() below omitted x-request-id entirely, the + // consumer fell through to the message id, and that value was then persisted on the next + // outbox hop — permanently severing the chain. + var resolved = correlationId is { Length: > 0 } + ? correlationId + : parsed ? ctx.TraceId.ToString() : null; + + var correlationScope = resolved is null + ? null + : AmbientCorrelationContext.BeginScope(resolved); return new ParentScope(previous, correlationScope); } @@ -80,8 +96,14 @@ public static void Inject(ServiceBusMessage message) } /// - /// Reads the log-correlation id carried on an inbound message, falling back to the message's - /// trace id so that a message published before this property existed still correlates. + /// Reads the log-correlation id carried on an inbound message. + /// + /// The two fallbacks are for messages that predate the correlation property, not statements + /// that the values are equivalent: the trace id correlates such a message to its originating + /// operation, and the message id is a last resort that at least keeps the records of one + /// message together. A message published by current code always carries the property, because + /// every publishing path now has an ambient correlation id to inject. + /// /// public static string ReadCorrelationId(ServiceBusReceivedMessage message) { diff --git a/src/BuildingBlocks/OrderSphere.BuildingBlocks.EventBus.AzureServiceBus/Outbox/OutboxCleanupService.cs b/src/BuildingBlocks/OrderSphere.BuildingBlocks.EventBus.AzureServiceBus/Outbox/OutboxCleanupService.cs index 4232778c..cfc82851 100644 --- a/src/BuildingBlocks/OrderSphere.BuildingBlocks.EventBus.AzureServiceBus/Outbox/OutboxCleanupService.cs +++ b/src/BuildingBlocks/OrderSphere.BuildingBlocks.EventBus.AzureServiceBus/Outbox/OutboxCleanupService.cs @@ -3,6 +3,7 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; +using OrderSphere.BuildingBlocks.Diagnostics; using OrderSphere.BuildingBlocks.EventBus.Outbox; using OrderSphere.BuildingBlocks.Locking; @@ -34,6 +35,8 @@ private async Task CleanupAsync(CancellationToken ct) if (handle is null) return; + using var operation = BackgroundOperationScope.Begin("outbox-cleanup"); + try { var retentionDays = configuration.GetValue("Outbox:RetentionDays", 7); diff --git a/src/BuildingBlocks/OrderSphere.BuildingBlocks.EventBus.AzureServiceBus/Outbox/OutboxDispatcher.cs b/src/BuildingBlocks/OrderSphere.BuildingBlocks.EventBus.AzureServiceBus/Outbox/OutboxDispatcher.cs index cc3fecaa..a303ae92 100644 --- a/src/BuildingBlocks/OrderSphere.BuildingBlocks.EventBus.AzureServiceBus/Outbox/OutboxDispatcher.cs +++ b/src/BuildingBlocks/OrderSphere.BuildingBlocks.EventBus.AzureServiceBus/Outbox/OutboxDispatcher.cs @@ -2,6 +2,7 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; +using OrderSphere.BuildingBlocks.Diagnostics; using OrderSphere.BuildingBlocks.EventBus.Outbox; using OrderSphere.BuildingBlocks.Locking; @@ -32,6 +33,12 @@ private async Task ProcessPendingAsync(CancellationToken ct) if (handle is null) return; + // Wraps the whole batch, not just the dispatch of one row: the poison-count warning, the + // per-message retry/permanent-failure records and the transient loop error below all sit + // outside RestorePublishParent, and they are precisely the records wanted when a flow has + // stalled. Per-row context nests inside this scope in DispatchAsync. + using var operation = BackgroundOperationScope.Begin("outbox-dispatch"); + try { await using var scope = scopeFactory.CreateAsyncScope(); @@ -108,9 +115,10 @@ private static async Task DispatchAsync( $"No handler registered for outbox event type '{message.Type}'. " + $"Registered types: {string.Join(", ", handlers.Keys)}"); - // Restore the originating trace context so the publish (which happens here, on the - // dispatcher's timer) joins the trace that produced the outbox row. - using (EventBusDiagnostics.RestorePublishParent(message.TraceParent)) + // Restore the originating trace context and correlation id so the publish (which happens + // here, on the dispatcher's timer) rejoins both the trace and the correlation chain that + // produced the outbox row. + using (EventBusDiagnostics.RestorePublishParent(message.TraceParent, message.CorrelationId)) { await handler.HandleAsync(message.Content, ct); } diff --git a/src/BuildingBlocks/OrderSphere.BuildingBlocks.EventBus.AzureServiceBus/Outbox/OutboxMessageConfiguration.cs b/src/BuildingBlocks/OrderSphere.BuildingBlocks.EventBus.AzureServiceBus/Outbox/OutboxMessageConfiguration.cs index 790a006f..931544f3 100644 --- a/src/BuildingBlocks/OrderSphere.BuildingBlocks.EventBus.AzureServiceBus/Outbox/OutboxMessageConfiguration.cs +++ b/src/BuildingBlocks/OrderSphere.BuildingBlocks.EventBus.AzureServiceBus/Outbox/OutboxMessageConfiguration.cs @@ -15,6 +15,8 @@ public void Configure(EntityTypeBuilder builder) builder.Property(x => x.RetryCount).HasDefaultValue(0); // W3C traceparent is a fixed 55-char string ("00-" + 32 + "-" + 16 + "-" + 2). builder.Property(x => x.TraceParent).HasMaxLength(55); + // Not 55: unlike traceparent this can carry a client-supplied X-Request-Id. + builder.Property(x => x.CorrelationId).HasMaxLength(OutboxMessage.MaxCorrelationIdLength); // Composite index optimises the dispatcher query: WHERE ProcessedAt IS NULL ORDER BY OccurredAt builder.HasIndex(x => new { x.ProcessedAt, x.OccurredAt }); builder.HasIndex(x => x.RetryCount); diff --git a/src/BuildingBlocks/OrderSphere.BuildingBlocks.EventBus.AzureServiceBus/ProcessorLog.cs b/src/BuildingBlocks/OrderSphere.BuildingBlocks.EventBus.AzureServiceBus/ProcessorLog.cs index 927ab991..e7dd3265 100644 --- a/src/BuildingBlocks/OrderSphere.BuildingBlocks.EventBus.AzureServiceBus/ProcessorLog.cs +++ b/src/BuildingBlocks/OrderSphere.BuildingBlocks.EventBus.AzureServiceBus/ProcessorLog.cs @@ -37,6 +37,17 @@ public static partial class ProcessorLog Message = "Message could not be deserialized. Dead-lettering.")] public static partial void MessageUndeserializable(this ILogger logger); + /// + /// Same condition as , for the call sites where + /// deserialization threw rather than returning null. Separate EventId because the + /// [LoggerMessage] generator rejects two methods sharing one (SYSLIB1006). + /// + [LoggerMessage( + EventId = 1109, + Level = LogLevel.Error, + Message = "Message could not be deserialized. Dead-lettering.")] + public static partial void MessageUndeserializable(this ILogger logger, Exception exception); + [LoggerMessage( EventId = 1104, Level = LogLevel.Debug, diff --git a/src/BuildingBlocks/OrderSphere.BuildingBlocks.EventBus/IntegrationEvent.cs b/src/BuildingBlocks/OrderSphere.BuildingBlocks.EventBus/IntegrationEvent.cs index 2e802865..abe454a9 100644 --- a/src/BuildingBlocks/OrderSphere.BuildingBlocks.EventBus/IntegrationEvent.cs +++ b/src/BuildingBlocks/OrderSphere.BuildingBlocks.EventBus/IntegrationEvent.cs @@ -10,14 +10,21 @@ public abstract record IntegrationEvent /// /// Tenant the event originated from (ADR 0012). Defaults to whatever tenant is ambient at - /// construction time (see ), so an event constructed inside - /// a worker's AmbientTenantContext.BeginScope(...) — e.g. while reacting to an inbound - /// event — automatically carries the tenant forward to any events it stages, with no per-call - /// site wiring. This does not read the current HTTP request's claim: a command handler - /// publishing the first event in a flow must open its own - /// AmbientTenantContext.BeginScope(tenantContext.TenantId) (or set this property - /// explicitly) before constructing the event, since no ambient scope is open by default in an - /// API request. + /// construction time (see ). + /// + /// Both paths that construct events open that scope for you, so this default is correct + /// without per-call-site wiring: RequestContextEnrichmentMiddleware opens it from the + /// org_id claim for the duration of an HTTP request — which spans the command handler + /// that stages the event — and MessageProcessingScope.SetTenant opens it from the + /// inbound event for the duration of a message loop, carrying the tenant forward into any + /// event that loop stages in turn. + /// + /// + /// Assigning this property explicitly is therefore a smell: it means either the ambient scope + /// is missing where it should not be, or the caller is overriding the originating tenant. + /// Fix the scope instead. The value falls back to TenantId.Default for genuinely + /// tenant-less flows (anonymous requests, background jobs), which is what that value means. + /// /// public Guid TenantId { get; init; } = AmbientTenantContext.Ambient ?? StronglyTypedIds.TenantId.Default; } diff --git a/src/BuildingBlocks/OrderSphere.BuildingBlocks.EventBus/Outbox/OutboxMessage.cs b/src/BuildingBlocks/OrderSphere.BuildingBlocks.EventBus/Outbox/OutboxMessage.cs index a762837b..e0ce80f7 100644 --- a/src/BuildingBlocks/OrderSphere.BuildingBlocks.EventBus/Outbox/OutboxMessage.cs +++ b/src/BuildingBlocks/OrderSphere.BuildingBlocks.EventBus/Outbox/OutboxMessage.cs @@ -1,3 +1,6 @@ +using System.Diagnostics; +using OrderSphere.BuildingBlocks.Security; + namespace OrderSphere.BuildingBlocks.EventBus.Outbox; public sealed class OutboxMessage @@ -16,5 +19,56 @@ public sealed class OutboxMessage /// public string? TraceParent { get; init; } + /// + /// Log-correlation id ambient when the row was written (see + /// ), so the asynchronously dispatched publish rejoins + /// the originating correlation_id as well as the trace. + /// + /// Persisted separately from because the two are not the + /// same value. The dispatcher used to derive correlation from the trace id, which held only + /// as long as nothing chose a different correlation id — but the API Gateway honours a + /// client-supplied X-Request-Id, and a consumer falls back to the Service Bus message + /// id when a message carries no correlation property. Either one silently changed the + /// correlation id at the outbox boundary and broke the chain a single Seq query is supposed + /// to return. + /// + /// + /// Null on rows written before this column existed; the dispatcher then falls back to the + /// old trace-id derivation, which is correct for exactly those rows. + /// + /// + /// Distinct from IntegrationEvent.CorrelationId, which is a business idempotency key. + /// + /// + public string? CorrelationId { get; init; } + public const int MaxRetries = 10; + + /// + /// Upper bound for . System-minted ids are 32-char hex (a trace id + /// or a Guid("N")), but the value can originate from a client-supplied + /// X-Request-Id header, so it is capped rather than trusted. + /// + public const int MaxCorrelationIdLength = 128; + + /// + /// Creates a row capturing the caller's ambient trace and log-correlation context, so the + /// asynchronously dispatched publish rejoins both. + /// + /// Prefer this over an object initializer: capturing that context is the entire reason the + /// three services' outbox rows correlate identically, and having one factory keeps the + /// truncation rule and the captured field set from drifting between them. + /// + /// + public static OutboxMessage Create(string type, string content) => new() + { + Type = type, + Content = content, + TraceParent = Activity.Current?.Id, + // Truncate rather than reject: this row is written inside the business transaction, so + // an over-long header must not be able to fail an order over a diagnostics field. + CorrelationId = AmbientCorrelationContext.Ambient is { Length: > 0 } id + ? id[..Math.Min(id.Length, MaxCorrelationIdLength)] + : null, + }; } diff --git a/src/Gateways/OrderSphere.ApiGateway/Program.cs b/src/Gateways/OrderSphere.ApiGateway/Program.cs index 19d472ca..20e390a0 100644 --- a/src/Gateways/OrderSphere.ApiGateway/Program.cs +++ b/src/Gateways/OrderSphere.ApiGateway/Program.cs @@ -3,6 +3,7 @@ using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.AspNetCore.RateLimiting; using OrderSphere.ApiGateway.Authentication; +using OrderSphere.BuildingBlocks.Security; var builder = WebApplication.CreateBuilder(args); @@ -108,12 +109,30 @@ if (!context.Request.Headers.ContainsKey("X-Request-Id")) { // Seeded from the trace id so the client-visible id, the log correlation_id and the - // trace are one and the same value — including across the outbox, where only the trace - // context is persisted. Same 32-char lowercase hex shape as the previous Guid("N"). + // trace are one and the same value. A client may still supply its own id, which is why + // the outbox persists the correlation id in its own column rather than deriving it from + // the trace. Same 32-char lowercase hex shape as the previous Guid("N"). context.Request.Headers["X-Request-Id"] = Activity.Current?.TraceId.ToString() ?? Guid.NewGuid().ToString("N"); } - context.Response.Headers["X-Request-Id"] = context.Request.Headers["X-Request-Id"].ToString(); + + var correlationId = context.Request.Headers["X-Request-Id"].ToString(); + + // On OnStarting rather than assigned here: MapReverseProxy copies the proxied service's + // response headers over this one, and that service echoes the same id, so a plain assignment + // would reach the client as "id,id". OnStarting runs after the copy, just before the flush. + context.Response.OnStarting(() => + { + context.Response.Headers["X-Request-Id"] = correlationId; + return Task.CompletedTask; + }); + + // Opened here, not left to UseOrderSphereRequestLogging further down the pipeline: the + // authentication and rate-limiting middleware between the two emit the 401/403 audit records + // and the 429s, and those were the gateway's only records with no correlation_id — despite + // the id already sitting in the request headers at that point. + using var correlationScope = AmbientCorrelationContext.BeginScope(correlationId); + await next(); }); diff --git a/src/Gateways/OrderSphere.ApiGateway/appsettings.json b/src/Gateways/OrderSphere.ApiGateway/appsettings.json index 7cc77195..efd2d371 100644 --- a/src/Gateways/OrderSphere.ApiGateway/appsettings.json +++ b/src/Gateways/OrderSphere.ApiGateway/appsettings.json @@ -84,6 +84,13 @@ }, "AuthorizationPolicy": "default" }, + "advisory-dlq": { + "ClusterId": "advisory", + "Match": { + "Path": "/api/v1/admin/advisory/dlq/{**catch-all}" + }, + "AuthorizationPolicy": "default" + }, "userprofile-admin-users": { "ClusterId": "userprofile", "Match": { diff --git a/src/Gateways/OrderSphere.Bff/Auth/BackchannelLogoutEndpoint.cs b/src/Gateways/OrderSphere.Bff/Auth/BackchannelLogoutEndpoint.cs index 2316632f..19c28b50 100644 --- a/src/Gateways/OrderSphere.Bff/Auth/BackchannelLogoutEndpoint.cs +++ b/src/Gateways/OrderSphere.Bff/Auth/BackchannelLogoutEndpoint.cs @@ -3,6 +3,7 @@ using Microsoft.IdentityModel.Protocols; using Microsoft.IdentityModel.Protocols.OpenIdConnect; using Microsoft.IdentityModel.Tokens; +using OrderSphere.Bff.Logging; using OrderSphere.BuildingBlocks.Security; namespace OrderSphere.Bff.Auth; @@ -99,17 +100,18 @@ private static async Task HandleAsync( logger.LogWarning(ex, "logout_token signature validation threw an exception."); auditLogger.Log(new SecurityAuditEvent( SecurityAuditEventType.TokenValidationFailed, - Details: "Back-channel logout token validation exception: " + ex.Message)); + Details: "Back-channel logout token validation threw")); return Results.BadRequest("Invalid logout_token."); } if (!validationResult.IsValid) { - logger.LogWarning("logout_token failed validation: {Reason}", - validationResult.Exception?.Message ?? "unknown"); + // Pass the exception rather than its Message: the type and stack trace name the + // failure mode, and a validation Message can echo token content into the log. + logger.LogWarning(validationResult.Exception, "logout_token failed validation."); auditLogger.Log(new SecurityAuditEvent( SecurityAuditEventType.TokenValidationFailed, - Details: "logout_token invalid: " + validationResult.Exception?.Message)); + Details: "logout_token invalid")); return Results.BadRequest("Invalid logout_token."); } @@ -156,8 +158,7 @@ private static async Task HandleAsync( var sessionKey = await redisStore.FindKeyBySessionIdAsync(sid); if (sessionKey is null) { - logger.LogInformation( - "Back-channel logout: no active session found for sid={Sid} (already expired or logged out).", sid); + logger.BackchannelLogoutNoActiveSession(sid); return Results.Ok(); } @@ -167,10 +168,9 @@ private static async Task HandleAsync( SecurityAuditEventType.BackchannelLogoutRevoked, UserId: sub, SessionId: sid, - Details: $"Session key {sessionKey} removed from Redis.")); + Details: "Session removed from Redis")); - logger.LogInformation( - "Back-channel logout: session revoked for sid={Sid}, key={Key}.", sid, sessionKey); + logger.BackchannelLogoutSessionRevoked(sid, sessionKey); return Results.Ok(); } diff --git a/src/Gateways/OrderSphere.Bff/Auth/RedisTicketStore.cs b/src/Gateways/OrderSphere.Bff/Auth/RedisTicketStore.cs index bc83f94e..74c40e15 100644 --- a/src/Gateways/OrderSphere.Bff/Auth/RedisTicketStore.cs +++ b/src/Gateways/OrderSphere.Bff/Auth/RedisTicketStore.cs @@ -2,6 +2,7 @@ using Microsoft.AspNetCore.Authentication.Cookies; using Microsoft.AspNetCore.DataProtection; using Microsoft.Extensions.Caching.Distributed; +using OrderSphere.Bff.Logging; namespace OrderSphere.Bff.Auth; @@ -53,10 +54,10 @@ public async Task StoreAsync(AuthenticationTicket ticket) sidOptions.AbsoluteExpirationRelativeToNow = TimeSpan.FromHours(8); await _cache.SetStringAsync(SidPrefix + sid, key, sidOptions); - _logger.LogDebug("SID index written: sid={Sid} -> {Key}", sid, key); + _logger.SidIndexWritten(sid, key); } - _logger.LogDebug("Session ticket stored: {Key}", key); + _logger.SessionTicketStored(key); return key; } @@ -94,14 +95,14 @@ public async Task RenewAsync(string key, AuthenticationTicket ticket) } catch (Exception ex) { - _logger.LogWarning(ex, "Failed to unprotect session ticket {Key}. Treating as missing.", key); + _logger.SessionTicketUnprotectFailed(ex, key); return null; } } public Task RemoveAsync(string key) { - _logger.LogDebug("Session ticket removed: {Key}", key); + _logger.SessionTicketRemoved(key); return _cache.RemoveAsync(key); } } diff --git a/src/Gateways/OrderSphere.Bff/Auth/RefreshTokenHandler.cs b/src/Gateways/OrderSphere.Bff/Auth/RefreshTokenHandler.cs index 2161f9ea..c7c6f642 100644 --- a/src/Gateways/OrderSphere.Bff/Auth/RefreshTokenHandler.cs +++ b/src/Gateways/OrderSphere.Bff/Auth/RefreshTokenHandler.cs @@ -101,7 +101,8 @@ public override async Task ValidatePrincipal(CookieValidatePrincipalContext cont SecurityAuditEventType.RefreshTokenRevoked, UserId: sub, IpAddress: context.HttpContext.Connection.RemoteIpAddress?.ToString(), - Details: "Unhandled exception: " + ex.Message)); + // The exception itself is on the LogError above, with type and stack trace. + Details: "Unhandled exception during refresh token rotation")); context.RejectPrincipal(); await context.HttpContext.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme); } diff --git a/src/Gateways/OrderSphere.Bff/Logging/BffLog.cs b/src/Gateways/OrderSphere.Bff/Logging/BffLog.cs new file mode 100644 index 00000000..52186012 --- /dev/null +++ b/src/Gateways/OrderSphere.Bff/Logging/BffLog.cs @@ -0,0 +1,69 @@ +using OrderSphere.BuildingBlocks.Compliance; + +namespace OrderSphere.Bff.Logging; + +/// +/// Source-generated log methods for the BFF session and back-channel logout paths. +/// EventIds 12001-12099 (Gateways range 12000-12999, see docs/logging.md). +/// +/// These exist because redaction only applies to [LoggerMessage] parameters carrying a +/// classification attribute — a plain logger.LogDebug("... {Sid}", sid) reaches every sink +/// verbatim. Both identifiers here are T2: the Auth0 sid identifies a person via a join, +/// and the Redis session key is the handle that dereferences to that person's live session. +/// Hashing keeps them groupable (all records for one session still line up) without putting a +/// usable session handle in the log stream. +/// +/// +internal static partial class BffLog +{ + [LoggerMessage( + EventId = 12001, + Level = LogLevel.Debug, + Message = "Session id index written.")] + public static partial void SidIndexWritten( + this ILogger logger, + [PseudonymousId] string sid, + [PseudonymousId] string sessionKey); + + [LoggerMessage( + EventId = 12002, + Level = LogLevel.Debug, + Message = "Session ticket stored.")] + public static partial void SessionTicketStored( + this ILogger logger, + [PseudonymousId] string sessionKey); + + [LoggerMessage( + EventId = 12003, + Level = LogLevel.Debug, + Message = "Session ticket removed.")] + public static partial void SessionTicketRemoved( + this ILogger logger, + [PseudonymousId] string sessionKey); + + [LoggerMessage( + EventId = 12004, + Level = LogLevel.Warning, + Message = "Failed to unprotect session ticket. Treating as missing.")] + public static partial void SessionTicketUnprotectFailed( + this ILogger logger, + Exception exception, + [PseudonymousId] string sessionKey); + + [LoggerMessage( + EventId = 12005, + Level = LogLevel.Information, + Message = "Back-channel logout: no active session found (already expired or logged out).")] + public static partial void BackchannelLogoutNoActiveSession( + this ILogger logger, + [PseudonymousId] string sid); + + [LoggerMessage( + EventId = 12006, + Level = LogLevel.Information, + Message = "Back-channel logout: session revoked.")] + public static partial void BackchannelLogoutSessionRevoked( + this ILogger logger, + [PseudonymousId] string sid, + [PseudonymousId] string sessionKey); +} diff --git a/src/Gateways/OrderSphere.Bff/Program.cs b/src/Gateways/OrderSphere.Bff/Program.cs index c5fd1052..f4c514d5 100644 --- a/src/Gateways/OrderSphere.Bff/Program.cs +++ b/src/Gateways/OrderSphere.Bff/Program.cs @@ -194,7 +194,9 @@ OrderSphere.BuildingBlocks.Security.SecurityAuditEventType.AntiforgeryValidationFailed, UserId: ctx.User.FindFirst("sub")?.Value, IpAddress: ctx.Connection.RemoteIpAddress?.ToString(), - Details: $"{ctx.Request.Method} {ctx.Request.Path}")); + Details: "Antiforgery token missing or invalid", + RequestMethod: ctx.Request.Method, + RequestPath: ctx.Request.Path)); ctx.Response.StatusCode = StatusCodes.Status403Forbidden; await ctx.Response.WriteAsJsonAsync(new diff --git a/src/Gateways/OrderSphere.Bff/Workers/RealtimeNotificationProcessor.cs b/src/Gateways/OrderSphere.Bff/Workers/RealtimeNotificationProcessor.cs index c10dd575..4f886f16 100644 --- a/src/Gateways/OrderSphere.Bff/Workers/RealtimeNotificationProcessor.cs +++ b/src/Gateways/OrderSphere.Bff/Workers/RealtimeNotificationProcessor.cs @@ -43,7 +43,7 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) private async Task OnMessageReceived(ProcessMessageEventArgs args) { using var messageScope = MessageProcessingScope.Begin(logger, args.Message, QueueName); - var messageId = args.Message.MessageId; + logger.MessageReceived(); try { @@ -57,6 +57,8 @@ await args.DeadLetterMessageAsync(args.Message, return; } + messageScope.SetTenant(evt.TenantId); + await hubContext.Clients.Group(evt.UserId).SendAsync( "ReceiveNotification", new @@ -71,7 +73,7 @@ await hubContext.Clients.Group(evt.UserId).SendAsync( logger.LogInformation( "Pushed {Type} notification to user {UserId}.", - evt.Type, evt.UserId, messageId); + evt.Type, evt.UserId); await args.CompleteMessageAsync(args.Message); } @@ -84,9 +86,8 @@ await hubContext.Clients.Group(evt.UserId).SendAsync( private Task OnError(ProcessErrorEventArgs args) { - logger.LogError(args.Exception, - "Service Bus processor error. Source: {Source}, Entity: {Entity}", - args.ErrorSource, args.EntityPath); + logger.ProcessorError(args.Exception, args.EntityPath, args.ErrorSource.ToString()); + return Task.CompletedTask; } diff --git a/src/Hosting/OrderSphere.AppHost/AppHost.cs b/src/Hosting/OrderSphere.AppHost/AppHost.cs index e85a3ba1..439e250d 100644 --- a/src/Hosting/OrderSphere.AppHost/AppHost.cs +++ b/src/Hosting/OrderSphere.AppHost/AppHost.cs @@ -527,21 +527,49 @@ void EnableSystemAssignedIdentity(IResourceBuilder().ToList()) + { + builder.CreateResourceBuilder(project) + .WithReference(appInsights) + .WithEnvironment("Logging__Redaction__HmacKey", redactionHmacKey); + } +} + +// Local-development log and trace sink. The Aspire dashboard carries no query language and +// discards its data on restart; Seq indexes every enrichment property, so a whole checkout +// chain is one expression: correlation_id = '...'. AddSeqEndpoint in ServiceDefaults adds an +// exporter next to the dashboard's OTLP one, so both receive every record. +// +// Run mode only: production telemetry goes to Application Insights (above), and a +// developer-tool container has no place in the published manifest. +if (builder.ExecutionContext.IsRunMode) +{ + // Aspire 13.5.3 pins datalust/seq:2025.2. Raised to 2026.1, the release the + // first-party MCP server (via seqcli) ships with — see docs/logging.md. + var seq = builder.AddSeq("seq") + .WithImageTag("2026.1") + .WithDataVolume(); + + // Applied across the project resources instead of listed per service, so a service added + // later is covered without touching this block. + foreach (var project in builder.Resources.OfType().ToList()) + { + builder.CreateResourceBuilder(project).WithReference(seq); + } } builder.Build().Run(); diff --git a/src/Hosting/OrderSphere.AppHost/OrderSphere.AppHost.csproj b/src/Hosting/OrderSphere.AppHost/OrderSphere.AppHost.csproj index 867411f6..3050fc7a 100644 --- a/src/Hosting/OrderSphere.AppHost/OrderSphere.AppHost.csproj +++ b/src/Hosting/OrderSphere.AppHost/OrderSphere.AppHost.csproj @@ -18,6 +18,7 @@ + diff --git a/src/Hosting/OrderSphere.ServiceDefaults/Authentication/OidcAuthenticationExtensions.cs b/src/Hosting/OrderSphere.ServiceDefaults/Authentication/OidcAuthenticationExtensions.cs index 7feedf77..c4fb892e 100644 --- a/src/Hosting/OrderSphere.ServiceDefaults/Authentication/OidcAuthenticationExtensions.cs +++ b/src/Hosting/OrderSphere.ServiceDefaults/Authentication/OidcAuthenticationExtensions.cs @@ -81,10 +81,15 @@ private static TBuilder AddOrderSphereJwtAuthCore( var audit = ctx.HttpContext.RequestServices .GetRequiredService(); + // The exception type names the failure mode (expired, bad signature, + // wrong audience); its Message can echo token content, so it stays out + // of the audit record and is carried by the logger instead. audit.Log(new SecurityAuditEvent( SecurityAuditEventType.TokenValidationFailed, IpAddress: ctx.HttpContext.Connection.RemoteIpAddress?.ToString(), - Details: $"{ctx.Request.Method} {ctx.Request.Path} — {ctx.Exception.GetType().Name}: {ctx.Exception.Message}")); + Details: ctx.Exception.GetType().Name, + RequestMethod: ctx.Request.Method, + RequestPath: ctx.Request.Path)); return Task.CompletedTask; }, @@ -101,7 +106,9 @@ private static TBuilder AddOrderSphereJwtAuthCore( audit.Log(new SecurityAuditEvent( SecurityAuditEventType.LoginFailure, IpAddress: ctx.HttpContext.Connection.RemoteIpAddress?.ToString(), - Details: $"{ctx.Request.Method} {ctx.Request.Path} — no bearer token")); + Details: "No bearer token", + RequestMethod: ctx.Request.Method, + RequestPath: ctx.Request.Path)); } return Task.CompletedTask; @@ -119,7 +126,9 @@ private static TBuilder AddOrderSphereJwtAuthCore( SecurityAuditEventType.AuthorizationDenied, UserId: userId, IpAddress: ctx.HttpContext.Connection.RemoteIpAddress?.ToString(), - Details: $"{ctx.Request.Method} {ctx.Request.Path} — 403 Forbidden")); + Details: "403 Forbidden", + RequestMethod: ctx.Request.Method, + RequestPath: ctx.Request.Path)); return Task.CompletedTask; }, diff --git a/src/Hosting/OrderSphere.ServiceDefaults/Extensions.cs b/src/Hosting/OrderSphere.ServiceDefaults/Extensions.cs index c1b1eda6..7be9e8a6 100644 --- a/src/Hosting/OrderSphere.ServiceDefaults/Extensions.cs +++ b/src/Hosting/OrderSphere.ServiceDefaults/Extensions.cs @@ -135,6 +135,10 @@ public static TBuilder ConfigureOpenTelemetry(this TBuilder builder) w .AddSource("OrderSphere.EventBus") // Per-request (MediatR/CQRS handler) spans from the LoggingBehavior. .AddSource("OrderSphere.Application") + // One span per iteration of a timer-driven loop (outbox dispatch, webhook + // delivery, scheduled jobs, DLQ monitoring) — see BackgroundOperationScope. + // Without this registration those loops get no trace and no correlation id. + .AddSource("OrderSphere.Background") .AddAspNetCoreInstrumentation(tracing => // Exclude health check requests from tracing tracing.Filter = context => @@ -236,6 +240,17 @@ private static TBuilder AddOpenTelemetryExporters(this TBuilder builde builder.Services.AddOpenTelemetry().UseAzureMonitor(); } + // Local development: the AppHost adds Seq as a container in run mode and injects + // ConnectionStrings__seq. AddSeqEndpoint registers an additional OTLP exporter for + // logs and traces — the dashboard exporter above stays untouched and keeps receiving + // everything. Absent the connection string (tests, production) this is a no-op. + if (!string.IsNullOrWhiteSpace(builder.Configuration["ConnectionStrings:seq"])) + { + // The health check is deliberately off: a developer-tooling sink must never be + // able to report a service as unready and stall the Aspire WaitFor chains. + builder.AddSeqEndpoint("seq", settings => settings.DisableHealthChecks = true); + } + return builder; } diff --git a/src/Hosting/OrderSphere.ServiceDefaults/Logging/OrderSphereLogEnricher.cs b/src/Hosting/OrderSphere.ServiceDefaults/Logging/OrderSphereLogEnricher.cs index 8b774431..ee999a18 100644 --- a/src/Hosting/OrderSphere.ServiceDefaults/Logging/OrderSphereLogEnricher.cs +++ b/src/Hosting/OrderSphere.ServiceDefaults/Logging/OrderSphereLogEnricher.cs @@ -8,10 +8,12 @@ namespace Microsoft.Extensions.Hosting; /// /// Attaches request-scoped identifiers to every log record produced by the process. /// -/// Tenant and correlation are read from slots rather than from -/// HttpContext, so the same enricher covers API requests and worker message loops. +/// Tenant and correlation are read primarily from slots rather than +/// from HttpContext, so the same enricher covers API requests and worker message loops. /// That is the reason worker logs carry the same field set as API logs — the message loop -/// opens the ambient scopes (see MessageProcessingScope) and this enricher picks them up. +/// opens the ambient scopes (see MessageProcessingScope, BackgroundOperationScope) +/// and this enricher picks them up. HttpContext.Items is consulted only as a fallback for +/// records written after those scopes have unwound; it is never populated off the HTTP path. /// /// /// Registered as a singleton and invoked once per log record, so it must not depend on scoped @@ -26,20 +28,39 @@ internal sealed class OrderSphereLogEnricher(IHttpContextAccessor httpContextAcc { public void Enrich(IEnrichmentTagCollector collector) { + // The ambient slots are authoritative and are the only source workers have. On the HTTP + // path they can already be gone while the request is still being handled — an unhandled + // exception unwinds past RequestContextEnrichmentMiddleware before the outer + // ExceptionHandlerMiddleware logs it — so fall back to the values that middleware stashed + // on the HttpContext, which outlives them. See RequestContextEnrichmentMiddleware.CorrelationItemKey. + var httpContext = httpContextAccessor.HttpContext; + if (AmbientTenantContext.Ambient is { } tenantId) { collector.Add("tenant_id", tenantId); } + else if (httpContext?.Items.TryGetValue( + RequestContextEnrichmentMiddleware.TenantItemKey, out var stashedTenant) is true + && stashedTenant is Guid stashedTenantId) + { + collector.Add("tenant_id", stashedTenantId); + } if (AmbientCorrelationContext.Ambient is { Length: > 0 } correlationId) { collector.Add("correlation_id", correlationId); } + else if (httpContext?.Items.TryGetValue( + RequestContextEnrichmentMiddleware.CorrelationItemKey, out var stashed) is true + && stashed is string { Length: > 0 } stashedCorrelationId) + { + collector.Add("correlation_id", stashedCorrelationId); + } // Auth0 "sub" — an opaque pseudonymous identifier. Logged verbatim by design so that // operators can trace a single user's requests (documented in docs/operations.md); // the directly identifying attributes behind it live in UserProfile, not in logs. - var user = httpContextAccessor.HttpContext?.User; + var user = httpContext?.User; if (user is not null) { var userId = user.FindFirstValue("sub") ?? user.FindFirstValue(ClaimTypes.NameIdentifier); diff --git a/src/Hosting/OrderSphere.ServiceDefaults/Logging/RequestContextEnrichmentMiddleware.cs b/src/Hosting/OrderSphere.ServiceDefaults/Logging/RequestContextEnrichmentMiddleware.cs index fcc778ea..7ccce9a9 100644 --- a/src/Hosting/OrderSphere.ServiceDefaults/Logging/RequestContextEnrichmentMiddleware.cs +++ b/src/Hosting/OrderSphere.ServiceDefaults/Logging/RequestContextEnrichmentMiddleware.cs @@ -2,14 +2,15 @@ using System.Security.Cryptography; using System.Text; using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using OrderSphere.BuildingBlocks.Security; namespace Microsoft.Extensions.Hosting; /// -/// Establishes the ambient correlation id for an HTTP request and pushes the request-local -/// fields that are not process-wide into the log scope. +/// Establishes the ambient correlation id and tenant for an HTTP request, and pushes the +/// request-local fields that are not process-wide into the log scope. /// /// The correlation id is taken from the X-Request-Id header the API Gateway sets /// (ApiGateway/Program.cs), so a value chosen at the edge survives every downstream hop. @@ -17,6 +18,14 @@ namespace Microsoft.Extensions.Hosting; /// there is neither. /// /// +/// The tenant scope is this middleware's second job and is what makes ADR 0012 hold on the +/// request path. It is deliberately not only a logging concern: ITenantContext, +/// EF audit stamping, the tenant query filter and IntegrationEvent.TenantId all read the +/// same slot, so opening it here is what carries the tenant +/// into events staged by a command handler. It must therefore run after +/// UseAuthentication() — it does in all hosts, via UseOrderSphereRequestLogging(). +/// +/// /// tenant_id, correlation_id and user_id reach the log record through /// rather than this scope, so that worker logs carry the /// same fields without an HttpContext. @@ -28,14 +37,54 @@ internal sealed class RequestContextEnrichmentMiddleware( { internal const string CorrelationHeader = "X-Request-Id"; + /// + /// Where looks when the ambient scopes are already gone. + /// + /// Every host registers UseExceptionHandler() ahead of UseOrderSphereRequestLogging(), + /// which it must: the handler can only catch what runs inside it. The consequence is that an + /// unhandled exception unwinds past this middleware — disposing both AsyncLocal scopes — + /// before ExceptionHandlerMiddleware logs it, so the one record an operator most wants to + /// correlate would be the only one without a correlation_id. The + /// itself outlives the scopes and is the same instance in both middlewares, so stashing the + /// values on it closes the gap without reordering the pipeline in ten hosts. + /// + /// + internal const string CorrelationItemKey = "OrderSphere.CorrelationId"; + + /// + internal const string TenantItemKey = "OrderSphere.TenantId"; + public async Task InvokeAsync(HttpContext context) { var correlationId = ResolveCorrelationId(context); + using var correlationScope = AmbientCorrelationContext.BeginScope(correlationId); + context.Items[CorrelationItemKey] = correlationId; + // Echo it back so a client (or the browser dev tools) can quote the id in a bug report. - context.Response.Headers[CorrelationHeader] = correlationId; + // Deferred to OnStarting rather than assigned outright: in the two proxying hosts + // (ApiGateway, Bff) YARP copies the downstream response headers on top of this one and the + // proxied service echoes the very same id, so a plain assignment reaches the client as + // "id,id". OnStarting runs after that copy, immediately before the headers are flushed. + context.Response.OnStarting(() => + { + context.Response.Headers[CorrelationHeader] = correlationId; + return Task.CompletedTask; + }); - using var correlationScope = AmbientCorrelationContext.BeginScope(correlationId); + // No org_id (anonymous traffic, or a deployment with Auth0 Organizations not enabled) + // opens no scope at all, leaving tenant_id off the record rather than stamping an + // all-zero GUID that reads like a real tenant. ITenantContext still resolves + // TenantId.Default for persistence, so EF behaviour is unchanged either way. + var tenantId = TenantClaimResolver.Resolve(context.User); + using IDisposable? tenantScope = tenantId is { } resolvedTenant + ? AmbientTenantContext.BeginScope(resolvedTenant) + : null; + + if (tenantId is { } stashedTenant) + { + context.Items[TenantItemKey] = stashedTenant; + } // The raw client IP is personal data under GDPR and would otherwise sit on every single // record. A truncated keyed hash keeps per-client grouping (rate-limit abuse, error diff --git a/src/Hosting/OrderSphere.ServiceDefaults/OrderSphere.ServiceDefaults.csproj b/src/Hosting/OrderSphere.ServiceDefaults/OrderSphere.ServiceDefaults.csproj index cb67ad19..247bb628 100644 --- a/src/Hosting/OrderSphere.ServiceDefaults/OrderSphere.ServiceDefaults.csproj +++ b/src/Hosting/OrderSphere.ServiceDefaults/OrderSphere.ServiceDefaults.csproj @@ -1,4 +1,4 @@ - + net10.0 @@ -32,6 +32,10 @@ by the OpenTelemetry logger provider. See docs/logging.md. --> + + diff --git a/src/Hosting/OrderSphere.ServiceDefaults/SchedulingExtensions.cs b/src/Hosting/OrderSphere.ServiceDefaults/SchedulingExtensions.cs index c45be634..4cfdc609 100644 --- a/src/Hosting/OrderSphere.ServiceDefaults/SchedulingExtensions.cs +++ b/src/Hosting/OrderSphere.ServiceDefaults/SchedulingExtensions.cs @@ -39,7 +39,20 @@ public sealed class ScheduledJobRunner( ILogger> logger) : BackgroundService where TJob : class, IScheduledJob { - private static readonly string JobName = typeof(TJob).Name; + // Trimmed of the CLR arity marker: typeof(InboxCleanupJob).Name is + // "InboxCleanupJob`1", and every job registered here is generic over its DbContext. The + // backtick would otherwise reach the span name, the "job" metric dimension and the failure + // log alike, where it has to be escaped in every query that filters on it. + private static readonly string JobName = TrimArity(typeof(TJob).Name); + + // Constant per closed generic type, so it is a span name and not a per-iteration value. + private static readonly string OperationName = $"scheduled-job:{JobName}"; + + private static string TrimArity(string typeName) + { + var arity = typeName.IndexOf('`'); + return arity < 0 ? typeName : typeName[..arity]; + } protected override async Task ExecuteAsync(CancellationToken stoppingToken) { @@ -47,17 +60,22 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) do { - try - { - await RunOnceAsync(stoppingToken); - } - catch (OperationCanceledException) - { - break; - } - catch (Exception ex) + // Opened around the try rather than inside RunOnceAsync so the failure record below + // carries the same correlation id as whatever the job logged before it threw. + using (BackgroundOperationScope.Begin(OperationName)) { - logger.LogError(ex, "Scheduled job {Job} failed.", JobName); + try + { + await RunOnceAsync(stoppingToken); + } + catch (OperationCanceledException) + { + break; + } + catch (Exception ex) + { + logger.LogError(ex, "Scheduled job {Job} failed.", JobName); + } } } while (await SafeWaitAsync(timer, stoppingToken)); diff --git a/src/Hosting/OrderSphere.ServiceDefaults/Security/HttpContextTenantContext.cs b/src/Hosting/OrderSphere.ServiceDefaults/Security/HttpContextTenantContext.cs index e6b25139..c3a3ba62 100644 --- a/src/Hosting/OrderSphere.ServiceDefaults/Security/HttpContextTenantContext.cs +++ b/src/Hosting/OrderSphere.ServiceDefaults/Security/HttpContextTenantContext.cs @@ -14,15 +14,23 @@ internal sealed class HttpContextTenantContext : ITenantContext public HttpContextTenantContext(IHttpContextAccessor httpContextAccessor) { - var orgId = httpContextAccessor.HttpContext?.User.FindFirst("org_id")?.Value; - _claimTenantId = orgId is null ? null : TenantIdHelper.FromOrgId(orgId); + _claimTenantId = TenantClaimResolver.Resolve(httpContextAccessor.HttpContext?.User); } /// - /// Ambient scope (set explicitly by worker message loops, see ) - /// takes precedence over the request claim so the same registration serves both API requests - /// and background message processing within the same process (e.g. Invoicing.Api's embedded - /// consumer). + /// The ambient scope wins. It is opened by + /// RequestContextEnrichmentMiddleware on the HTTP path and by + /// MessageProcessingScope.SetTenant on the worker path, so one registration serves + /// requests and background message processing alike (e.g. Invoicing.Api's embedded consumer). + /// + /// On any request that reaches the middleware the ambient value and + /// _claimTenantId are derived from the same claim by the same function and are + /// therefore equal; the claim fallback stays as defence in depth for paths that bypass the + /// enrichment branch (/health, /alive, /version). Removing it would make + /// tenant isolation depend on middleware ordering in every host's Program.cs, where + /// the failure mode — everything silently reading and writing the default tenant — is + /// invisible until data has already crossed a boundary. + /// /// public Guid TenantId => AmbientTenantContext.Ambient ?? _claimTenantId ?? TenantIdHelper.Default; } diff --git a/src/Hosting/OrderSphere.ServiceDefaults/Security/SecurityAuditLogger.cs b/src/Hosting/OrderSphere.ServiceDefaults/Security/SecurityAuditLogger.cs index 2e6978d1..2151b8d3 100644 --- a/src/Hosting/OrderSphere.ServiceDefaults/Security/SecurityAuditLogger.cs +++ b/src/Hosting/OrderSphere.ServiceDefaults/Security/SecurityAuditLogger.cs @@ -36,6 +36,8 @@ public void Log(SecurityAuditEvent evt) evt.SessionId ?? "-", evt.IpAddress ?? "-", evt.Details ?? "-", + evt.RequestMethod ?? "-", + evt.RequestPath ?? "-", evt.OccurredAt); } @@ -50,7 +52,11 @@ public void Log(SecurityAuditEvent evt) /// /// /// details is written by OrderSphere code, never by request input; it must not be - /// used to carry user-supplied text. + /// used to carry user-supplied text. It is therefore deliberately unclassified — classifying + /// it would erase the one field whose whole purpose is to say what happened. Request context + /// has its own fields (auditRequestMethod, auditRequestPath) so callers are not + /// tempted to interpolate it into details; the path is a route, not a query string, + /// and carries no caller-supplied values. /// /// [LoggerMessage( @@ -64,6 +70,8 @@ private static partial void SecurityAudit( [PseudonymousId] string auditSessionId, [PseudonymousId] string auditIpAddress, string auditDetails, + string auditRequestMethod, + string auditRequestPath, DateTimeOffset auditOccurredAt); } diff --git a/src/Hosting/OrderSphere.ServiceDefaults/Security/TenantClaimResolver.cs b/src/Hosting/OrderSphere.ServiceDefaults/Security/TenantClaimResolver.cs new file mode 100644 index 00000000..31ebaafb --- /dev/null +++ b/src/Hosting/OrderSphere.ServiceDefaults/Security/TenantClaimResolver.cs @@ -0,0 +1,39 @@ +using System.Security.Claims; +using TenantIdHelper = OrderSphere.BuildingBlocks.StronglyTypedIds.TenantId; + +namespace Microsoft.Extensions.DependencyInjection; + +/// +/// Derives the tenant from a principal's Auth0 Organizations org_id claim (ADR 0012). +/// +/// Shared by (which backs ITenantContext for EF +/// stamping and query filtering) and by the request pipeline, which opens the ambient tenant +/// scope. One implementation so the two cannot disagree about what the claim means. +/// +/// +internal static class TenantClaimResolver +{ + /// Auth0 Organizations claim carrying the org identifier (ADR 0012). + internal const string OrgIdClaim = "org_id"; + + /// + /// Returns the tenant for , or when the + /// principal is anonymous or carries no usable org_id. + /// + /// Null rather than TenantId.Default on purpose: callers must be able to tell "no + /// tenant" from "the default tenant". The log pipeline omits tenant_id entirely in the + /// first case, which is honest; stamping an all-zero GUID on anonymous traffic would be + /// indistinguishable from a genuine single-organisation deployment. + /// + /// + /// The whitespace guard matters: is total — it hashes + /// whatever it is given, so an empty claim value would otherwise derive a valid-looking but + /// entirely fictional tenant rather than falling back. + /// + /// + internal static Guid? Resolve(ClaimsPrincipal? user) + { + var orgId = user?.FindFirst(OrgIdClaim)?.Value; + return string.IsNullOrWhiteSpace(orgId) ? null : TenantIdHelper.FromOrgId(orgId); + } +} diff --git a/src/Services/Advisory/OrderSphere.Advisory.Api/Program.cs b/src/Services/Advisory/OrderSphere.Advisory.Api/Program.cs index 2f1d30e2..da94233e 100644 --- a/src/Services/Advisory/OrderSphere.Advisory.Api/Program.cs +++ b/src/Services/Advisory/OrderSphere.Advisory.Api/Program.cs @@ -8,6 +8,8 @@ using OrderSphere.Advisory.Infrastructure; using OrderSphere.Advisory.Infrastructure.Persistence; using OrderSphere.BuildingBlocks.Auditing; +using OrderSphere.BuildingBlocks.EventBus.AzureServiceBus; +using OrderSphere.BuildingBlocks.EventBus.AzureServiceBus.Dlq; using OrderSphere.BuildingBlocks.EventBus.AzureServiceBus.Inbox; using OrderSphere.BuildingBlocks.EventBus.AzureServiceBus.Scheduling; using OrderSphere.BuildingBlocks.EventBus.Inbox; @@ -49,6 +51,11 @@ builder.Services.AddScoped>(); builder.Services.AddHostedService(); +// erasure-advisory is a GDPR erasure queue: a dead-lettered message here is an unfulfilled +// deletion request, so it needs the same depth gauge and replay surface as its three sibling +// erasure queues in Ordering, Payment and Invoicing. +builder.Services.AddDlqAdmin("erasure-advisory"); + // Retention cleanup: processed inbox rows and audit log entries past their retention window. builder.Services.AddScheduledJob>(); builder.Services.AddScheduledJob>(); @@ -109,5 +116,6 @@ // Admin audit-log surface — the gateway forwards /api/v1/admin/advisory/audit-log/** here. app.MapAuditLogAdminEndpoints("api/v1/admin/advisory/audit-log", "AdminPolicy"); +app.MapDlqAdminEndpoints("api/v1/admin/advisory/dlq", "AdminPolicy"); app.Run(); diff --git a/src/Services/Advisory/OrderSphere.Advisory.Api/Workers/CustomerErasureProcessor.cs b/src/Services/Advisory/OrderSphere.Advisory.Api/Workers/CustomerErasureProcessor.cs index a9328cb4..7f6c66eb 100644 --- a/src/Services/Advisory/OrderSphere.Advisory.Api/Workers/CustomerErasureProcessor.cs +++ b/src/Services/Advisory/OrderSphere.Advisory.Api/Workers/CustomerErasureProcessor.cs @@ -71,7 +71,7 @@ await args.DeadLetterMessageAsync(args.Message, if (await inboxStore.HasBeenProcessedAsync(evt.Id, args.CancellationToken)) { - logger.LogInformation("Duplicate erasure-advisory event {EventId} — skipping.", evt.Id); + logger.DuplicateMessageIgnored(); await args.CompleteMessageAsync(args.Message); return; } @@ -98,9 +98,7 @@ await args.DeadLetterMessageAsync(args.Message, private Task OnError(ProcessErrorEventArgs args) { - logger.LogError(args.Exception, - "Service Bus processor error. Source: {Source}, Entity: {Entity}", - args.ErrorSource, args.EntityPath); + logger.ProcessorError(args.Exception, args.EntityPath, args.ErrorSource.ToString()); return Task.CompletedTask; } diff --git a/src/Services/Basket/OrderSphere.Basket.Api/appsettings.Development.json b/src/Services/Basket/OrderSphere.Basket.Api/appsettings.Development.json new file mode 100644 index 00000000..339d732b --- /dev/null +++ b/src/Services/Basket/OrderSphere.Basket.Api/appsettings.Development.json @@ -0,0 +1,10 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning", + "Microsoft.Hosting.Lifetime": "Information", + "OrderSphere": "Debug" + } + } +} diff --git a/src/Services/Basket/OrderSphere.Basket.Infrastructure/CatalogClient/GrpcCatalogClient.cs b/src/Services/Basket/OrderSphere.Basket.Infrastructure/CatalogClient/GrpcCatalogClient.cs index 92dc7ff5..ce310689 100644 --- a/src/Services/Basket/OrderSphere.Basket.Infrastructure/CatalogClient/GrpcCatalogClient.cs +++ b/src/Services/Basket/OrderSphere.Basket.Infrastructure/CatalogClient/GrpcCatalogClient.cs @@ -29,7 +29,7 @@ public async Task> GetProductByIdAsync(Guid productId } catch (RpcException ex) { - logger.LogError(ex, "gRPC error fetching product {ProductId} from Catalog", productId); + logger.LogWarning(ex, "gRPC error fetching product {ProductId} from Catalog", productId); return Result.Failure(new Error("Catalog.Unavailable", "Catalog service unavailable.")); } } @@ -59,7 +59,7 @@ public async Task>> GetProd { // Cart enrichment degrades gracefully: an unreachable Catalog yields an empty map, // so the cart still renders (names/prices fall back to placeholders). - logger.LogError(ex, "gRPC error fetching product infos from Catalog"); + logger.LogWarning(ex, "gRPC error fetching product infos from Catalog. Continuing without enrichment."); return Result>.Success( new Dictionary()); } diff --git a/src/Services/Catalog/OrderSphere.Catalog.Api/appsettings.Development.json b/src/Services/Catalog/OrderSphere.Catalog.Api/appsettings.Development.json new file mode 100644 index 00000000..339d732b --- /dev/null +++ b/src/Services/Catalog/OrderSphere.Catalog.Api/appsettings.Development.json @@ -0,0 +1,10 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning", + "Microsoft.Hosting.Lifetime": "Information", + "OrderSphere": "Debug" + } + } +} diff --git a/src/Services/Catalog/OrderSphere.Catalog.Infrastructure/OrderingClient/HttpOrderingClient.cs b/src/Services/Catalog/OrderSphere.Catalog.Infrastructure/OrderingClient/HttpOrderingClient.cs index 4753b89c..41dfc657 100644 --- a/src/Services/Catalog/OrderSphere.Catalog.Infrastructure/OrderingClient/HttpOrderingClient.cs +++ b/src/Services/Catalog/OrderSphere.Catalog.Infrastructure/OrderingClient/HttpOrderingClient.cs @@ -24,8 +24,10 @@ public async Task HasPurchasedAsync(Guid customerId, Guid productId, Cance } catch (Exception ex) { - logger.LogError(ex, - "Error verifying purchase of product {ProductId} by customer {CustomerId}", + // Verification failing closed (false) is the safe outcome, not an incident: + // the caller simply does not grant the purchase-gated capability. + logger.LogWarning(ex, + "Error verifying purchase of product {ProductId} by customer {CustomerId}. Treating as not purchased.", productId, customerId); return false; } diff --git a/src/Services/Invoicing/OrderSphere.Invoicing.Api/Workers/CustomerErasureProcessor.cs b/src/Services/Invoicing/OrderSphere.Invoicing.Api/Workers/CustomerErasureProcessor.cs index f6382d23..518ea742 100644 --- a/src/Services/Invoicing/OrderSphere.Invoicing.Api/Workers/CustomerErasureProcessor.cs +++ b/src/Services/Invoicing/OrderSphere.Invoicing.Api/Workers/CustomerErasureProcessor.cs @@ -70,7 +70,7 @@ await args.DeadLetterMessageAsync(args.Message, if (await inboxStore.HasBeenProcessedAsync(evt.Id, args.CancellationToken)) { - logger.LogInformation("Duplicate erasure-invoicing event {EventId} — skipping.", evt.Id); + logger.DuplicateMessageIgnored(); await args.CompleteMessageAsync(args.Message); return; } @@ -97,9 +97,7 @@ await args.DeadLetterMessageAsync(args.Message, private Task OnError(ProcessErrorEventArgs args) { - logger.LogError(args.Exception, - "Service Bus processor error. Source: {Source}, Entity: {Entity}", - args.ErrorSource, args.EntityPath); + logger.ProcessorError(args.Exception, args.EntityPath, args.ErrorSource.ToString()); return Task.CompletedTask; } diff --git a/src/Services/Invoicing/OrderSphere.Invoicing.Api/Workers/InvoiceProcessor.cs b/src/Services/Invoicing/OrderSphere.Invoicing.Api/Workers/InvoiceProcessor.cs index 52e0be21..e8afe7e3 100644 --- a/src/Services/Invoicing/OrderSphere.Invoicing.Api/Workers/InvoiceProcessor.cs +++ b/src/Services/Invoicing/OrderSphere.Invoicing.Api/Workers/InvoiceProcessor.cs @@ -70,7 +70,7 @@ await args.DeadLetterMessageAsync(args.Message, if (await inboxStore.HasBeenProcessedAsync(evt.Id, args.CancellationToken)) { - logger.LogInformation("Duplicate invoice-generation event {EventId} — skipping.", evt.Id); + logger.DuplicateMessageIgnored(); await args.CompleteMessageAsync(args.Message); return; } @@ -121,9 +121,7 @@ await args.DeadLetterMessageAsync(args.Message, private Task OnError(ProcessErrorEventArgs args) { - logger.LogError(args.Exception, - "Service Bus processor error. Source: {Source}, Entity: {Entity}", - args.ErrorSource, args.EntityPath); + logger.ProcessorError(args.Exception, args.EntityPath, args.ErrorSource.ToString()); return Task.CompletedTask; } diff --git a/src/Services/Invoicing/OrderSphere.Invoicing.Api/appsettings.Development.json b/src/Services/Invoicing/OrderSphere.Invoicing.Api/appsettings.Development.json new file mode 100644 index 00000000..339d732b --- /dev/null +++ b/src/Services/Invoicing/OrderSphere.Invoicing.Api/appsettings.Development.json @@ -0,0 +1,10 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning", + "Microsoft.Hosting.Lifetime": "Information", + "OrderSphere": "Debug" + } + } +} diff --git a/src/Services/Notification/OrderSphere.Notification.Worker/Properties/launchSettings.json b/src/Services/Notification/OrderSphere.Notification.Worker/Properties/launchSettings.json new file mode 100644 index 00000000..47ec257f --- /dev/null +++ b/src/Services/Notification/OrderSphere.Notification.Worker/Properties/launchSettings.json @@ -0,0 +1,10 @@ +{ + "profiles": { + "OrderSphere.Notification.Worker": { + "commandName": "Project", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/src/Services/Notification/OrderSphere.Notification.Worker/Workers/InvoiceGeneratedProcessor.cs b/src/Services/Notification/OrderSphere.Notification.Worker/Workers/InvoiceGeneratedProcessor.cs index 72b7400d..d47e0421 100644 --- a/src/Services/Notification/OrderSphere.Notification.Worker/Workers/InvoiceGeneratedProcessor.cs +++ b/src/Services/Notification/OrderSphere.Notification.Worker/Workers/InvoiceGeneratedProcessor.cs @@ -56,13 +56,15 @@ await args.DeadLetterMessageAsync(args.Message, return; } + messageScope.SetTenant(evt.TenantId); + await using var scope = scopeFactory.CreateAsyncScope(); var inboxStore = scope.ServiceProvider.GetRequiredService(); var emailService = scope.ServiceProvider.GetRequiredService(); if (await inboxStore.HasBeenProcessedAsync(evt.Id, args.CancellationToken)) { - logger.LogInformation("Duplicate invoice-ready event {EventId} — skipping.", evt.Id); + logger.DuplicateMessageIgnored(); await args.CompleteMessageAsync(args.Message); return; } @@ -82,9 +84,7 @@ await args.DeadLetterMessageAsync(args.Message, private Task OnError(ProcessErrorEventArgs args) { - logger.LogError(args.Exception, - "Service Bus processor error. Source: {Source}, Entity: {Entity}", - args.ErrorSource, args.EntityPath); + logger.ProcessorError(args.Exception, args.EntityPath, args.ErrorSource.ToString()); return Task.CompletedTask; } diff --git a/src/Services/Notification/OrderSphere.Notification.Worker/Workers/NotificationProcessor.cs b/src/Services/Notification/OrderSphere.Notification.Worker/Workers/NotificationProcessor.cs index 244c7b8d..a53dd941 100644 --- a/src/Services/Notification/OrderSphere.Notification.Worker/Workers/NotificationProcessor.cs +++ b/src/Services/Notification/OrderSphere.Notification.Worker/Workers/NotificationProcessor.cs @@ -58,6 +58,8 @@ await args.DeadLetterMessageAsync(args.Message, return; } + messageScope.SetTenant(evt.TenantId); + await using var scope = scopeFactory.CreateAsyncScope(); var inboxStore = scope.ServiceProvider.GetRequiredService(); var channels = scope.ServiceProvider.GetRequiredService>(); @@ -83,7 +85,7 @@ internal async Task ProcessNotificationAsync( // Idempotency check — guard against ASB at-least-once redelivery. if (await inboxStore.HasBeenProcessedAsync(evt.Id, ct)) { - logger.LogInformation("Duplicate notification event {EventId} — skipping.", evt.Id); + logger.DuplicateMessageIgnored(); return; } @@ -117,9 +119,7 @@ internal async Task ProcessNotificationAsync( private Task OnError(ProcessErrorEventArgs args) { - logger.LogError(args.Exception, - "Service Bus processor error. Source: {Source}, Entity: {Entity}", - args.ErrorSource, args.EntityPath); + logger.ProcessorError(args.Exception, args.EntityPath, args.ErrorSource.ToString()); return Task.CompletedTask; } diff --git a/src/Services/Notification/OrderSphere.Notification.Worker/appsettings.Development.json b/src/Services/Notification/OrderSphere.Notification.Worker/appsettings.Development.json new file mode 100644 index 00000000..339d732b --- /dev/null +++ b/src/Services/Notification/OrderSphere.Notification.Worker/appsettings.Development.json @@ -0,0 +1,10 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning", + "Microsoft.Hosting.Lifetime": "Information", + "OrderSphere": "Debug" + } + } +} diff --git a/src/Services/Ordering/OrderSphere.Ordering.Api/appsettings.Development.json b/src/Services/Ordering/OrderSphere.Ordering.Api/appsettings.Development.json new file mode 100644 index 00000000..339d732b --- /dev/null +++ b/src/Services/Ordering/OrderSphere.Ordering.Api/appsettings.Development.json @@ -0,0 +1,10 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning", + "Microsoft.Hosting.Lifetime": "Information", + "OrderSphere": "Debug" + } + } +} diff --git a/src/Services/Ordering/OrderSphere.Ordering.Application/Features/Checkout/CheckoutCartCommandHandler.cs b/src/Services/Ordering/OrderSphere.Ordering.Application/Features/Checkout/CheckoutCartCommandHandler.cs index 377408e9..093bf20d 100644 --- a/src/Services/Ordering/OrderSphere.Ordering.Application/Features/Checkout/CheckoutCartCommandHandler.cs +++ b/src/Services/Ordering/OrderSphere.Ordering.Application/Features/Checkout/CheckoutCartCommandHandler.cs @@ -149,7 +149,10 @@ public async Task> Handle(CheckoutCartCommand request, Cancellation var releaseResult = await catalogClient.ReleaseReservationAsync(correlationId, CancellationToken.None); if (releaseResult.IsFailure) { - logger.LogError( + // Warning, not Error: this is a handled outcome with a designed fallback — + // the Catalog TTL sweeper reclaims the hold. Nothing is lost and no one + // needs to act. + logger.LogWarning( "COMPENSATION: immediate reservation release failed for CorrelationId {CorrelationId}; TTL sweeper will reclaim it.", correlationId); } diff --git a/src/Services/Ordering/OrderSphere.Ordering.Infrastructure/CatalogClient/HttpBasketClient.cs b/src/Services/Ordering/OrderSphere.Ordering.Infrastructure/CatalogClient/HttpBasketClient.cs index 34519d1a..8ec6b413 100644 --- a/src/Services/Ordering/OrderSphere.Ordering.Infrastructure/CatalogClient/HttpBasketClient.cs +++ b/src/Services/Ordering/OrderSphere.Ordering.Infrastructure/CatalogClient/HttpBasketClient.cs @@ -22,7 +22,7 @@ public async Task> GetCartAsync(Guid customerId, Cancella } catch (Exception ex) { - logger.LogError(ex, "Error fetching cart for customer {CustomerId} from Basket", customerId); + logger.LogWarning(ex, "Error fetching cart for customer {CustomerId} from Basket", customerId); return Result.Failure(new Error("Basket.Unavailable", "Basket service unavailable.")); } } @@ -38,7 +38,7 @@ public async Task ClearCartItemsAsync(Guid customerId, CancellationToken } catch (Exception ex) { - logger.LogError(ex, "Error clearing cart items for customer {CustomerId}", customerId); + logger.LogWarning(ex, "Error clearing cart items for customer {CustomerId}", customerId); return Result.Failure(new Error("Basket.Unavailable", "Basket service unavailable.")); } } diff --git a/src/Services/Ordering/OrderSphere.Ordering.Infrastructure/CatalogClient/HttpCatalogClient.cs b/src/Services/Ordering/OrderSphere.Ordering.Infrastructure/CatalogClient/HttpCatalogClient.cs index 094892e3..7a39f69e 100644 --- a/src/Services/Ordering/OrderSphere.Ordering.Infrastructure/CatalogClient/HttpCatalogClient.cs +++ b/src/Services/Ordering/OrderSphere.Ordering.Infrastructure/CatalogClient/HttpCatalogClient.cs @@ -22,7 +22,7 @@ public async Task> GetProductByIdAsync(Guid productId } catch (Exception ex) { - logger.LogError(ex, "Error fetching product {ProductId} from Catalog", productId); + logger.LogWarning(ex, "Error fetching product {ProductId} from Catalog", productId); return Result.Failure(new Error("Catalog.Unavailable", "Catalog service unavailable.")); } } @@ -48,7 +48,11 @@ public async Task>> GetProductNamesById } catch (Exception ex) { - logger.LogError(ex, "Error fetching product names from Catalog"); + // Product names are presentational only, so an unreachable Catalog degrades to + // "no names" rather than failing the caller — hence Success with an empty map. + // The log record is the only trace of the degradation, which is why it is a + // Warning and not swallowed silently. + logger.LogWarning(ex, "Error fetching product names from Catalog. Continuing without names."); return Result>.Success(new Dictionary()); } } @@ -67,7 +71,7 @@ public async Task DecrementStockAsync(Guid productId, int quantity, Canc } catch (Exception ex) { - logger.LogError(ex, "Error decrementing stock for product {ProductId}", productId); + logger.LogWarning(ex, "Error decrementing stock for product {ProductId}", productId); return Result.Failure(new Error("Catalog.Unavailable", "Catalog service unavailable.")); } } @@ -86,7 +90,7 @@ public async Task RestoreStockAsync(Guid productId, int quantity, Cancel } catch (Exception ex) { - logger.LogError(ex, "Error restoring stock for product {ProductId}", productId); + logger.LogWarning(ex, "Error restoring stock for product {ProductId}", productId); return Result.Failure(new Error("Catalog.Unavailable", "Catalog service unavailable.")); } } @@ -113,7 +117,7 @@ public async Task ReserveStockAsync( } catch (Exception ex) { - logger.LogError(ex, "Error reserving stock for correlation {CorrelationId}", correlationId); + logger.LogWarning(ex, "Error reserving stock for correlation {CorrelationId}", correlationId); return Result.Failure(new Error("Catalog.Unavailable", "Catalog service unavailable.")); } } @@ -136,7 +140,7 @@ public async Task ConfirmReservationAsync(Guid correlationId, Cancellati } catch (Exception ex) { - logger.LogError(ex, "Error confirming reservation for correlation {CorrelationId}", correlationId); + logger.LogWarning(ex, "Error confirming reservation for correlation {CorrelationId}", correlationId); return Result.Failure(new Error("Catalog.Unavailable", "Catalog service unavailable.")); } } @@ -152,7 +156,7 @@ public async Task ReleaseReservationAsync(Guid correlationId, Cancellati } catch (Exception ex) { - logger.LogError(ex, "Error releasing reservation for correlation {CorrelationId}", correlationId); + logger.LogWarning(ex, "Error releasing reservation for correlation {CorrelationId}", correlationId); return Result.Failure(new Error("Catalog.Unavailable", "Catalog service unavailable.")); } } diff --git a/src/Services/Ordering/OrderSphere.Ordering.Infrastructure/Migrations/20260907184512_AddOutboxCorrelationId.Designer.cs b/src/Services/Ordering/OrderSphere.Ordering.Infrastructure/Migrations/20260907184512_AddOutboxCorrelationId.Designer.cs new file mode 100644 index 00000000..e0611cba --- /dev/null +++ b/src/Services/Ordering/OrderSphere.Ordering.Infrastructure/Migrations/20260907184512_AddOutboxCorrelationId.Designer.cs @@ -0,0 +1,665 @@ +// +using System; +using System.Collections.Generic; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using OrderSphere.Ordering.Infrastructure.Persistence; + +#nullable disable + +namespace OrderSphere.Ordering.Infrastructure.Migrations +{ + [DbContext(typeof(OrderingDbContext))] + [Migration("20260907184512_AddOutboxCorrelationId")] + partial class AddOutboxCorrelationId + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.11") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("OrderSphere.BuildingBlocks.Auditing.AuditLogEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Action") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("ChangedBy") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Changes") + .IsRequired() + .HasColumnType("text"); + + b.Property("EntityId") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EntityType") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("OccurredAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("EntityType", "EntityId", "OccurredAt"); + + b.ToTable("audit_log_entries", (string)null); + }); + + modelBuilder.Entity("OrderSphere.BuildingBlocks.EventBus.Inbox.InboxMessage", b => + { + b.Property("EventId") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("EventType") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("ProcessedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("EventId"); + + b.HasIndex("ProcessedAt"); + + b.ToTable("inbox_messages", (string)null); + }); + + modelBuilder.Entity("OrderSphere.BuildingBlocks.EventBus.Outbox.OutboxMessage", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Content") + .IsRequired() + .HasColumnType("text"); + + b.Property("CorrelationId") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Error") + .HasColumnType("text"); + + b.Property("OccurredAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ProcessedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("RetryCount") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0); + + b.Property("TraceParent") + .HasMaxLength(55) + .HasColumnType("character varying(55)"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("xmin") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.HasKey("Id"); + + b.HasIndex("RetryCount"); + + b.HasIndex("ProcessedAt", "OccurredAt"); + + b.ToTable("outbox_messages", (string)null); + }); + + modelBuilder.Entity("OrderSphere.Ordering.Domain.Entities.Coupon", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DiscountType") + .HasColumnType("integer"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("MaxRedemptions") + .HasColumnType("integer"); + + b.Property("MinSubtotal") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("RedeemedCount") + .HasColumnType("integer"); + + b.PrimitiveCollection>("ScopedCategoryIds") + .IsRequired() + .HasColumnType("uuid[]") + .HasColumnName("scoped_category_ids"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ValidFrom") + .HasColumnType("timestamp with time zone"); + + b.Property("ValidUntil") + .HasColumnType("timestamp with time zone"); + + b.Property("Value") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.ToTable("coupons", (string)null); + + b.HasData( + new + { + Id = new Guid("0192a000-0000-7000-8000-000000000001"), + Code = "WELCOME10", + CreatedAt = new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Utc), + DiscountType = 0, + IsActive = true, + IsDeleted = false, + RedeemedCount = 0, + ScopedCategoryIds = new List(), + TenantId = new Guid("00000000-0000-0000-0000-000000000000"), + Value = 10m + }, + new + { + Id = new Guid("0192a000-0000-7000-8000-000000000002"), + Code = "SUMMER15", + CreatedAt = new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Utc), + DiscountType = 0, + IsActive = true, + IsDeleted = false, + MinSubtotal = 100m, + RedeemedCount = 0, + ScopedCategoryIds = new List(), + TenantId = new Guid("00000000-0000-0000-0000-000000000000"), + Value = 15m + }); + }); + + modelBuilder.Entity("OrderSphere.Ordering.Domain.Entities.OrderHistoryEntry", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CorrelationId") + .HasColumnType("uuid"); + + b.Property("CustomerEmail") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NewStatus") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("OccurredAt") + .HasColumnType("timestamp with time zone"); + + b.Property("OrderId") + .HasColumnType("uuid"); + + b.Property("PreviousStatus") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.HasKey("Id"); + + b.HasIndex("CustomerEmail"); + + b.HasIndex("OccurredAt"); + + b.HasIndex("OrderId", "OccurredAt"); + + b.ToTable("order_history", (string)null); + }); + + modelBuilder.Entity("OrderSphere.Ordering.Domain.Entities.OrderItem", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CategoryId") + .HasColumnType("uuid") + .HasColumnName("category_id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("OrderId") + .HasColumnType("uuid"); + + b.Property("ProductId") + .HasColumnType("uuid"); + + b.Property("ProductName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Quantity") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.ComplexProperty(typeof(Dictionary), "Price", "OrderSphere.Ordering.Domain.Entities.OrderItem.Price#Money", b1 => + { + b1.IsRequired(); + + b1.Property("Amount") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)") + .HasColumnName("price"); + + b1.Property("Currency") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(3) + .HasColumnType("character varying(3)") + .HasDefaultValue("EUR") + .HasColumnName("price_currency"); + }); + + b.HasKey("Id"); + + b.HasIndex("OrderId"); + + b.ToTable("order_items", (string)null); + }); + + modelBuilder.Entity("OrderSphere.Ordering.Domain.Entities.OrderSaga", b => + { + b.Property("CorrelationId") + .HasColumnType("uuid"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("LastError") + .HasMaxLength(1024) + .HasColumnType("character varying(1024)"); + + b.Property("OrderId") + .HasColumnType("uuid"); + + b.Property("PaymentRequestedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("State") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("CorrelationId"); + + b.HasIndex("OrderId"); + + b.ToTable("order_sagas", (string)null); + }); + + modelBuilder.Entity("OrderSphere.Ordering.Domain.Entities.ReturnRequest", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Currency") + .IsRequired() + .HasMaxLength(3) + .HasColumnType("character varying(3)"); + + b.Property("CustomerId") + .HasColumnType("uuid"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("OrderId") + .HasColumnType("uuid"); + + b.Property("Reason") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("RequestedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Resolution") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("ResolvedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("CustomerId"); + + b.HasIndex("OrderId"); + + b.ToTable("return_requests", (string)null); + }); + + modelBuilder.Entity("OrderSphere.Ordering.Domain.ReadModels.OrderView", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CorrelationId") + .HasColumnType("uuid"); + + b.Property("CouponCode") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CustomerId") + .HasColumnType("uuid"); + + b.Property("DiscountAmount") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("PaymentMethod") + .HasColumnType("integer"); + + b.Property("ShippingCost") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("TrackingNumber") + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("CorrelationId") + .IsUnique(); + + b.ToTable("orders", (string)null); + }); + + modelBuilder.Entity("OrderSphere.Ordering.Infrastructure.EventSourcing.OrderEventRecord", b => + { + b.Property("StreamId") + .HasColumnType("uuid"); + + b.Property("Version") + .HasColumnType("integer"); + + b.Property("EventType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("OccurredAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Payload") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("StreamId", "Version"); + + b.HasIndex("OccurredAt"); + + b.ToTable("order_events", (string)null); + }); + + modelBuilder.Entity("OrderSphere.Ordering.Domain.Entities.Coupon", b => + { + b.OwnsMany("OrderSphere.Ordering.Domain.ValueObjects.CouponTier", "Tiers", b1 => + { + b1.Property("CouponId"); + + b1.Property("__synthesizedOrdinal") + .ValueGeneratedOnAdd(); + + b1.Property("DiscountValue") + .HasPrecision(18, 2); + + b1.Property("MinSubtotal") + .HasPrecision(18, 2); + + b1.HasKey("CouponId", "__synthesizedOrdinal"); + + b1.ToTable("coupons"); + + b1 + .ToJson("tiers") + .HasColumnType("jsonb"); + + b1.WithOwner() + .HasForeignKey("CouponId"); + }); + + b.Navigation("Tiers"); + }); + + modelBuilder.Entity("OrderSphere.Ordering.Domain.Entities.OrderItem", b => + { + b.HasOne("OrderSphere.Ordering.Domain.ReadModels.OrderView", null) + .WithMany("Items") + .HasForeignKey("OrderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("OrderSphere.Ordering.Domain.Entities.ReturnRequest", b => + { + b.OwnsMany("OrderSphere.Ordering.Domain.Entities.ReturnItem", "Items", b1 => + { + b1.Property("Id") + .HasColumnType("uuid"); + + b1.Property("ProductId") + .HasColumnType("uuid"); + + b1.Property("ProductName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b1.Property("Quantity") + .HasColumnType("integer"); + + b1.Property("ReturnRequestId") + .HasColumnType("uuid"); + + b1.Property("UnitPrice") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b1.HasKey("Id"); + + b1.HasIndex("ReturnRequestId"); + + b1.ToTable("return_request_items", (string)null); + + b1.WithOwner() + .HasForeignKey("ReturnRequestId"); + }); + + b.Navigation("Items"); + }); + + modelBuilder.Entity("OrderSphere.Ordering.Domain.ReadModels.OrderView", b => + { + b.OwnsMany("OrderSphere.Ordering.Domain.Entities.OrderStatusHistory", "StatusHistory", b1 => + { + b1.Property("Id") + .HasColumnType("uuid"); + + b1.Property("Note") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b1.Property("OccurredAt") + .HasColumnType("timestamp with time zone"); + + b1.Property("OrderId") + .HasColumnType("uuid"); + + b1.Property("Status") + .HasColumnType("integer"); + + b1.HasKey("Id"); + + b1.HasIndex("OrderId"); + + b1.ToTable("order_status_history", (string)null); + + b1.WithOwner() + .HasForeignKey("OrderId"); + }); + + b.OwnsOne("OrderSphere.Ordering.Domain.ValueObjects.Address", "ShippingAddress", b1 => + { + b1.Property("OrderViewId") + .HasColumnType("uuid"); + + b1.Property("City") + .IsRequired() + .HasColumnType("text") + .HasColumnName("shipping_city"); + + b1.Property("Country") + .IsRequired() + .HasColumnType("text") + .HasColumnName("shipping_country"); + + b1.Property("FirstName") + .IsRequired() + .HasColumnType("text") + .HasColumnName("shipping_first_name"); + + b1.Property("LastName") + .IsRequired() + .HasColumnType("text") + .HasColumnName("shipping_last_name"); + + b1.Property("PostalCode") + .IsRequired() + .HasColumnType("text") + .HasColumnName("shipping_postal_code"); + + b1.Property("Street") + .IsRequired() + .HasColumnType("text") + .HasColumnName("shipping_street"); + + b1.HasKey("OrderViewId"); + + b1.ToTable("orders"); + + b1.WithOwner() + .HasForeignKey("OrderViewId"); + }); + + b.Navigation("ShippingAddress") + .IsRequired(); + + b.Navigation("StatusHistory"); + }); + + modelBuilder.Entity("OrderSphere.Ordering.Domain.ReadModels.OrderView", b => + { + b.Navigation("Items"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Services/Ordering/OrderSphere.Ordering.Infrastructure/Migrations/20260907184512_AddOutboxCorrelationId.cs b/src/Services/Ordering/OrderSphere.Ordering.Infrastructure/Migrations/20260907184512_AddOutboxCorrelationId.cs new file mode 100644 index 00000000..48d864d0 --- /dev/null +++ b/src/Services/Ordering/OrderSphere.Ordering.Infrastructure/Migrations/20260907184512_AddOutboxCorrelationId.cs @@ -0,0 +1,33 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace OrderSphere.Ordering.Infrastructure.Migrations +{ + /// + public partial class AddOutboxCorrelationId : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "CorrelationId", + table: "outbox_messages", + type: "character varying(128)", + maxLength: 128, + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + // The scaffolder also emitted two UpdateData calls resetting the seeded coupons' + // scoped_category_ids — an artifact of how it re-emits collection-valued seed data on + // Down. Up does not touch coupons and the model snapshot shows no such change, so + // they were removed: Down must undo exactly what Up did and nothing else. + migrationBuilder.DropColumn( + name: "CorrelationId", + table: "outbox_messages"); + } + } +} diff --git a/src/Services/Ordering/OrderSphere.Ordering.Infrastructure/Migrations/OrderingDbContextModelSnapshot.cs b/src/Services/Ordering/OrderSphere.Ordering.Infrastructure/Migrations/OrderingDbContextModelSnapshot.cs index 0e42b0a0..6eedcf91 100644 --- a/src/Services/Ordering/OrderSphere.Ordering.Infrastructure/Migrations/OrderingDbContextModelSnapshot.cs +++ b/src/Services/Ordering/OrderSphere.Ordering.Infrastructure/Migrations/OrderingDbContextModelSnapshot.cs @@ -18,7 +18,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) { #pragma warning disable 612, 618 modelBuilder - .HasAnnotation("ProductVersion", "10.0.9") + .HasAnnotation("ProductVersion", "10.0.11") .HasAnnotation("Relational:MaxIdentifierLength", 63); NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); @@ -93,6 +93,10 @@ protected override void BuildModel(ModelBuilder modelBuilder) .IsRequired() .HasColumnType("text"); + b.Property("CorrelationId") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + b.Property("Error") .HasColumnType("text"); diff --git a/src/Services/Ordering/OrderSphere.Ordering.Infrastructure/Persistence/OrderingDbContext.cs b/src/Services/Ordering/OrderSphere.Ordering.Infrastructure/Persistence/OrderingDbContext.cs index b8228382..4dd0fb7a 100644 --- a/src/Services/Ordering/OrderSphere.Ordering.Infrastructure/Persistence/OrderingDbContext.cs +++ b/src/Services/Ordering/OrderSphere.Ordering.Infrastructure/Persistence/OrderingDbContext.cs @@ -36,13 +36,7 @@ public sealed class OrderingDbContext( internal DbSet AuditLogEntries => Set(); public void AddOutboxMessage(string type, string content) - => OutboxMessages.Add(new OutboxMessage - { - Type = type, - Content = content, - // Capture the current trace context so the asynchronous dispatch joins this trace. - TraceParent = Activity.Current?.Id - }); + => OutboxMessages.Add(OutboxMessage.Create(type, content)); internal DbSet InboxMessages => Set(); private IDbContextTransaction? _transaction; diff --git a/src/Services/Ordering/OrderSphere.Ordering.Worker/Properties/launchSettings.json b/src/Services/Ordering/OrderSphere.Ordering.Worker/Properties/launchSettings.json new file mode 100644 index 00000000..2ea3cab9 --- /dev/null +++ b/src/Services/Ordering/OrderSphere.Ordering.Worker/Properties/launchSettings.json @@ -0,0 +1,10 @@ +{ + "profiles": { + "OrderSphere.Ordering.Worker": { + "commandName": "Project", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/src/Services/Ordering/OrderSphere.Ordering.Worker/Workers/CustomerErasureProcessor.cs b/src/Services/Ordering/OrderSphere.Ordering.Worker/Workers/CustomerErasureProcessor.cs index e92fdf16..20705243 100644 --- a/src/Services/Ordering/OrderSphere.Ordering.Worker/Workers/CustomerErasureProcessor.cs +++ b/src/Services/Ordering/OrderSphere.Ordering.Worker/Workers/CustomerErasureProcessor.cs @@ -71,7 +71,7 @@ await args.DeadLetterMessageAsync(args.Message, if (await inboxStore.HasBeenProcessedAsync(evt.Id, args.CancellationToken)) { - logger.LogInformation("Duplicate erasure-ordering event {EventId} — skipping.", evt.Id); + logger.DuplicateMessageIgnored(); await args.CompleteMessageAsync(args.Message); return; } @@ -100,9 +100,7 @@ await args.DeadLetterMessageAsync(args.Message, private Task OnError(ProcessErrorEventArgs args) { - logger.LogError(args.Exception, - "Service Bus processor error. Source: {Source}, Entity: {Entity}", - args.ErrorSource, args.EntityPath); + logger.ProcessorError(args.Exception, args.EntityPath, args.ErrorSource.ToString()); return Task.CompletedTask; } diff --git a/src/Services/Ordering/OrderSphere.Ordering.Worker/Workers/OrderHistoryProjector.cs b/src/Services/Ordering/OrderSphere.Ordering.Worker/Workers/OrderHistoryProjector.cs index a66fcf14..ae3e121d 100644 --- a/src/Services/Ordering/OrderSphere.Ordering.Worker/Workers/OrderHistoryProjector.cs +++ b/src/Services/Ordering/OrderSphere.Ordering.Worker/Workers/OrderHistoryProjector.cs @@ -117,9 +117,7 @@ internal async Task ProjectAsync( private Task OnError(ProcessErrorEventArgs args) { - logger.LogError(args.Exception, - "Service Bus processor error. Source: {Source}, Entity: {Entity}", - args.ErrorSource, args.EntityPath); + logger.ProcessorError(args.Exception, args.EntityPath, args.ErrorSource.ToString()); return Task.CompletedTask; } diff --git a/src/Services/Ordering/OrderSphere.Ordering.Worker/Workers/OrderProcessor.cs b/src/Services/Ordering/OrderSphere.Ordering.Worker/Workers/OrderProcessor.cs index 8b804754..40bc77b7 100644 --- a/src/Services/Ordering/OrderSphere.Ordering.Worker/Workers/OrderProcessor.cs +++ b/src/Services/Ordering/OrderSphere.Ordering.Worker/Workers/OrderProcessor.cs @@ -76,10 +76,9 @@ await args.DeadLetterMessageAsync(args.Message, if (result.IsSuccess) { await args.CompleteMessageAsync(args.Message); - // CorrelationId here is the business idempotency key on the event, distinct - // from the log correlation_id supplied by enrichment. - logger.LogInformation("Message processed. Event correlation {EventCorrelationId}", - evt.CorrelationId); + // The event's business idempotency key is already on the "Order ... created" + // record below; message_id, event_type and queue come from the scope. + logger.MessageProcessed(); } else { @@ -264,9 +263,7 @@ private static bool IsUniqueConstraintViolation(DbUpdateException ex) private Task OnError(ProcessErrorEventArgs args) { - logger.LogError(args.Exception, - "Service Bus processor error. Source: {Source}, Entity: {Entity}", - args.ErrorSource, args.EntityPath); + logger.ProcessorError(args.Exception, args.EntityPath, args.ErrorSource.ToString()); return Task.CompletedTask; } diff --git a/src/Services/Ordering/OrderSphere.Ordering.Worker/Workers/PaymentRefundProcessor.cs b/src/Services/Ordering/OrderSphere.Ordering.Worker/Workers/PaymentRefundProcessor.cs index 7970fa62..9d0f1bdb 100644 --- a/src/Services/Ordering/OrderSphere.Ordering.Worker/Workers/PaymentRefundProcessor.cs +++ b/src/Services/Ordering/OrderSphere.Ordering.Worker/Workers/PaymentRefundProcessor.cs @@ -91,7 +91,7 @@ internal async Task ProcessRefundAsync( { if (await inboxStore.HasBeenProcessedAsync(evt.Id, ct)) { - logger.LogInformation("Event {EventId} already processed.", evt.Id); + logger.DuplicateMessageIgnored(); return; } @@ -153,9 +153,7 @@ private async Task ProcessReturnRefundAsync( private Task OnError(ProcessErrorEventArgs args) { - logger.LogError(args.Exception, - "Service Bus processor error. Source: {Source}, Entity: {Entity}", - args.ErrorSource, args.EntityPath); + logger.ProcessorError(args.Exception, args.EntityPath, args.ErrorSource.ToString()); return Task.CompletedTask; } diff --git a/src/Services/Ordering/OrderSphere.Ordering.Worker/Workers/PaymentResultProcessor.cs b/src/Services/Ordering/OrderSphere.Ordering.Worker/Workers/PaymentResultProcessor.cs index 2225c04e..a70ac4d0 100644 --- a/src/Services/Ordering/OrderSphere.Ordering.Worker/Workers/PaymentResultProcessor.cs +++ b/src/Services/Ordering/OrderSphere.Ordering.Worker/Workers/PaymentResultProcessor.cs @@ -105,7 +105,7 @@ internal async Task ProcessPaymentResultAsync( { if (await inboxStore.HasBeenProcessedAsync(evt.Id, ct)) { - logger.LogInformation("Event {EventId} already processed.", evt.Id); + logger.DuplicateMessageIgnored(); return PaymentResultOutcome.AlreadyProcessed; } @@ -305,9 +305,7 @@ internal enum PaymentResultOutcome { Processed, AlreadyProcessed, OrderNotFound private Task OnError(ProcessErrorEventArgs args) { - logger.LogError(args.Exception, - "Service Bus processor error. Source: {Source}, Entity: {Entity}", - args.ErrorSource, args.EntityPath); + logger.ProcessorError(args.Exception, args.EntityPath, args.ErrorSource.ToString()); return Task.CompletedTask; } diff --git a/src/Services/Ordering/OrderSphere.Ordering.Worker/appsettings.Development.json b/src/Services/Ordering/OrderSphere.Ordering.Worker/appsettings.Development.json new file mode 100644 index 00000000..339d732b --- /dev/null +++ b/src/Services/Ordering/OrderSphere.Ordering.Worker/appsettings.Development.json @@ -0,0 +1,10 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning", + "Microsoft.Hosting.Lifetime": "Information", + "OrderSphere": "Debug" + } + } +} diff --git a/src/Services/Payment/OrderSphere.Payment.Api/appsettings.Development.json b/src/Services/Payment/OrderSphere.Payment.Api/appsettings.Development.json new file mode 100644 index 00000000..339d732b --- /dev/null +++ b/src/Services/Payment/OrderSphere.Payment.Api/appsettings.Development.json @@ -0,0 +1,10 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning", + "Microsoft.Hosting.Lifetime": "Information", + "OrderSphere": "Debug" + } + } +} diff --git a/src/Services/Payment/OrderSphere.Payment.Infrastructure/Migrations/20260907184525_AddOutboxCorrelationId.Designer.cs b/src/Services/Payment/OrderSphere.Payment.Infrastructure/Migrations/20260907184525_AddOutboxCorrelationId.Designer.cs new file mode 100644 index 00000000..bfcf6955 --- /dev/null +++ b/src/Services/Payment/OrderSphere.Payment.Infrastructure/Migrations/20260907184525_AddOutboxCorrelationId.Designer.cs @@ -0,0 +1,215 @@ +// +using System; +using System.Collections.Generic; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using OrderSphere.Payment.Infrastructure.Persistence; + +#nullable disable + +namespace OrderSphere.Payment.Infrastructure.Migrations +{ + [DbContext(typeof(PaymentDbContext))] + [Migration("20260907184525_AddOutboxCorrelationId")] + partial class AddOutboxCorrelationId + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.11") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("OrderSphere.BuildingBlocks.Auditing.AuditLogEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Action") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("ChangedBy") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Changes") + .IsRequired() + .HasColumnType("text"); + + b.Property("EntityId") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EntityType") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("OccurredAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("EntityType", "EntityId", "OccurredAt"); + + b.ToTable("audit_log_entries", (string)null); + }); + + modelBuilder.Entity("OrderSphere.BuildingBlocks.EventBus.Inbox.InboxMessage", b => + { + b.Property("EventId") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("EventType") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("ProcessedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("EventId"); + + b.HasIndex("ProcessedAt"); + + b.ToTable("inbox_messages", (string)null); + }); + + modelBuilder.Entity("OrderSphere.BuildingBlocks.EventBus.Outbox.OutboxMessage", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Content") + .IsRequired() + .HasColumnType("text"); + + b.Property("CorrelationId") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Error") + .HasColumnType("text"); + + b.Property("OccurredAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ProcessedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("RetryCount") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0); + + b.Property("TraceParent") + .HasMaxLength(55) + .HasColumnType("character varying(55)"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("xmin") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.HasKey("Id"); + + b.HasIndex("RetryCount"); + + b.HasIndex("ProcessedAt", "OccurredAt"); + + b.ToTable("outbox_messages", (string)null); + }); + + modelBuilder.Entity("OrderSphere.Payment.Domain.Entities.PaymentRecord", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CorrelationId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CustomerEmail") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("FailureReason") + .HasMaxLength(1024) + .HasColumnType("character varying(1024)"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("OrderId") + .HasColumnType("uuid"); + + b.Property("PaymentMethod") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("TransactionId") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.ComplexProperty(typeof(Dictionary), "Amount", "OrderSphere.Payment.Domain.Entities.PaymentRecord.Amount#Money", b1 => + { + b1.IsRequired(); + + b1.Property("Amount") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)") + .HasColumnName("Amount"); + + b1.Property("Currency") + .IsRequired() + .HasMaxLength(3) + .HasColumnType("character varying(3)") + .HasColumnName("Currency"); + }); + + b.HasKey("Id"); + + b.HasIndex("CorrelationId"); + + b.HasIndex("OrderId") + .IsUnique(); + + b.ToTable("payments", (string)null); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Services/Payment/OrderSphere.Payment.Infrastructure/Migrations/20260907184525_AddOutboxCorrelationId.cs b/src/Services/Payment/OrderSphere.Payment.Infrastructure/Migrations/20260907184525_AddOutboxCorrelationId.cs new file mode 100644 index 00000000..6a9b26d0 --- /dev/null +++ b/src/Services/Payment/OrderSphere.Payment.Infrastructure/Migrations/20260907184525_AddOutboxCorrelationId.cs @@ -0,0 +1,29 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace OrderSphere.Payment.Infrastructure.Migrations +{ + /// + public partial class AddOutboxCorrelationId : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "CorrelationId", + table: "outbox_messages", + type: "character varying(128)", + maxLength: 128, + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "CorrelationId", + table: "outbox_messages"); + } + } +} diff --git a/src/Services/Payment/OrderSphere.Payment.Infrastructure/Migrations/PaymentDbContextModelSnapshot.cs b/src/Services/Payment/OrderSphere.Payment.Infrastructure/Migrations/PaymentDbContextModelSnapshot.cs index 68417199..931be44e 100644 --- a/src/Services/Payment/OrderSphere.Payment.Infrastructure/Migrations/PaymentDbContextModelSnapshot.cs +++ b/src/Services/Payment/OrderSphere.Payment.Infrastructure/Migrations/PaymentDbContextModelSnapshot.cs @@ -18,7 +18,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) { #pragma warning disable 612, 618 modelBuilder - .HasAnnotation("ProductVersion", "10.0.9") + .HasAnnotation("ProductVersion", "10.0.11") .HasAnnotation("Relational:MaxIdentifierLength", 63); NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); @@ -93,6 +93,10 @@ protected override void BuildModel(ModelBuilder modelBuilder) .IsRequired() .HasColumnType("text"); + b.Property("CorrelationId") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + b.Property("Error") .HasColumnType("text"); diff --git a/src/Services/Payment/OrderSphere.Payment.Infrastructure/Persistence/PaymentDbContext.cs b/src/Services/Payment/OrderSphere.Payment.Infrastructure/Persistence/PaymentDbContext.cs index 06dee7e2..c18cf11e 100644 --- a/src/Services/Payment/OrderSphere.Payment.Infrastructure/Persistence/PaymentDbContext.cs +++ b/src/Services/Payment/OrderSphere.Payment.Infrastructure/Persistence/PaymentDbContext.cs @@ -26,13 +26,7 @@ public sealed class PaymentDbContext( internal DbSet AuditLogEntries => Set(); public void AddOutboxMessage(string type, string content) - => OutboxMessages.Add(new OutboxMessage - { - Type = type, - Content = content, - // Capture the current trace context so the asynchronous dispatch joins this trace. - TraceParent = Activity.Current?.Id - }); + => OutboxMessages.Add(OutboxMessage.Create(type, content)); public override async Task SaveChangesAsync(CancellationToken cancellationToken = default) { diff --git a/src/Services/Payment/OrderSphere.Payment.Infrastructure/Providers/StripePaymentProvider.cs b/src/Services/Payment/OrderSphere.Payment.Infrastructure/Providers/StripePaymentProvider.cs index ce8b6536..9baf3d4c 100644 --- a/src/Services/Payment/OrderSphere.Payment.Infrastructure/Providers/StripePaymentProvider.cs +++ b/src/Services/Payment/OrderSphere.Payment.Infrastructure/Providers/StripePaymentProvider.cs @@ -49,8 +49,7 @@ public async Task> AuthorizeAsync(PaymentRequest r } catch (StripeException ex) { - logger.LogWarning(ex, "Stripe authorization failed for order {OrderId}: {Message}", - request.OrderId, ex.Message); + logger.LogWarning(ex, "Stripe authorization failed for order {OrderId}.", request.OrderId); return Result.Failure(PaymentErrors.AuthorizationFailed); } } @@ -67,7 +66,7 @@ public async Task> CaptureAsync(string transaction } catch (StripeException ex) { - logger.LogWarning(ex, "Stripe capture failed for intent {IntentId}: {Message}", transactionId, ex.Message); + logger.LogWarning(ex, "Stripe capture failed for intent {IntentId}.", transactionId); return Result.Failure(PaymentErrors.CaptureFailed); } } @@ -87,7 +86,7 @@ await service.CreateAsync(new RefundCreateOptions } catch (StripeException ex) { - logger.LogWarning(ex, "Stripe refund failed for intent {IntentId}: {Message}", transactionId, ex.Message); + logger.LogWarning(ex, "Stripe refund failed for intent {IntentId}.", transactionId); return Result.Failure(PaymentErrors.RefundFailed); } } diff --git a/src/Services/Payment/OrderSphere.Payment.Worker/Properties/launchSettings.json b/src/Services/Payment/OrderSphere.Payment.Worker/Properties/launchSettings.json new file mode 100644 index 00000000..99d63286 --- /dev/null +++ b/src/Services/Payment/OrderSphere.Payment.Worker/Properties/launchSettings.json @@ -0,0 +1,10 @@ +{ + "profiles": { + "OrderSphere.Payment.Worker": { + "commandName": "Project", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/src/Services/Payment/OrderSphere.Payment.Worker/Workers/CustomerErasureProcessor.cs b/src/Services/Payment/OrderSphere.Payment.Worker/Workers/CustomerErasureProcessor.cs index de8fda52..fb2061d0 100644 --- a/src/Services/Payment/OrderSphere.Payment.Worker/Workers/CustomerErasureProcessor.cs +++ b/src/Services/Payment/OrderSphere.Payment.Worker/Workers/CustomerErasureProcessor.cs @@ -70,7 +70,7 @@ await args.DeadLetterMessageAsync(args.Message, if (await inboxStore.HasBeenProcessedAsync(evt.Id, args.CancellationToken)) { - logger.LogInformation("Duplicate erasure-payment event {EventId} — skipping.", evt.Id); + logger.DuplicateMessageIgnored(); await args.CompleteMessageAsync(args.Message); return; } @@ -98,9 +98,7 @@ await args.DeadLetterMessageAsync(args.Message, private Task OnError(ProcessErrorEventArgs args) { - logger.LogError(args.Exception, - "Service Bus processor error. Source: {Source}, Entity: {Entity}", - args.ErrorSource, args.EntityPath); + logger.ProcessorError(args.Exception, args.EntityPath, args.ErrorSource.ToString()); return Task.CompletedTask; } diff --git a/src/Services/Payment/OrderSphere.Payment.Worker/Workers/OrderConfirmationFailedProcessor.cs b/src/Services/Payment/OrderSphere.Payment.Worker/Workers/OrderConfirmationFailedProcessor.cs index 84d84035..7b62fab5 100644 --- a/src/Services/Payment/OrderSphere.Payment.Worker/Workers/OrderConfirmationFailedProcessor.cs +++ b/src/Services/Payment/OrderSphere.Payment.Worker/Workers/OrderConfirmationFailedProcessor.cs @@ -97,7 +97,7 @@ internal async Task ProcessConfirmationFailureAsync( { if (await inboxStore.HasBeenProcessedAsync(evt.Id, ct)) { - logger.LogInformation("Event {EventId} already processed.", evt.Id); + logger.DuplicateMessageIgnored(); return; } @@ -159,9 +159,7 @@ internal async Task ProcessConfirmationFailureAsync( private Task OnError(ProcessErrorEventArgs args) { - logger.LogError(args.Exception, - "Service Bus processor error. Source: {Source}, Entity: {Entity}", - args.ErrorSource, args.EntityPath); + logger.ProcessorError(args.Exception, args.EntityPath, args.ErrorSource.ToString()); return Task.CompletedTask; } diff --git a/src/Services/Payment/OrderSphere.Payment.Worker/Workers/PaymentProcessor.cs b/src/Services/Payment/OrderSphere.Payment.Worker/Workers/PaymentProcessor.cs index bfe7a406..7f608a98 100644 --- a/src/Services/Payment/OrderSphere.Payment.Worker/Workers/PaymentProcessor.cs +++ b/src/Services/Payment/OrderSphere.Payment.Worker/Workers/PaymentProcessor.cs @@ -51,7 +51,6 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) private async Task OnMessageReceived(ProcessMessageEventArgs args) { using var messageScope = MessageProcessingScope.Begin(logger, args.Message, QueueName); - var messageId = args.Message.MessageId; logger.MessageReceived(); try @@ -75,7 +74,7 @@ await args.DeadLetterMessageAsync(args.Message, if (await inboxStore.HasBeenProcessedAsync(evt.Id)) { - logger.LogInformation("Event {EventId} already processed. Completing message.", evt.Id); + logger.DuplicateMessageIgnored(); await args.CompleteMessageAsync(args.Message); return; } @@ -100,7 +99,7 @@ await args.DeadLetterMessageAsync(args.Message, await args.CompleteMessageAsync(args.Message); logger.LogInformation("Payment message processed. OrderId: {OrderId}, Succeeded: {Succeeded}", - messageId, evt.OrderId, succeeded); + evt.OrderId, succeeded); } catch (Exception ex) { @@ -202,9 +201,8 @@ private static void EnqueuePaymentProcessedOutboxMessage( private Task OnError(ProcessErrorEventArgs args) { - logger.LogError(args.Exception, - "Service Bus processor error. Source: {Source}, Entity: {Entity}", - args.ErrorSource, args.EntityPath); + logger.ProcessorError(args.Exception, args.EntityPath, args.ErrorSource.ToString()); + return Task.CompletedTask; } diff --git a/src/Services/Payment/OrderSphere.Payment.Worker/Workers/RefundRequestedProcessor.cs b/src/Services/Payment/OrderSphere.Payment.Worker/Workers/RefundRequestedProcessor.cs index 251c2c29..9df297d6 100644 --- a/src/Services/Payment/OrderSphere.Payment.Worker/Workers/RefundRequestedProcessor.cs +++ b/src/Services/Payment/OrderSphere.Payment.Worker/Workers/RefundRequestedProcessor.cs @@ -97,7 +97,7 @@ internal async Task ProcessRefundRequestAsync( { if (await inboxStore.HasBeenProcessedAsync(evt.Id, ct)) { - logger.LogInformation("Event {EventId} already processed.", evt.Id); + logger.DuplicateMessageIgnored(); return; } @@ -163,9 +163,7 @@ internal async Task ProcessRefundRequestAsync( private Task OnError(ProcessErrorEventArgs args) { - logger.LogError(args.Exception, - "Service Bus processor error. Source: {Source}, Entity: {Entity}", - args.ErrorSource, args.EntityPath); + logger.ProcessorError(args.Exception, args.EntityPath, args.ErrorSource.ToString()); return Task.CompletedTask; } diff --git a/src/Services/UserProfile/OrderSphere.UserProfile.Api/appsettings.Development.json b/src/Services/UserProfile/OrderSphere.UserProfile.Api/appsettings.Development.json new file mode 100644 index 00000000..339d732b --- /dev/null +++ b/src/Services/UserProfile/OrderSphere.UserProfile.Api/appsettings.Development.json @@ -0,0 +1,10 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning", + "Microsoft.Hosting.Lifetime": "Information", + "OrderSphere": "Debug" + } + } +} diff --git a/src/Services/UserProfile/OrderSphere.UserProfile.Infrastructure/Migrations/20260907184537_AddOutboxCorrelationId.Designer.cs b/src/Services/UserProfile/OrderSphere.UserProfile.Infrastructure/Migrations/20260907184537_AddOutboxCorrelationId.Designer.cs new file mode 100644 index 00000000..9c755e42 --- /dev/null +++ b/src/Services/UserProfile/OrderSphere.UserProfile.Infrastructure/Migrations/20260907184537_AddOutboxCorrelationId.Designer.cs @@ -0,0 +1,327 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using OrderSphere.UserProfile.Infrastructure.Persistence; + +#nullable disable + +namespace OrderSphere.UserProfile.Infrastructure.Migrations +{ + [DbContext(typeof(UserProfileDbContext))] + [Migration("20260907184537_AddOutboxCorrelationId")] + partial class AddOutboxCorrelationId + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.11") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("OrderSphere.BuildingBlocks.Auditing.AuditLogEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Action") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("ChangedBy") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Changes") + .IsRequired() + .HasColumnType("text"); + + b.Property("EntityId") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EntityType") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("OccurredAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("EntityType", "EntityId", "OccurredAt"); + + b.ToTable("audit_log_entries", (string)null); + }); + + modelBuilder.Entity("OrderSphere.BuildingBlocks.EventBus.Outbox.OutboxMessage", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Content") + .IsRequired() + .HasColumnType("text"); + + b.Property("CorrelationId") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Error") + .HasColumnType("text"); + + b.Property("OccurredAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ProcessedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("RetryCount") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0); + + b.Property("TraceParent") + .HasMaxLength(55) + .HasColumnType("character varying(55)"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("xmin") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.HasKey("Id"); + + b.HasIndex("RetryCount"); + + b.HasIndex("ProcessedAt", "OccurredAt"); + + b.ToTable("outbox_messages", (string)null); + }); + + modelBuilder.Entity("OrderSphere.UserProfile.Domain.Entities.CustomerProfile", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DarkModeEnabled") + .HasColumnType("boolean"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(320) + .HasColumnType("character varying(320)"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("IsOnboardingComplete") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("Subject") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("Subject") + .IsUnique(); + + b.ToTable("CustomerProfiles", (string)null); + }); + + modelBuilder.Entity("OrderSphere.UserProfile.Domain.Entities.SavedAddress", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("City") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Country") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CustomerProfileId") + .HasColumnType("uuid"); + + b.Property("FirstName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IsDefault") + .HasColumnType("boolean"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("Label") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("LastName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("PostalCode") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("Street") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("character varying(300)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("CustomerProfileId"); + + b.ToTable("SavedAddresses", (string)null); + }); + + modelBuilder.Entity("OrderSphere.UserProfile.Domain.Entities.Tenant", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("Slug") + .IsUnique(); + + b.ToTable("Tenants", (string)null); + }); + + modelBuilder.Entity("OrderSphere.UserProfile.Domain.Entities.CustomerProfile", b => + { + b.OwnsOne("OrderSphere.UserProfile.Domain.Entities.NotificationPreferences", "NotificationPreferences", b1 => + { + b1.Property("CustomerProfileId") + .HasColumnType("uuid"); + + b1.Property("ConsentedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("notification_consented_at"); + + b1.Property("EmailEnabled") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true) + .HasColumnName("notification_email_enabled"); + + b1.Property("PushEnabled") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("notification_push_enabled"); + + b1.Property("SmsEnabled") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("notification_sms_enabled"); + + b1.HasKey("CustomerProfileId"); + + b1.ToTable("CustomerProfiles"); + + b1.WithOwner() + .HasForeignKey("CustomerProfileId"); + }); + + b.Navigation("NotificationPreferences") + .IsRequired(); + }); + + modelBuilder.Entity("OrderSphere.UserProfile.Domain.Entities.SavedAddress", b => + { + b.HasOne("OrderSphere.UserProfile.Domain.Entities.CustomerProfile", null) + .WithMany("Addresses") + .HasForeignKey("CustomerProfileId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("OrderSphere.UserProfile.Domain.Entities.CustomerProfile", b => + { + b.Navigation("Addresses"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Services/UserProfile/OrderSphere.UserProfile.Infrastructure/Migrations/20260907184537_AddOutboxCorrelationId.cs b/src/Services/UserProfile/OrderSphere.UserProfile.Infrastructure/Migrations/20260907184537_AddOutboxCorrelationId.cs new file mode 100644 index 00000000..08e9b94a --- /dev/null +++ b/src/Services/UserProfile/OrderSphere.UserProfile.Infrastructure/Migrations/20260907184537_AddOutboxCorrelationId.cs @@ -0,0 +1,29 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace OrderSphere.UserProfile.Infrastructure.Migrations +{ + /// + public partial class AddOutboxCorrelationId : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "CorrelationId", + table: "outbox_messages", + type: "character varying(128)", + maxLength: 128, + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "CorrelationId", + table: "outbox_messages"); + } + } +} diff --git a/src/Services/UserProfile/OrderSphere.UserProfile.Infrastructure/Migrations/UserProfileDbContextModelSnapshot.cs b/src/Services/UserProfile/OrderSphere.UserProfile.Infrastructure/Migrations/UserProfileDbContextModelSnapshot.cs index 59dad50a..e5d3a75d 100644 --- a/src/Services/UserProfile/OrderSphere.UserProfile.Infrastructure/Migrations/UserProfileDbContextModelSnapshot.cs +++ b/src/Services/UserProfile/OrderSphere.UserProfile.Infrastructure/Migrations/UserProfileDbContextModelSnapshot.cs @@ -17,7 +17,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) { #pragma warning disable 612, 618 modelBuilder - .HasAnnotation("ProductVersion", "10.0.9") + .HasAnnotation("ProductVersion", "10.0.11") .HasAnnotation("Relational:MaxIdentifierLength", 63); NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); @@ -71,6 +71,10 @@ protected override void BuildModel(ModelBuilder modelBuilder) .IsRequired() .HasColumnType("text"); + b.Property("CorrelationId") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + b.Property("Error") .HasColumnType("text"); diff --git a/src/Services/UserProfile/OrderSphere.UserProfile.Infrastructure/Persistence/UserProfileDbContext.cs b/src/Services/UserProfile/OrderSphere.UserProfile.Infrastructure/Persistence/UserProfileDbContext.cs index 02b0c594..b94b85eb 100644 --- a/src/Services/UserProfile/OrderSphere.UserProfile.Infrastructure/Persistence/UserProfileDbContext.cs +++ b/src/Services/UserProfile/OrderSphere.UserProfile.Infrastructure/Persistence/UserProfileDbContext.cs @@ -26,13 +26,7 @@ public sealed class UserProfileDbContext( internal DbSet AuditLogEntries => Set(); public void AddOutboxMessage(string type, string content) - => OutboxMessages.Add(new OutboxMessage - { - Type = type, - Content = content, - // Capture the current trace context so the asynchronous dispatch joins this trace. - TraceParent = Activity.Current?.Id - }); + => OutboxMessages.Add(OutboxMessage.Create(type, content)); public override async Task SaveChangesAsync(CancellationToken cancellationToken = default) { diff --git a/src/Services/Webhooks/OrderSphere.Webhooks.Worker/Properties/launchSettings.json b/src/Services/Webhooks/OrderSphere.Webhooks.Worker/Properties/launchSettings.json new file mode 100644 index 00000000..5faf689b --- /dev/null +++ b/src/Services/Webhooks/OrderSphere.Webhooks.Worker/Properties/launchSettings.json @@ -0,0 +1,10 @@ +{ + "profiles": { + "OrderSphere.Webhooks.Worker": { + "commandName": "Project", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/src/Services/Webhooks/OrderSphere.Webhooks.Worker/Workers/WebhookDeliveryProcessor.cs b/src/Services/Webhooks/OrderSphere.Webhooks.Worker/Workers/WebhookDeliveryProcessor.cs index 2f55d340..da54dab7 100644 --- a/src/Services/Webhooks/OrderSphere.Webhooks.Worker/Workers/WebhookDeliveryProcessor.cs +++ b/src/Services/Webhooks/OrderSphere.Webhooks.Worker/Workers/WebhookDeliveryProcessor.cs @@ -1,6 +1,7 @@ using System.Security.Cryptography; using System.Text; using Microsoft.EntityFrameworkCore; +using OrderSphere.BuildingBlocks.Diagnostics; using OrderSphere.BuildingBlocks.Locking; using OrderSphere.Webhooks.Domain.Enums; using OrderSphere.Webhooks.Infrastructure.Persistence; @@ -36,16 +37,24 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) while (!stoppingToken.IsCancellationRequested) { - try + // Covers the whole batch, including the outbound HTTP calls. Beyond grouping this + // iteration's records, it gives CorrelationPropagationHandler an ambient id to put on + // the "WebhookDelivery" client — the handler was already registered but never fired + // here, because nothing had opened a correlation scope, so a partner's failed + // delivery carried no id linking it back to the event that produced it. + using (BackgroundOperationScope.Begin("webhook-delivery")) { - var delivered = await ProcessPendingDeliveriesAsync(stoppingToken); - if (delivered == 0) + try + { + var delivered = await ProcessPendingDeliveriesAsync(stoppingToken); + if (delivered == 0) + await Task.Delay(PollInterval, stoppingToken).ConfigureAwait(ConfigureAwaitOptions.SuppressThrowing); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + logger.LogError(ex, "Unhandled error in webhook delivery loop."); await Task.Delay(PollInterval, stoppingToken).ConfigureAwait(ConfigureAwaitOptions.SuppressThrowing); - } - catch (Exception ex) when (ex is not OperationCanceledException) - { - logger.LogError(ex, "Unhandled error in webhook delivery loop."); - await Task.Delay(PollInterval, stoppingToken).ConfigureAwait(ConfigureAwaitOptions.SuppressThrowing); + } } } } diff --git a/src/Services/Webhooks/OrderSphere.Webhooks.Worker/Workers/WebhookEventProcessor.cs b/src/Services/Webhooks/OrderSphere.Webhooks.Worker/Workers/WebhookEventProcessor.cs index a3b1d8aa..57a6fe4b 100644 --- a/src/Services/Webhooks/OrderSphere.Webhooks.Worker/Workers/WebhookEventProcessor.cs +++ b/src/Services/Webhooks/OrderSphere.Webhooks.Worker/Workers/WebhookEventProcessor.cs @@ -4,6 +4,7 @@ using OrderSphere.BuildingBlocks.Contracts.Events; using OrderSphere.BuildingBlocks.EventBus.AzureServiceBus; using OrderSphere.BuildingBlocks.EventBus.Inbox; +using OrderSphere.BuildingBlocks.StronglyTypedIds; using OrderSphere.Webhooks.Domain.Entities; using OrderSphere.Webhooks.Domain.Enums; using OrderSphere.Webhooks.Infrastructure.Persistence; @@ -35,17 +36,18 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) _processor.ProcessErrorAsync += ProcessErrorAsync; await _processor.StartProcessingAsync(stoppingToken); + logger.ProcessorStarted(nameof(WebhookEventProcessor), QueueName); // Keep the service alive until shutdown is requested. await Task.Delay(Timeout.Infinite, stoppingToken).ConfigureAwait(ConfigureAwaitOptions.SuppressThrowing); await _processor.StopProcessingAsync(); + logger.ProcessorStopped(nameof(WebhookEventProcessor)); } private async Task ProcessMessageAsync(ProcessMessageEventArgs args) { using var messageScope = MessageProcessingScope.Begin(logger, args.Message, QueueName); - var messageId = args.Message.MessageId; logger.MessageReceived(); using var scope = scopeFactory.CreateScope(); @@ -75,8 +77,7 @@ await args.DeadLetterMessageAsync(args.Message, } catch (Exception ex) { - logger.LogError(ex, - "Message body could not be deserialized. Dead-lettering."); + logger.MessageUndeserializable(ex); await args.DeadLetterMessageAsync(args.Message, deadLetterReason: "DeserializationFailed", deadLetterErrorDescription: ex.Message, @@ -84,10 +85,15 @@ await args.DeadLetterMessageAsync(args.Message, return; } + // Must be set before the Subscriptions query below: the tenant query filter reads + // ITenantContext at query-execution time, so resolving the DbContext earlier is fine, + // but executing a query before this line would scope it to the wrong tenant. + messageScope.SetTenant(ExtractTenantId(body)); + // Inbox check — idempotent processing. if (await inboxStore.HasBeenProcessedAsync(eventId, args.CancellationToken)) { - logger.LogInformation("Event {EventId} already processed (inbox). Completing message.", eventId); + logger.DuplicateMessageIgnored(); await args.CompleteMessageAsync(args.Message, args.CancellationToken); return; } @@ -98,7 +104,7 @@ await args.DeadLetterMessageAsync(args.Message, { logger.LogWarning( "Message has event type '{EventType}' with no webhook mapping. Dead-lettering.", - messageId, eventType); + eventType); await args.DeadLetterMessageAsync(args.Message, deadLetterReason: "UnknownEventType", deadLetterErrorDescription: $"No webhook mapping for event type '{eventType}'.", @@ -117,14 +123,11 @@ await args.DeadLetterMessageAsync(args.Message, .Where(s => s.ListensTo(webhookEventType.Value)) .ToList(); - if (matchingSubscriptions.Count == 0) - { - logger.LogDebug("No active subscriptions for event type {EventType}.", eventTypeName); - await inboxStore.MarkAsProcessedAsync(eventId, eventType, args.CancellationToken); - await args.CompleteMessageAsync(args.Message, args.CancellationToken); - return; - } - + // "No subscriber" is a normal outcome, not a special case: it takes the same path and + // produces the same Information record with Count = 0. Previously it returned early + // with only a Debug line, which made a completed message indistinguishable from a + // processor that never ran — see docs/logging.md, one Information record per message. + // // Create a delivery record for each matching subscription. foreach (var sub in matchingSubscriptions) { @@ -154,9 +157,8 @@ await args.DeadLetterMessageAsync(args.Message, private Task ProcessErrorAsync(ProcessErrorEventArgs args) { - logger.LogError(args.Exception, - "Service Bus processor error. Source: {Source}, Entity: {Entity}", - args.ErrorSource, args.EntityPath); + logger.ProcessorError(args.Exception, args.EntityPath, args.ErrorSource.ToString()); + return Task.CompletedTask; } @@ -186,6 +188,32 @@ private static Guid ExtractEventId(string body) return Guid.NewGuid(); } + /// + /// Reads the tenant off the raw event body. This processor never deserializes a typed event + /// (it dispatches on the event type string), but every IntegrationEvent serializes + /// TenantId from the base record, so the value is present on the wire. + /// + /// Falls back to TenantId.Default rather than throwing: messages published before the + /// tenant was propagated carry no usable value, and dead-lettering those would turn a + /// diagnostics improvement into dropped webhook deliveries. + /// + /// + private static Guid ExtractTenantId(string body) + { + try + { + using var doc = JsonDocument.Parse(body); + if (doc.RootElement.TryGetProperty("TenantId", out var prop) + && prop.TryGetGuid(out var tenantId)) + { + return tenantId; + } + } + catch { /* Body already validated as JSON by ExtractEventId; be defensive anyway. */ } + + return TenantId.Default; + } + private static WebhookEventType? MapToWebhookEventType(string eventType) => eventType switch { nameof(OrderPlacedIntegrationEvent) or "OrderPlaced" => WebhookEventType.OrderPlaced, diff --git a/tests/OrderSphere.Domain.Tests/Security/AmbientTenantContextTests.cs b/tests/OrderSphere.Domain.Tests/Security/AmbientTenantContextTests.cs new file mode 100644 index 00000000..2233d3d0 --- /dev/null +++ b/tests/OrderSphere.Domain.Tests/Security/AmbientTenantContextTests.cs @@ -0,0 +1,72 @@ +using FluentAssertions; +using OrderSphere.BuildingBlocks.Security; +using Xunit; + +namespace OrderSphere.Domain.Tests.Security; + +/// +/// The ambient tenant slot underpins four separate mechanisms — log enrichment, EF audit +/// stamping, the tenant query filter, and IntegrationEvent.TenantId's default — so its +/// scoping behaviour is load-bearing well beyond diagnostics. These mirror +/// . +/// +public sealed class AmbientTenantContextTests +{ + [Fact] + public void Ambient_is_null_outside_any_scope() + { + AmbientTenantContext.Ambient.Should().BeNull(); + } + + [Fact] + public void Scope_sets_and_restores_the_ambient_value() + { + var tenant = Guid.NewGuid(); + + using (AmbientTenantContext.BeginScope(tenant)) + { + AmbientTenantContext.Ambient.Should().Be(tenant); + } + + AmbientTenantContext.Ambient.Should().BeNull(); + } + + [Fact] + public void Nested_scope_restores_the_previous_value_not_null() + { + // A request scope (opened by RequestContextEnrichmentMiddleware) can enclose a nested + // operation. If the inner scope restored null instead of the outer tenant, the remainder + // of the request would silently read and write the default tenant. + var outer = Guid.NewGuid(); + var inner = Guid.NewGuid(); + + using (AmbientTenantContext.BeginScope(outer)) + { + using (AmbientTenantContext.BeginScope(inner)) + { + AmbientTenantContext.Ambient.Should().Be(inner); + } + + AmbientTenantContext.Ambient.Should().Be(outer); + } + } + + [Fact] + public async Task Scope_does_not_leak_into_a_parallel_flow() + { + Guid? observed = Guid.Empty; + + var other = Task.Run(async () => + { + await Task.Yield(); + observed = AmbientTenantContext.Ambient; + }); + + using (AmbientTenantContext.BeginScope(Guid.NewGuid())) + { + await other; + } + + observed.Should().BeNull(); + } +} diff --git a/tests/OrderSphere.EventBus.AzureServiceBus.Tests/EventBusDiagnosticsTests.cs b/tests/OrderSphere.EventBus.AzureServiceBus.Tests/EventBusDiagnosticsTests.cs new file mode 100644 index 00000000..5c1fef64 --- /dev/null +++ b/tests/OrderSphere.EventBus.AzureServiceBus.Tests/EventBusDiagnosticsTests.cs @@ -0,0 +1,111 @@ +using System.Diagnostics; +using Azure.Messaging.ServiceBus; +using FluentAssertions; +using OrderSphere.BuildingBlocks.EventBus.AzureServiceBus; +using OrderSphere.BuildingBlocks.Security; +using Xunit; + +namespace OrderSphere.EventBus.AzureServiceBus.Tests; + +/// +/// Covers the outbox correlation boundary. There was no coverage here before, which is how the +/// assumption that correlation_id == trace_id survived as a code comment while two +/// production paths quietly broke it: the API Gateway honours a client-supplied +/// X-Request-Id, and a consumer falls back to the Service Bus message id. +/// +public sealed class EventBusDiagnosticsTests +{ + private const string SampleTraceParent = + "00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01"; + + private const string SampleTraceId = "0af7651916cd43dd8448eb211c80319c"; + + [Fact] + public void Persisted_correlation_id_wins_over_the_trace_id() + { + // The case the old derivation got wrong: a client chose the id, so it is not the trace id. + using (EventBusDiagnostics.RestorePublishParent(SampleTraceParent, "client-supplied-id")) + { + AmbientCorrelationContext.Ambient.Should().Be("client-supplied-id"); + } + } + + [Fact] + public void Falls_back_to_the_trace_id_for_rows_written_before_the_column_existed() + { + // Backwards compatibility only. Those rows really were correlated by the trace id, + // because the gateway seeded X-Request-Id from it. + using (EventBusDiagnostics.RestorePublishParent(SampleTraceParent, correlationId: null)) + { + AmbientCorrelationContext.Ambient.Should().Be(SampleTraceId); + } + } + + [Fact] + public void Opens_a_scope_even_without_a_usable_trace_context() + { + // Previously this opened no scope at all, so Inject() omitted x-request-id, the consumer + // fell through to the message id, and that value was persisted on the next outbox hop — + // severing the chain permanently. + using (EventBusDiagnostics.RestorePublishParent(traceParent: null, "corr-1")) + { + AmbientCorrelationContext.Ambient.Should().Be("corr-1"); + } + } + + [Fact] + public void Injects_the_correlation_id_onto_the_message_without_a_trace_context() + { + var message = new ServiceBusMessage(); + + using (EventBusDiagnostics.RestorePublishParent(traceParent: null, "corr-1")) + { + EventBusDiagnostics.Inject(message); + } + + message.ApplicationProperties.Should().ContainKey("x-request-id") + .WhoseValue.Should().Be("corr-1"); + } + + [Fact] + public void Opens_no_scope_when_there_is_neither_trace_nor_correlation() + { + using (EventBusDiagnostics.RestorePublishParent(traceParent: null, correlationId: null)) + { + AmbientCorrelationContext.Ambient.Should().BeNull(); + } + } + + [Fact] + public void Disposal_restores_the_previous_ambient_value() + { + using (AmbientCorrelationContext.BeginScope("outer")) + { + using (EventBusDiagnostics.RestorePublishParent(SampleTraceParent, "inner")) + { + AmbientCorrelationContext.Ambient.Should().Be("inner"); + } + + AmbientCorrelationContext.Ambient.Should().Be("outer"); + } + } + + [Fact] + public void Round_trips_a_client_supplied_id_from_publish_to_consume() + { + // The end-to-end property the CorrelationId column exists to guarantee: an id chosen at + // the edge survives the outbox boundary and is what the consumer reads back. + var published = new ServiceBusMessage(); + + using (EventBusDiagnostics.RestorePublishParent(SampleTraceParent, "client-supplied-id")) + { + EventBusDiagnostics.Inject(published); + } + + var received = ServiceBusModelFactory.ServiceBusReceivedMessage( + messageId: Guid.NewGuid().ToString(), + properties: published.ApplicationProperties); + + EventBusDiagnostics.ReadCorrelationId(received).Should().Be("client-supplied-id"); + } +} diff --git a/tests/OrderSphere.EventBus.AzureServiceBus.Tests/MessageProcessingScopeTests.cs b/tests/OrderSphere.EventBus.AzureServiceBus.Tests/MessageProcessingScopeTests.cs index 56f77fba..ba807c16 100644 --- a/tests/OrderSphere.EventBus.AzureServiceBus.Tests/MessageProcessingScopeTests.cs +++ b/tests/OrderSphere.EventBus.AzureServiceBus.Tests/MessageProcessingScopeTests.cs @@ -35,8 +35,12 @@ public void Begin_opens_the_correlation_slot_from_the_message() [Fact] public void Begin_falls_back_to_the_trace_id_when_no_correlation_property_is_present() { - // Messages published through the outbox carry only traceparent; the trace id is the - // correlation id by construction (the gateway seeds X-Request-Id from it). + // Legacy path: messages published before the correlation property existed carry only + // traceparent, and the trace id is the best available correlation id for them. This is + // not a claim that the two values are equivalent — they are not, which is why the outbox + // now persists the correlation id in its own column (see EventBusDiagnosticsTests). + // Current publishers always set x-request-id, so this branch covers in-flight messages + // across a deploy, not steady state. var traceId = ActivityTraceId.CreateRandom().ToString(); var spanId = ActivitySpanId.CreateRandom().ToString(); var message = Message(properties: new Dictionary diff --git a/tests/OrderSphere.IntegrationTests/Api/RequestTenantScopeTests.cs b/tests/OrderSphere.IntegrationTests/Api/RequestTenantScopeTests.cs new file mode 100644 index 00000000..af741c85 --- /dev/null +++ b/tests/OrderSphere.IntegrationTests/Api/RequestTenantScopeTests.cs @@ -0,0 +1,112 @@ +using System.Net; +using System.Net.Http.Json; +using System.Text.Json; +using FluentAssertions; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using OrderSphere.BuildingBlocks.EventBus.Outbox; +using OrderSphere.UserProfile.Infrastructure.Persistence; +using Xunit; +using TenantIdHelper = OrderSphere.BuildingBlocks.StronglyTypedIds.TenantId; + +namespace OrderSphere.IntegrationTests.Api; + +/// +/// Locks in the request-side half of ADR 0012. +/// +/// The load-bearing claim is that opening the ambient tenant scope once, in +/// RequestContextEnrichmentMiddleware, is sufficient — no command handler needs to assign +/// TenantId, because IntegrationEvent.TenantId's default reads the ambient slot at +/// construction time and the handler runs inside the request flow. Before that middleware existed +/// the scope was opened on no HTTP path at all, so every API-originated event was staged with +/// Guid.Empty and every downstream service inherited it. +/// +/// +public sealed class RequestTenantScopeTests : IClassFixture +{ + private const string OrgId = "org_tenant_scope_tests"; + + private readonly UserProfileApiFactory _factory; + + public RequestTenantScopeTests(UserProfileApiFactory factory) => _factory = factory; + + private HttpClient Client(string sub, string? org = null) + { + var client = _factory.CreateClient(); + client.DefaultRequestHeaders.Add(TestAuthHandler.SubHeader, sub); + if (org is not null) + client.DefaultRequestHeaders.Add(TestAuthHandler.OrgHeader, org); + return client; + } + + private async Task StageErasureAndReadOutboxAsync(string sub, string? org) + { + var client = Client(sub, org); + + // Auto-provisions the profile, so the erasure command below finds one to anonymize. + await client.GetAsync("api/v1/profile"); + + var response = await client.PostAsJsonAsync("api/v1/profile/erasure-request", new { }); + response.StatusCode.Should().Be(HttpStatusCode.NoContent, "the profile exists at this point"); + + using var scope = _factory.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + var rows = await db.Set() + .Where(m => m.Content.Contains(sub)) + .ToListAsync(); + + return rows.Should().ContainSingle().Subject; + } + + [Fact] + public async Task Staged_integration_event_carries_the_tenant_from_the_org_claim() + { + // The crux: no handler assigns TenantId. If the middleware does not open the scope, the + // serialized event body carries Guid.Empty and this fails. + var row = await StageErasureAndReadOutboxAsync("auth0|tenant-scope-org", OrgId); + + var tenantId = JsonDocument.Parse(row.Content) + .RootElement.GetProperty("TenantId").GetGuid(); + + tenantId.Should().Be(TenantIdHelper.FromOrgId(OrgId)); + tenantId.Should().NotBe(TenantIdHelper.Default); + } + + [Fact] + public async Task Staged_integration_event_falls_back_to_the_default_tenant_without_an_org_claim() + { + // A token with no org_id is the current production state (Auth0 Organizations is not + // enabled), so this is the path every existing test and deployment takes. It must stay + // on TenantId.Default rather than deriving something from an absent claim. + var row = await StageErasureAndReadOutboxAsync("auth0|tenant-scope-no-org", org: null); + + JsonDocument.Parse(row.Content) + .RootElement.GetProperty("TenantId").GetGuid() + .Should().Be(TenantIdHelper.Default); + } + + [Fact] + public async Task Outbox_row_captures_the_correlation_id_the_gateway_echoed() + { + // The Workstream 3 counterpart: the row must carry the correlation id that was ambient + // when it was written, so the dispatcher can restore it instead of deriving one from the + // trace id. A client-supplied X-Request-Id is the case the derivation got wrong. + const string clientId = "client-chosen-correlation-id"; + + var client = Client("auth0|tenant-scope-correlation"); + client.DefaultRequestHeaders.Add("X-Request-Id", clientId); + + await client.GetAsync("api/v1/profile"); + await client.PostAsJsonAsync("api/v1/profile/erasure-request", new { }); + + using var scope = _factory.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + var row = await db.Set() + .Where(m => m.Content.Contains("auth0|tenant-scope-correlation")) + .SingleAsync(); + + row.CorrelationId.Should().Be(clientId); + } +} diff --git a/tests/OrderSphere.IntegrationTests/Api/TestAuthHandler.cs b/tests/OrderSphere.IntegrationTests/Api/TestAuthHandler.cs index 93b7396f..0ae8e329 100644 --- a/tests/OrderSphere.IntegrationTests/Api/TestAuthHandler.cs +++ b/tests/OrderSphere.IntegrationTests/Api/TestAuthHandler.cs @@ -11,6 +11,11 @@ namespace OrderSphere.IntegrationTests.Api; /// when it carries an X-Test-Sub header (the OIDC sub claim); roles are supplied via a /// comma-separated X-Test-Roles header. A request with no X-Test-Sub stays anonymous, /// so endpoints guarded by RequireAuthorization() challenge with 401 exactly as in production. +/// +/// An optional X-Test-Org header emits the Auth0 Organizations org_id claim +/// (ADR 0012). It is opt-in so that existing tests keep resolving TenantId.Default and are +/// unaffected by tenant scoping. +/// /// internal sealed class TestAuthHandler( IOptionsMonitor options, @@ -26,6 +31,9 @@ internal sealed class TestAuthHandler( public const string SubHeader = "X-Test-Sub"; public const string RolesHeader = "X-Test-Roles"; + /// Supplies the Auth0 org_id claim; omitted means no tenant (ADR 0012). + public const string OrgHeader = "X-Test-Org"; + protected override Task HandleAuthenticateAsync() { if (!Request.Headers.TryGetValue(SubHeader, out var sub) || string.IsNullOrWhiteSpace(sub)) @@ -38,6 +46,11 @@ protected override Task HandleAuthenticateAsync() new("email", "test-user@example.com"), }; + if (Request.Headers.TryGetValue(OrgHeader, out var org) && !string.IsNullOrWhiteSpace(org)) + { + claims.Add(new Claim("org_id", org!)); + } + if (Request.Headers.TryGetValue(RolesHeader, out var roles)) { foreach (var role in roles.ToString().Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) diff --git a/tests/OrderSphere.IntegrationTests/Logging/LogEnrichmentTests.cs b/tests/OrderSphere.IntegrationTests/Logging/LogEnrichmentTests.cs index e368b4ac..0ef442da 100644 --- a/tests/OrderSphere.IntegrationTests/Logging/LogEnrichmentTests.cs +++ b/tests/OrderSphere.IntegrationTests/Logging/LogEnrichmentTests.cs @@ -1,4 +1,7 @@ using FluentAssertions; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.TestHost; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; @@ -49,6 +52,12 @@ public void Record_carries_ambient_tenant_and_correlation() tags.Should().Contain("OrderId", "42"); } + /// + /// This is the canonical encoding of a deliberate decision, not just an absence check: + /// anonymous traffic and background work open no tenant scope, so tenant_id is absent + /// rather than stamped with TenantId.Default. An all-zero GUID on every anonymous + /// request would be indistinguishable from a genuine single-organisation tenant. + /// [Fact] public void Record_omits_tenant_and_correlation_when_no_scope_is_open() { @@ -63,6 +72,43 @@ public void Record_omits_tenant_and_correlation_when_no_scope_is_open() tags.Should().NotContainKey("correlation_id"); } + /// + /// The record an operator reaches for first — an unhandled 500 — is the one the ambient scopes + /// cannot cover. UseExceptionHandler() has to sit outside UseOrderSphereRequestLogging() + /// to catch anything at all (every host registers them in that order), so by the time it logs, + /// the exception has already unwound past the enrichment middleware and disposed both scopes. + /// This pins the HttpContext.Items fallback that closes the gap; it fails against a + /// build where the enricher only reads the AsyncLocal slots. + /// + [Fact] + public async Task Unhandled_exception_record_carries_the_correlation_id_after_the_scope_unwound() + { + const string correlationId = "corr-survives-unwind"; + + var builder = WebApplication.CreateBuilder(); + builder.WebHost.UseTestServer(); + builder.AddServiceDefaults(); + builder.Services.AddOrderSphereRequestLogging(); + builder.Services.AddProblemDetails(); + builder.Logging.AddFakeLogging(); + + await using var app = builder.Build(); + app.UseExceptionHandler(); + app.UseOrderSphereRequestLogging(); + app.MapGet("/boom", void () => throw new InvalidOperationException("deliberate")); + + await app.StartAsync(); + + using var client = app.GetTestClient(); + client.DefaultRequestHeaders.Add("X-Request-Id", correlationId); + await client.GetAsync("/boom"); + + var errorRecord = app.Services.GetFakeLogCollector().GetSnapshot() + .Should().ContainSingle(r => r.Level == LogLevel.Error).Subject; + + TagsOf(errorRecord).Should().Contain("correlation_id", correlationId); + } + [Fact] public void Static_enricher_stamps_build_version_on_every_record() { diff --git a/tests/OrderSphere.Webhooks.Tests/Persistence/TenantBackfillHazardTests.cs b/tests/OrderSphere.Webhooks.Tests/Persistence/TenantBackfillHazardTests.cs new file mode 100644 index 00000000..909adc56 --- /dev/null +++ b/tests/OrderSphere.Webhooks.Tests/Persistence/TenantBackfillHazardTests.cs @@ -0,0 +1,82 @@ +using Microsoft.EntityFrameworkCore; +using OrderSphere.BuildingBlocks.Security; +using OrderSphere.Webhooks.Tests.Helpers; +using TenantIdHelper = OrderSphere.BuildingBlocks.StronglyTypedIds.TenantId; + +namespace OrderSphere.Webhooks.Tests.Persistence; + +/// +/// Executable specification of the migration hazard in ADR 0012. +/// +/// Auth0 Organizations is not enabled in any environment today, so every row in every +/// tenant-filtered context carries TenantId.Default (). The tenant +/// query filter (ModelBuilderExtensions.ApplyTenantQueryFilter) is strict equality with no +/// Guid.Empty exemption. The moment the first real org_id claim is issued, every +/// pre-existing row for that organisation stops matching. +/// +/// +/// These tests pass today — they assert the hazard, not a defect. Webhooks is the worst instance +/// of it because the loss is silent: WebhookEventProcessor reports a subscription lookup +/// that matches nothing as "Created 0 webhook deliveries" at Information, so no alert +/// fires and no message dead-letters. If a backfill is ever added, the first test is the one that +/// must be revisited. +/// +/// +public sealed class TenantBackfillHazardTests +{ + private const string OrgId = "org_backfill_hazard"; + private static readonly Guid RealTenant = TenantIdHelper.FromOrgId(OrgId); + + /// + /// Stands in for the switch-over: the same database, read first by a process with no + /// organisation claim and then by one that has one. + /// + private sealed class MutableTenantContext : ITenantContext + { + public Guid TenantId { get; set; } = TenantIdHelper.Default; + } + + private static WebhookSubscription CreateSubscription() => + new(CustomerId.New(), "https://example.com/hook", "secret", [WebhookEventType.OrderPlaced]); + + [Fact] + public async Task Rows_written_before_organizations_are_enabled_are_invisible_to_a_real_tenant() + { + var tenant = new MutableTenantContext(); + await using var ctx = WebhooksDbContextFactory.Create(tenant); + + // Written today: no org_id claim in circulation, so the row is stamped Guid.Empty. + ctx.Subscriptions.Add(CreateSubscription()); + await ctx.SaveChangesAsync(); + + // Auth0 Organizations is switched on; the same worker now resolves a real tenant. + tenant.TenantId = RealTenant; + + var visible = await ctx.Subscriptions.ToListAsync(); + visible.Should().BeEmpty("the strict-equality tenant filter excludes Guid.Empty rows"); + + // The row was not deleted — it is filtered out. That is what makes the loss silent. + var stored = await ctx.Subscriptions.IgnoreQueryFilters().ToListAsync(); + stored.Should().ContainSingle().Which.TenantId.Should().Be(TenantIdHelper.Default); + } + + [Fact] + public async Task Rows_backfilled_to_the_real_tenant_become_visible_again() + { + var tenant = new MutableTenantContext(); + await using var ctx = WebhooksDbContextFactory.Create(tenant); + + ctx.Subscriptions.Add(CreateSubscription()); + await ctx.SaveChangesAsync(); + + tenant.TenantId = RealTenant; + + // What the backfill migration does per table, before the deploy that issues the claims. + var row = await ctx.Subscriptions.IgnoreQueryFilters().SingleAsync(); + row.TenantId = RealTenant; + await ctx.SaveChangesAsync(); + + var visible = await ctx.Subscriptions.ToListAsync(); + visible.Should().ContainSingle(); + } +}