10 KiB
10 KiB
Refinement Phase 9 — Observability, ops hardening, docs honesty & scale-later — Report (2026-07-13)
Track: backend (observability/docs) + explicit deferrals · Depends on: nothing hard ·
Gate: dotnet build 0 new warnings · dotnet test 407 pass (402 prior + 5 new: audit-retention ×2,
ticket-body encryption ×2, liveness ×1; the existing messaging suite now also exercises the encrypted body).
This phase makes the running platform diagnosable and honest, and records the explicitly-deferred scale work so nobody mistakes it for missing MVP scope. No feature behaviour changed; the money/trust rules are untouched.
Observability & ops hardening (finish before launch)
9.1 — Tracing added; one metrics stack; requestId = trace id
- One metrics stack. Removed the duplicate prometheus-net stack (
UseMetricServer/UseHttpMetrics/ForwardToPrometheus+ the threeprometheus-net*packages). OpenTelemetry is now the only metrics source, scraped at/metricsviaUseOpenTelemetryPrometheusScrapingEndpoint(). HTTP request metrics now come from the OTel ASP.NET Core instrumentation; themediator_meterrequest-duration histogram (MetricsBehaviour) is now actually exported (added to the meter list — the old prometheus-net stack never captured it). - Tracing. Added
WithTracing(ASP.NET Core + EF Core instrumentation) sharing one resource (service.name = Baya.Web.Api), so a cross-service money flow (webhook → confirm → ledger) is one trace. - OTLP export is opt-in. Traces + metrics export to an OTLP collector only when
OpenTelemetry:Otlp:Endpointis set — an MVP with Prometheus alone runs unchanged and no exporter spams an absent collector. requestIdalready carries the trace id (ApiResult.RequestId = Activity.Current.TraceId) withActivity.DefaultIdFormat = W3C— a support ticket maps 1:1 to a trace with no extra wiring.- New packages (all cached, no restore risk):
OpenTelemetry.Exporter.OpenTelemetryProtocol(1.15.3),OpenTelemetry.Instrumentation.EntityFrameworkCore(1.15.1-beta.1).
9.2 — Health checks broadened; liveness/readiness split
/healthz/live— process only (a dependency-freeselfcheck), so a dependency outage never restart-loops./healthz/ready— the app DB, the log DB (deployed only — its conn string is a placeholder in Dev/Testing), and a real object-storage write round-trip (ObjectStorageWriteHealthCheck: put → get → delete a probe blob)./HealthCheck— the aggregate, retained for backward compatibility. The deadcurrentUrlline is gone.- Redis is noted as the next readiness check to add when it becomes a real dependency (>1 instance); not now.
Baya.Infrastructure.Monitoringnow referencesBaya.Application(for theIObjectStorageprobe) — a legitimate Infrastructure→Application edge, noted in the server Project map.
9.3 — Prod log level raised to Information+; no PII; dead ES sink removed
- Deployed envs now log Information+ (was Warning+, which dropped every Information-level audit trail), with framework categories held at Warning so the floor raise doesn't flood the sink.
- No secrets/PII in logs.
LoggingSmsSenderno longer logs the OTP code (a login secret) in any environment — a developer gets it from the Development-onlyGET /api/v1/dev/last_otp. Clinical text / IBANs / phone numbers are already encrypted or masked before any handler logs. ThecolumnOptions(previously built but never applied) are now wired to the SQL sink. - Dead Elasticsearch sink resolved by deletion: removed the commented ES sink block and the unused
Serilog.Sinks.Elasticsearchpackage (this also revealedSerilog.Sinks.Filewas only a transitive of the ES package — added it explicitly). Log-table retention is documented as an ops/DBA responsibility (or ship logs to the OTLP collector).
9.4 — Audit-log retention as a scheduled job
- New
AuditLogRetentionJob(IRecurringJob, registered like the others) runs a two-tier retention sweep over the append-onlyops.AuditLogs: financial/verification entity types (Refund,NurseClawback,NursePayout,NursePayoutBatch,NurseVerification,PlatformConfig,PartnerCenter) keep a long legal window (audit_retention_financial_days, default 2555 ≈ 7 years); everyday rows a shorter one (audit_retention_general_days, default 730 ≈ 2 years). Cadence keyaudit_retention_scan_cadence_hours(24). IAuditLogger.PurgeExpiredAsync(...)does the delete: oldest-first (Id is monotonic withOccurredAt), capped at 20 000 rows/run so a backlog drains across runs; age compared in memory (SQLite can't translate aDateTimeOffsetpredicate), delete is a single id-keyedExecuteDeleteAsync. Idempotent.- Migration
RefinementPhase9TicketBodyEncryptionAndAuditRetentionseeds the three config keys (ids 24–26).
9.5 — TicketMessage.Body encrypted; gRPC reflection gated to Development
- Ticket bodies are the refund/dispute paper trail (users type phone numbers, addresses, clinical detail) — now
encrypted at rest through the existing
IFieldEncryptorconverter (wired inApplicationDbContext, like every other PII column). The stored column is widened tonvarchar(max)(ciphertext is longer than plaintext); the 4000- char plaintext limit stays a boundary-validation rule (Open/PostMessage validators). Body is never a SQL search/ filter predicate (the admin thread read decrypts per row), so losing SQL-searchability is an accepted trade-off. - gRPC decision — keep the plugin, gate reflection to Development. The plugin exposes only the User service and
the client is HTTP/JSON, but removing it is more invasive than the risk warrants. gRPC reflection (which
advertises the full schema) is now registered/mapped only in Development. The HTTP/2-posture concern is already
mitigated (refinement-phase-5 set Kestrel
Http1AndHttp2), so the plugin shares the mixed-protocol listener (ALPN negotiates h2 for gRPC clients) — no dedicated port needed.
9.6 — Docs made honest
- Mocks-registry: pruned the 7 stale duplicate 🔴 rows (
IDistributedLock/INurseSearch/IPaymentProvider/ISettlementSplitProvider/IWebhookVerifier/IMoadianClient/ILicenseVerificationService) the detailed rows already correct; the recurring-jobs row is the real in-process scheduler; theIPaymentCaptureSimulatorrow now reflects its 6.4 prod removal (fail-closed in prod; Dev/Testing mock is a test affordance). Added a phase-9 banner. - REQ tracker: already honest — refinement-phase-3 marked every delivered/deferred/resolved REQ; this phase ships no new contract, so no REQ status changed. (The pre-phase-3 "all 15 open" state the audit flagged is long fixed.)
- Architecture maps:
server/CLAUDE.mdupdated (observability wiring, the audit-retention cron, ticket-body encryption, the gRPC decision, the new Monitoring→Application edge);runtime-services.mdupdated (OTel consolidation, tracing, health split).
Scale & later — explicitly NOT MVP (recorded, not built). Each has a written pull-trigger.
| # | Deferred item | Where it lives today (the real MVP) | Pull it when… |
|---|---|---|---|
| 9.7 | Elasticsearch read backend + outbox feeder — ElasticNurseSearch + the CDC/outbox stream |
SqlNurseSearch is real & correct; Search:Backend fails fast on any non-sql value |
SQL search shows strain (latency/throughput on nurse_search_index). Build ElasticNurseSearch (same filters/sort/paging) + the outbox feeder off ISearchIndexMaintainer; keep SQL as the reconciliation source (RebuildAsync). |
| 9.8 | Analytics pipeline — warehouse/stream sink | IAnalyticsSink writes ops.SystemEvents fire-and-forget (real, queryable) |
Product needs cross-event analytics beyond SQL queries. Pipe SystemEvents to a warehouse/stream (e.g. Kafka→ClickHouse), keeping fire-and-forget semantics. |
| 9.9 | Holiday-calendar feed — automated lunar-Hijri drift feed | IHolidayCalendar reads the seeded, manually-maintained ops.IranianHolidays table (real) |
The manual yearly refresh becomes a burden. A yearly ops-checklist item to top up the table is an acceptable MVP alternative to a feed — the read interface stays. |
| 9.10 | Push/SMS notification channels — SMS/FCM fan-out | InAppNotificationDispatcher writes real in-app ops.Notifications; non-InApp channels are dropped by design |
The notification UX demands out-of-app reach. Fan out to SMS (via the phase-8 ISmsSender) and FCM push behind the same INotificationDispatcher. |
| 9.11 | Deferred product tables — organizations, organization_nurses, fraud_flags, recurring_booking_schedules, bnpl_settlement_entries, availability slots, customer national-ID KYC, geo bulk import |
All verified absent; each is a pure additive migration when product pulls it | Product pulls the feature. No structural blocker — additive migration + feature slice; nothing in the current schema needs to change first. |
These are decisions, not gaps. SQL search, in-app notifications, and the manual holiday table are the real, correct MVP; Elasticsearch/analytics/push each has a concrete trigger above and stays out until then.
How it was verified
- Build:
dotnet build Baya.sln— 0 new warnings (the pre-existing NU1510 + NU1903 transitive-dependency audit warnings are unrelated to this phase). - Tests:
dotnet test Baya.sln— all green. New:AuditLogRetentionTests(two-tier purge + idempotency),TicketMessageEncryptionTests(encrypted at rest + round-trips on read),HealthCheckApiTests(liveness healthy without dependencies). - Trace/requestId: a request's
ApiResult.requestIdisActivity.Current.TraceId(W3C) — the same id a configured OTLP collector records. - No PII in logs: the OTP code is no longer logged in any environment; clinical text/IBANs are encrypted/masked.
Follow-ups for later phases
- Wire an OTLP collector (Grafana Tempo / Jaeger / OTEL Collector) in the deploy topology and set
OpenTelemetry:Otlp:Endpointto turn tracing export on. - When the first
redisdependency lands (>1 instance), add its readiness check to/healthz/ready. - The NU1903 transitive-dependency vulnerability warnings (
Microsoft.OpenApi,SQLitePCLRaw) are a separate dependency-bump task, out of this phase's scope.