Files
clean-architecture-backend-…/docs/jpa/observability.md
T
DongHyeonkaandClaude Opus 5 0e61f86eb5 feat(jpa): implement the JPA relational persistence platform
Implements the Stable and Experimental JPA persistence platform designs
against real PostgreSQL, adapted to this repository's fail-closed 19-leaf
registry.

The design models the platform as 25 Gradle projects. `src/settings.gradle`
throws unless the registry holds exactly 19 leaves, so the plan's modules
become packages inside `:adapter:outbound:persistence-jpa` (starter in
`:app-bootstrap`, testkit in its own source set). The full mapping, the
renames this repository's naming gate required, and every deliberate
substitution are recorded in `docs/jpa/repository-adaptation.md`.

Seven Docker-backed lanes replace the plan's seven JVM test suites. Each
fails closed: a lane that discovers nothing, or a container that cannot
start, is an error rather than a skip.

Three defects the contracts found against a real server:

- `CommitFailureClassifier` treated only SQLSTATE 40003, class 08, and
  transport breaks as completion-unknown. A backend terminated mid-commit
  reports 57P01, and the commit record may already be in the WAL — so a
  possibly-committed transaction could be re-run. 57P01/57P02/57P03 now
  classify as completion-unknown.
- `SchemaTenantMigrationOrchestrator` recorded `MigrateResult`'s target
  version, which is empty for a tenant already current, reporting migrated
  tenants as unmigrated during a partial rollout. It now reads the applied
  version back from the tenant's schema history.
- `JpaStreamExecutor` checked only the declared return type for reactive
  publishers, and `RegisteredPostgreSqlCopyLoader` passed the COPY timeout
  to `SET`, which is parsed before parameter binding.

`JpaModuleBoundaryTest` enforces the plan's module map as package rules;
`verifyCleanArchitectureDependencies` governs edges between leaves and
cannot see these. Its first assertion is that the import is non-empty,
because every rule under it is a `noClasses()` rule and would pass
vacuously on an empty import.

Verified: 128 container tests across all seven lanes, 1183 unit tests,
`:adapter:outbound:persistence-jpa:check`, `:app-bootstrap:check`,
`verifyCleanArchitectureDependencies`, `verifyOneTypePerFile`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-14 14:06:18 +09:00

3.0 KiB

Observability

Design §37. What is measured, and what must never appear in a measurement.

Bounded tags, always

Every JPA metric carries exactly five tags: persistence unit, operation, query, outcome, failure category. All five are registered identifiers, validated by LowCardinality at construction rather than at the registry — so an unbounded value fails where it was introduced instead of surviving until a dashboard stops loading.

Never a tag: entity id, tenant id, SQL parameter, exception message, JDBC URL. Each is unbounded, so each creates a time series per row or per failure; several are also the data the platform keeps out of logs, which a metrics backend would store just as durably and export just as widely.

Transaction metrics

Meter Why it exists
jpa.transaction.duration the baseline
jpa.transaction.rollback rollback rate by failure category
jpa.transaction.timeout timeouts, distinct from other rollbacks
jpa.transaction.completion.unknown its own counter, deliberately

Completion-unknown gets a separate counter rather than being folded into failures. It is the one outcome that means a human has to look: every other failure is a transaction that definitely did not happen, while this one is a transaction that may have.

Query metrics

jpa.query.duration and jpa.query.rows. Rows are measured as well as duration because a query that issues one statement and hydrates twenty thousand rows is fast per statement and catastrophic per request — a duration metric alone reports it as merely slow.

Retry metrics

Attempts are metrics, not warnings. Optimistic conflicts and serialization failures are the expected cost of concurrency; logging each at WARN pages someone for a system working as designed, after which the retry log gets filtered out and takes the genuinely interesting entries with it.

jpa.retry.attempt, jpa.retry.attempts (distribution per operation), jpa.retry.exhausted.

Query names in SQL

NamedStatementInspector prefixes each statement with its registered query name as a SQL comment, which travels into pg_stat_activity, auto_explain, and the slow-query log. Without it, "which endpoint issues this query" is answered by grepping the codebase for fragments of SQL.

Diagnostics

SqlDiagnosticRedactor removes string literals, numbers, and anything email-shaped before SQL reaches a log. Redaction is blunt on purpose: preserving "harmless" values would require knowing which columns hold personal data.

The actuator endpoint

jpaplatform reports database major version, provider version, schema version, OSIV state, runtime role verification, and capability levels. It reports no JDBC URL, no username, no SQL, and no entity catalog — an actuator endpoint is reachable by anyone who reaches the management port, and each of those would be a free reconnaissance answer. It is read-only: an endpoint that could trigger a migration or a repair would be an admin capability exposed over HTTP.