bmad-method-quarkus 1.0.5 → 1.0.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/bmm-skills/agents/bmad-quarkus-build/SKILL.md +7 -3
- package/src/bmm-skills/agents/bmad-quarkus-build/skills/quarkus-error-handling-i18n/SKILL.md +6 -2
- package/src/bmm-skills/agents/bmad-quarkus-build/skills/quarkus-hexagonal-core/SKILL.md +64 -17
- package/src/bmm-skills/agents/bmad-quarkus-build/skills/quarkus-kafka-messaging/SKILL.md +5 -3
- package/src/bmm-skills/agents/bmad-quarkus-build/skills/quarkus-observability-otel/SKILL.md +191 -13
- package/src/bmm-skills/agents/bmad-quarkus-build/skills/quarkus-openapi-tmforum/SKILL.md +6 -7
- package/src/bmm-skills/agents/bmad-quarkus-build/skills/quarkus-security-standards/SKILL.md +132 -0
- package/src/bmm-skills/agents/bmad-quarkus-build/skills/quarkus-sql-jdbc-agroal/SKILL.md +5 -2
- package/src/bmm-skills/agents/bmad-quarkus-build/skills/quarkus-temporal-workflows/SKILL.md +616 -0
- package/src/commands/quarkus-all.md +6 -5
package/package.json
CHANGED
|
@@ -44,7 +44,7 @@ Ultra-succinct, citable, and role-aware. You speak in file paths, AC IDs, and cl
|
|
|
44
44
|
|
|
45
45
|
## Domain Standards
|
|
46
46
|
|
|
47
|
-
These
|
|
47
|
+
These 9 standards are installed at `.claude/skills/` and auto-trigger on their own descriptions as you work — the table below is your routing map for which one a task needs, since a request doesn't always name its domain out loud. `quarkus-hexagonal-core` defines the vocabulary the other eight use, so it is the one you apply first. Eight are universal; `quarkus-temporal-workflows` applies only to projects that actually use Temporal:
|
|
48
48
|
|
|
49
49
|
| Skill | Consult when the task touches |
|
|
50
50
|
| --- | --- |
|
|
@@ -55,6 +55,8 @@ These 7 standards are installed at `.claude/skills/` and auto-trigger on their o
|
|
|
55
55
|
| `quarkus-grpc-services` | `.proto` files, a `GrpcService` adapter, or an outbound call wrapped in `common/client` |
|
|
56
56
|
| `quarkus-kafka-messaging` | Domain events — the outbox row, the relay mode, a `Consumer`, DLQ, idempotency |
|
|
57
57
|
| `quarkus-observability-otel` | `@WithSpan` on `process()`, `trace_id`/`span_id` in logs, the `audit_event` context, metrics |
|
|
58
|
+
| `quarkus-security-standards` | Any credential/URL/API key, `.env`/`.env.example`, `application.properties` secrets, OIDC/JWT auth, CORS/TLS, or a security review |
|
|
59
|
+
| `quarkus-temporal-workflows` | **Temporal projects only** — a saga/compensation, a `@WorkflowInterface`/`@ActivityInterface`, the worker bootstrap, a multi-slice process one transaction can't make atomic, or scaffolding a **worker-only deployable** (no REST, no slices — it overrides the slice layout) |
|
|
58
60
|
|
|
59
61
|
## On Activation
|
|
60
62
|
|
|
@@ -86,15 +88,17 @@ Treat every entry in `{agent.persistent_facts}` as foundational context you carr
|
|
|
86
88
|
|
|
87
89
|
### Step 5: Load the House Standards
|
|
88
90
|
|
|
89
|
-
Read
|
|
91
|
+
Read the eight universal Quarkus standards **in full** before doing any work. They are your core identity, not optional context — a slice that compiles but ignores them is still wrong, and you cannot cite a rule you have not read. The ninth is conditional: read it only when the project uses Temporal.
|
|
90
92
|
|
|
91
|
-
1. `{project-root}/.claude/skills/quarkus-hexagonal-core/SKILL.md` — **read this one first**; the other
|
|
93
|
+
1. `{project-root}/.claude/skills/quarkus-hexagonal-core/SKILL.md` — **read this one first**; the other eight use the vocabulary it defines
|
|
92
94
|
2. `{project-root}/.claude/skills/quarkus-sql-jdbc-agroal/SKILL.md`
|
|
93
95
|
3. `{project-root}/.claude/skills/quarkus-error-handling-i18n/SKILL.md`
|
|
94
96
|
4. `{project-root}/.claude/skills/quarkus-openapi-tmforum/SKILL.md`
|
|
95
97
|
5. `{project-root}/.claude/skills/quarkus-grpc-services/SKILL.md`
|
|
96
98
|
6. `{project-root}/.claude/skills/quarkus-kafka-messaging/SKILL.md`
|
|
97
99
|
7. `{project-root}/.claude/skills/quarkus-observability-otel/SKILL.md`
|
|
100
|
+
8. `{project-root}/.claude/skills/quarkus-security-standards/SKILL.md`
|
|
101
|
+
9. `{project-root}/.claude/skills/quarkus-temporal-workflows/SKILL.md` — **only if the project uses Temporal** (`io.temporal:temporal-sdk` in a `pom.xml`, or an `orchestration/` package). Skip it otherwise; it governs nothing in a project without workflows.
|
|
98
102
|
|
|
99
103
|
These are **sibling** skills installed flat at `.claude/skills/`, not nested under this one — a bare `skills/<name>/SKILL.md` path resolves from `{skill-root}`, finds nothing, and fails silently. If a file is missing at that path, try `{project-root}/src/bmm-skills/agents/bmad-quarkus-build/skills/<name>/SKILL.md` (pre-install staging layout) and use whichever resolves. If neither resolves, **say so before writing any code** rather than working from memory.
|
|
100
104
|
|
package/src/bmm-skills/agents/bmad-quarkus-build/skills/quarkus-error-handling-i18n/SKILL.md
CHANGED
|
@@ -75,7 +75,7 @@ Canonical rules in the quarkus-hexagonal-core skill; the error/i18n specifics:
|
|
|
75
75
|
| gRPC counterpart | `GrpcExceptionInterceptor`, in `common/error` | |
|
|
76
76
|
| i18n components | `MessageResolver`, `ErrorCatalog` | |
|
|
77
77
|
| Error DTO | `ErrorDto` (TMF630 Error shape) | |
|
|
78
|
-
| Bundle files | `messages/errors[_<lang>].properties` | `
|
|
78
|
+
| Bundle files | `messages/errors[_<lang>].properties` — the default (en) bundle is `errors.properties`, with no suffix | `errors.properties`, `errors_es.properties` |
|
|
79
79
|
| Bundle key | the error code itself, never a prose key | `USR-404-001` |
|
|
80
80
|
|
|
81
81
|
`GlobalExceptionHandler` is no longer "the only sanctioned `*Handler`" — under the slice standard `*Handler` is the business-logic class of every slice (`CreatePartyIndividualHandler`). What stays true is that there is exactly **one** global exception handler per transport, both in `common/error`, and that no `Resource`, `GrpcService` or `Consumer` builds an error response itself.
|
|
@@ -189,7 +189,11 @@ public class MessageResolver {
|
|
|
189
189
|
var locale = headers.getAcceptableLanguages().stream()
|
|
190
190
|
.findFirst().filter(l -> !"*".equals(l.getLanguage()))
|
|
191
191
|
.orElse(Locale.ENGLISH); // default locale = en
|
|
192
|
-
|
|
192
|
+
// getNoFallbackControl is REQUIRED: the default Control falls back to the JVM's
|
|
193
|
+
// default locale before the base bundle, so `Accept-Language: fr` on a pod with
|
|
194
|
+
// LANG=es returns Spanish instead of the English default.
|
|
195
|
+
var bundle = ResourceBundle.getBundle("messages.errors", locale,
|
|
196
|
+
ResourceBundle.Control.getNoFallbackControl(ResourceBundle.Control.FORMAT_PROPERTIES));
|
|
193
197
|
var pattern = bundle.containsKey(code) ? bundle.getString(code)
|
|
194
198
|
: bundle.getString("GEN-500-001");
|
|
195
199
|
return MessageFormat.format(pattern, args);
|
|
@@ -5,10 +5,12 @@ description: Foundation for building Java + Quarkus backend services compiled to
|
|
|
5
5
|
|
|
6
6
|
# Quarkus Vertical Slice + Hexagonal Core (Native-First)
|
|
7
7
|
|
|
8
|
-
Foundation skill for all backend services. The other skills in this set (sql-jdbc-agroal
|
|
8
|
+
Foundation skill for all backend services. The other eight skills in this set (`quarkus-sql-jdbc-agroal`, `quarkus-error-handling-i18n`, `quarkus-openapi-tmforum`, `quarkus-grpc-services`, `quarkus-kafka-messaging`, `quarkus-observability-otel`, `quarkus-security-standards`, and `quarkus-temporal-workflows` on Temporal projects) build on the structure defined here and use the vocabulary defined here — apply this one first when scaffolding.
|
|
9
9
|
|
|
10
10
|
**Scope & precedence:** these standards apply to any Java/Quarkus backend project, whether or not it is organized as DDD bounded contexts. **Module naming:** every module/bounded context is identified in code by a short, lowercase, semantic name (`iam`, `tenant`, `wallet`, `customer`) — never by an inventory/governance code like `bc01`/`bc02`. If the organization keeps a numbered BC inventory, the `bcNN` ↔ name mapping lives in governance docs (domain README identity table, service catalog); packages, topics, config keys, URLs, metrics and proto packages only ever carry the semantic name. They are the default, not law: a project directive that says otherwise (CLAUDE.md, an ADR, an explicit user instruction) wins over this skill. When you deviate because of such a directive, follow the directive and mention which rule was overridden.
|
|
11
11
|
|
|
12
|
+
**One structural exception:** a **Temporal worker-only deployable** (`{module}-{capability}-worker`) — an app that hosts workflows and activities and exposes no API — has no vertical slices, no `Resource`, no `<Slice>Handler`/`<Slice>Sql` pair and usually no datasource. The naming rules, monorepo layout, native build, Dockerfile and the `service.yaml` + README standard below still apply to it in full; the slice layout, the REST adapter rules and the slice-oriented ArchUnit rules do not. See the quarkus-temporal-workflows skill §3 before scaffolding one — do not give it slices because this skill's checklist says so.
|
|
13
|
+
|
|
12
14
|
## The central idea: one feature = one folder
|
|
13
15
|
|
|
14
16
|
Classic layered hexagonal architecture spreads a single feature across eight or more files (inbound port, use case, command, result, outbound port, JDBC adapter, DTO, mapper) in four packages. This standard keeps the **hexagon's dependency rule** but collapses it into a **vertical slice**: everything one business capability needs lives in one folder, and the hexagon is expressed by *class role*, not by package depth.
|
|
@@ -48,6 +50,7 @@ One monorepo per domain (bounded context in DDD projects) holds **every deployab
|
|
|
48
50
|
│ │ ├── service.yaml # structured metadata — JSON-Schema validated in CI
|
|
49
51
|
│ │ └── README.md # per-app operational doc (see "Per-app documentation")
|
|
50
52
|
│ ├── iam-identity-ms/ # a second backend in the same domain, same internal structure
|
|
53
|
+
│ ├── iam-onboarding-worker/ # Temporal worker — no API, no slices (see temporal skill)
|
|
51
54
|
│ ├── iam-admin-mf/ # microfrontend remote — own build, same service.yaml + README rule
|
|
52
55
|
│ └── iam-shell-module/ # host/shell that composes the remotes
|
|
53
56
|
├── libs/ # code shared *within* this domain — never across domains
|
|
@@ -61,7 +64,7 @@ One monorepo per domain (bounded context in DDD projects) holds **every deployab
|
|
|
61
64
|
└── README.md # domain onboarding doc (see "Domain README")
|
|
62
65
|
```
|
|
63
66
|
|
|
64
|
-
**Scope of this skill inside the monorepo:** everything from "Slice layout" onward governs `-ms` apps only. `-mf` and `-module` apps are first-class residents of the same repo — they share `contracts/`, `libs/web/`, `deploy/` and `docs/`, and they carry the same mandatory `service.yaml` + `README.md` — but their internal structure is not this standard's business. A frontend never reaches a backend except through a published contract in `contracts/`; there is no shared runtime code between an `-ms` and an `-mf`.
|
|
67
|
+
**Scope of this skill inside the monorepo:** everything from "Slice layout" onward governs `-ms` apps only. A `-worker` app is a backend and follows this skill's naming, native build and documentation rules, but it has no slices — its internal structure belongs to the quarkus-temporal-workflows skill. `-mf` and `-module` apps are first-class residents of the same repo — they share `contracts/`, `libs/web/`, `deploy/` and `docs/`, and they carry the same mandatory `service.yaml` + `README.md` — but their internal structure is not this standard's business. A frontend never reaches a backend except through a published contract in `contracts/`; there is no shared runtime code between an `-ms` and an `-mf`.
|
|
65
68
|
|
|
66
69
|
### Deployable app naming (tenant-agnostic)
|
|
67
70
|
|
|
@@ -69,7 +72,9 @@ One monorepo per domain (bounded context in DDD projects) holds **every deployab
|
|
|
69
72
|
{module}-{service or submodule}-{type-suffix}
|
|
70
73
|
```
|
|
71
74
|
|
|
72
|
-
Suffixes: `-ms` (backend microservice —
|
|
75
|
+
Suffixes: `-ms` (backend microservice serving an API), `-worker` (backend deployable that serves **no** API — currently Temporal workflow workers; see the quarkus-temporal-workflows skill), `-mf` (microfrontend remote), `-module` (host/shell). Examples: `iam-management-ms`, `customer-onboarding-worker`.
|
|
76
|
+
|
|
77
|
+
`-ms` vs `-worker` is a deploy-time contract, not a label: an `-ms` gets an ingress, HTTP probes and RPS-based scaling; a `-worker` gets none of those and scales on queue backlog. An app that serves an API *and* hosts a worker is an `-ms`. **Client and tenant never appear in app or code names** — they are runtime configuration (namespaces, labels, Helm values) applied at deploy time; the code is identical for every client and tenant.
|
|
73
78
|
|
|
74
79
|
## Slice layout (the canonical structure)
|
|
75
80
|
|
|
@@ -179,7 +184,7 @@ Several slices may expose operations under the same TMF base path (`create_party
|
|
|
179
184
|
|
|
180
185
|
| Kind | Convention | Example |
|
|
181
186
|
|---|---|---|
|
|
182
|
-
| Deployable app | `{module}-{service or submodule}-{type}` kebab-case, tenant-agnostic (`-ms` backend) | `iam-management-ms` |
|
|
187
|
+
| Deployable app | `{module}-{service or submodule}-{type}` kebab-case, tenant-agnostic (`-ms` backend with an API, `-worker` backend without one) | `iam-management-ms`, `customer-onboarding-worker` |
|
|
183
188
|
| Slice package | lowercase `snake_case`, verb-first | `com.alva.customer.create_party_individual` |
|
|
184
189
|
| SQL constant | `UPPER_SNAKE_CASE` matching the method | `INSERT_PARTY`, `SELECT_PARTY_BY_ID` |
|
|
185
190
|
| SQL table / column | `snake_case`, singular table, schema-qualified | `customer.party`, `created_at` |
|
|
@@ -228,7 +233,7 @@ private void validate(CreatePartyIndividualRequestDto request) {
|
|
|
228
233
|
|
|
229
234
|
- Opens **one** `Connection` from the injected `DataSource` in try-with-resources and passes it to every `Sql` call in the operation. This is the performance contract: one connection per business operation, whether or not a transaction is active. Under JTA, Agroal would return the same enlisted connection anyway; on a read path with no `@Transactional`, per-method `getConnection()` would acquire *N* separate pool leases for *N* queries — passing `conn` makes both paths cost exactly one.
|
|
230
235
|
- Calls `${SERVICE_CLASS_PREFIX}Sql` methods in business order (insert `party` first to get `partyId`, reuse it for dependent inserts). Before writing a call, re-read the real `Sql` method signature and pass exactly those parameters, in that order and type.
|
|
231
|
-
- Catches every checked `SQLException` and rethrows it as `BusinessException`
|
|
236
|
+
- Catches every checked `SQLException` and rethrows it **unchecked** via `SqlStateTranslator` — as a `BusinessException` for the states that mean a business conflict (`23505`, `23503`) and as the `PersistenceException` family for everything else (see the mapping table in quarkus-sql-jdbc-agroal skill §9). An unchecked exception is what triggers the container rollback; a propagated checked one would not.
|
|
232
237
|
- Date fields arrive from the DTO as `String`. To use them as `LocalDate`/`ZonedDateTime`, declare a **new local variable** and convert explicitly (`LocalDate.parse(...)`) — never reassign the DTO field.
|
|
233
238
|
- Domain events are published by inserting an outbox row through `OutboxEventRecorder` from `common/messaging`, on this method's `Connection` and therefore in this same transaction — never by emitting to Kafka directly (see quarkus-kafka-messaging skill). Who relays that row (Debezium, `OutboxRelayJob`, or a post-commit emit) is a runtime property, `app.messaging.outbox.relay-mode`, and changes nothing here.
|
|
234
239
|
- **The outbox is cross-cutting, not slice-local: never declare `insertOutboxEvent` on a `<Slice>Sql`.** It is the same table, the same columns and the same envelope in every slice, so the "duplicate twice, extract on the third" rule below does not apply — there is nothing slice-specific to discover. The shared `Connection` is what keeps the write transactional; the class's location is irrelevant to that guarantee. Same for `AuditRecorder` and any other table `common/` owns.
|
|
@@ -321,7 +326,7 @@ Full standard (pool config, batches, jsonb, pagination, keyset paging, SQLState
|
|
|
321
326
|
- Constants are text blocks or comma-first concatenations, schema-qualified, with positional `?` placeholders only. **Check the real schema name before writing a query** — if the schema or table is a reserved word (`order`, `user`, `group`), quote it: `"order".party`.
|
|
322
327
|
- If the PK has a database default (`id uuid DEFAULT customer.uuidv7() NOT NULL`), the `id` column is excluded from the INSERT column list and never passed as a parameter. Prefer `INSERT ... RETURNING id` over `getGeneratedKeys()`.
|
|
323
328
|
- **Every method's first parameter is `Connection conn`** — supplied by the Handler, never obtained inside the method. That signature is the contract the Handler must honour exactly.
|
|
324
|
-
- Methods `throws SQLException` — they never swallow it and never translate it. Translation
|
|
329
|
+
- Methods `throws SQLException` — they never swallow it and never translate it. Translation happens in the Handler via `SqlStateTranslator`, into `BusinessException` or the `PersistenceException` family depending on the SQLState.
|
|
325
330
|
- **No transaction control here.** No `commit`, no `rollback`, no `setAutoCommit`.
|
|
326
331
|
- Row mapping is hand-written into DTOs (`PartyDto.builder()...`) — no reflection-based mappers, which also keeps native images small.
|
|
327
332
|
|
|
@@ -344,7 +349,7 @@ All three are thin: extract metadata, invoke `handler.process(...)`, format the
|
|
|
344
349
|
- `<Resource>Resource` class with `@Path` + `@Tag`, injecting the slice `Handler`(s).
|
|
345
350
|
- Returns `RestResponse<T>` synchronously. Never `Uni`/`Multi`.
|
|
346
351
|
- `@Blocking` on JDBC-backed methods. With a plain (non-reactive) return type Quarkus REST already dispatches to a worker thread, so the annotation is redundant *today* — keep it as an explicit threading contract that survives a later signature change. `@RunOnVirtualThread` is the alternative for high-concurrency blocking endpoints (see sql skill §8).
|
|
347
|
-
-
|
|
352
|
+
- Reads `tenantId` — and any other value that drives authorization, such as `partyId` or `partyRolList` — from the **validated token** (`@Inject JsonWebToken jwt` → `jwt.getClaim(...)`, or `@Claim` — not `SecurityIdentity`, which carries roles and mechanism attributes rather than claims), never from a request header the caller controls (see the Tenancy model rule under "Per-app documentation" and quarkus-security-standards skill §4). `language` and other non-security metadata may come from headers. It sets them on the request DTO before calling `process()`.
|
|
348
353
|
- Full OpenAPI annotations per method: `@Operation` with `operationId`, one `@APIResponse` per relevant status, `@Parameter` for headers and path/query params, `Location` header on create.
|
|
349
354
|
|
|
350
355
|
**gRPC** — full standard in **quarkus-grpc-services**:
|
|
@@ -394,10 +399,10 @@ Code shared across **apps** in the domain goes to `libs/` and uses constructor i
|
|
|
394
399
|
|
|
395
400
|
## Scaffolding checklist
|
|
396
401
|
|
|
397
|
-
When creating a new service:
|
|
402
|
+
When creating a new service. A **Temporal worker-only deployable** follows the same steps with a reduced scope (quarkus-temporal-workflows skill §3): in step 2 drop `quarkus-rest*`, `quarkus-smallrye-openapi`, and — unless it owns a table of its own — `quarkus-agroal`, `quarkus-jdbc-*` and `quarkus-flyway`; in step 3 keep only the error/exception classes it actually throws; skip step 5's slice-oriented rules and ship the ArchUnit subset that skill lists. It has no slices and no `Resource`.
|
|
398
403
|
|
|
399
|
-
1. App folder `apps/<module>-<service-or-submodule>-ms/` per the monorepo layout above.
|
|
400
|
-
2. `pom.xml` with BOM `io.quarkus.platform:quarkus-bom` (latest 3.x LTS), extensions: `quarkus-rest`, `quarkus-rest-jackson`, `quarkus-agroal`, `quarkus-jdbc-postgresql`, `quarkus-arc`, `quarkus-narayana-jta`, `quarkus-hibernate-validator`, `quarkus-smallrye-health`, `quarkus-smallrye-openapi`, `quarkus-opentelemetry`, `quarkus-flyway` (kept even where migrations are disabled — see sql skill §10), plus Lombok and skill-specific extensions as needed.
|
|
404
|
+
1. App folder `apps/<module>-<service-or-submodule>-ms/` per the monorepo layout above — `-worker` instead of `-ms` for a Temporal worker deployable.
|
|
405
|
+
2. `pom.xml` with BOM `io.quarkus.platform:quarkus-bom` (latest 3.x LTS), extensions: `quarkus-rest`, `quarkus-rest-jackson`, `quarkus-agroal`, `quarkus-jdbc-postgresql`, `quarkus-arc`, `quarkus-narayana-jta`, `quarkus-hibernate-validator`, `quarkus-smallrye-health`, `quarkus-smallrye-openapi`, `quarkus-opentelemetry`, `quarkus-micrometer-registry-prometheus` (mandatory metrics — brings `quarkus-micrometer` transitively) and `quarkus-logging-json` (mandatory structured logs) — the three observability extensions are all required, see that skill — `quarkus-oidc` wherever the app exposes an authenticated API (security skill §4), `quarkus-flyway` (kept even where migrations are disabled — see sql skill §10), plus Lombok and skill-specific extensions as needed.
|
|
401
406
|
3. `common/` package: `BusinessException`, `ErrorCatalog`, `ErrorDto`, `GlobalExceptionHandler`, `MessageResolver`, `SqlStateTranslator`, `StringUtils`.
|
|
402
407
|
4. `application.properties` baseline (below).
|
|
403
408
|
5. ArchUnit test (below) — in the same commit as the first slice, not later.
|
|
@@ -434,8 +439,12 @@ quarkus.flyway.migrate-at-start=false
|
|
|
434
439
|
app.messaging.outbox.relay-mode=debezium
|
|
435
440
|
app.messaging.outbox.poller.every=off
|
|
436
441
|
|
|
442
|
+
# Metrics — Micrometer is mandatory in every service (see quarkus-observability-otel skill)
|
|
443
|
+
quarkus.micrometer.export.prometheus.enabled=true
|
|
444
|
+
quarkus.datasource.metrics.enabled=true
|
|
445
|
+
|
|
437
446
|
# i18n (see error-handling skill) — locales must be declared for native
|
|
438
|
-
quarkus.locales=en,es
|
|
447
|
+
quarkus.locales=en,es,pt
|
|
439
448
|
quarkus.default-locale=en
|
|
440
449
|
quarkus.native.resources.includes=messages/*.properties
|
|
441
450
|
|
|
@@ -484,13 +493,13 @@ Rules:
|
|
|
484
493
|
|
|
485
494
|
Every deployable under `apps/<app>/` carries two files, versioned with the code and updated in the **same PR** that changes the service's behavior:
|
|
486
495
|
|
|
487
|
-
- **`service.yaml`** — structured metadata validated against a JSON Schema in CI: identity (domain, system, component type, lifecycle), ownership (team, SRE, support group), criticality, tenancy (model, data isolation), runtime (type, version, ports), provided APIs with their contract files in `contracts/`, dependencies with `hard`/`soft` requirement, and the observability contract (log format, levels, required fields, data-protection rules). It feeds the service catalog and the
|
|
496
|
+
- **`service.yaml`** — structured metadata validated against a JSON Schema in CI: identity (domain, system, component type, lifecycle), **the API gateway base path(s) under `metadata.gateway`** (see "Gateway routing" below), ownership (team, SRE, support group), criticality, tenancy (model, data isolation), runtime (type, version, ports), provided APIs with their contract files in `contracts/`, dependencies with `hard`/`soft` requirement, and the observability contract (log format, levels, required fields, data-protection rules). It feeds the service catalog, the dependency map and the gateway route configuration.
|
|
488
497
|
- **`README.md`** (per app) — operational doc written so a system administrator who has never seen the code can configure, deploy and recover the service. No `TBD` / "see code" (a justified `N/A` is allowed). No manual changelog section — Git keeps the history.
|
|
489
498
|
|
|
490
499
|
Required `README.md` sections:
|
|
491
500
|
|
|
492
501
|
1. **Identity** — app name, module (`part-of`), type (`ms`), owning team, repository, criticality, part of core yes/no
|
|
493
|
-
2. **Purpose &
|
|
502
|
+
2. **Purpose, responsibility & gateway routes** — description + out of scope, **plus the API gateway base path(s) this service is published under** (see "Gateway routing" below). A `-worker` app declares `N/A — publishes no API`.
|
|
494
503
|
3. **Slice inventory** — table: slice folder, capability, exposed channels (REST/gRPC/Kafka), link to the slice README
|
|
495
504
|
4. **Tenancy model** — `shared` | `per-tenant` | `pool`; tenant identification (validated JWT claim, never a free header); data isolation; per-tenant parameters
|
|
496
505
|
5. **APIs exposed** — table: api/version, protocol, route/topic, contract file, auth, visibility
|
|
@@ -509,6 +518,39 @@ Required `README.md` sections:
|
|
|
509
518
|
|
|
510
519
|
Rule of completeness: if a variable, secret or dependency is not documented here, it does not exist — CI validates the `service.yaml` schema and the mandatory `README.md` sections as a blocking gate.
|
|
511
520
|
|
|
521
|
+
### Gateway routing (README §2 + `service.yaml` `metadata.gateway`)
|
|
522
|
+
|
|
523
|
+
Infra configures the API gateway from these two files, so the route a service will be published under is declared **before** it is deployed, in the same PR as the endpoint.
|
|
524
|
+
|
|
525
|
+
**The base path is the module's semantic name**, the same name packages, topics, config keys and metrics already use: `/iam`, `/customer`, `/wallet` — **never the inventory code** (`/bc01` is wrong; the `bcNN` ↔ name mapping lives in governance docs, not in a URL). Every north-bound REST API of every `-ms` in the module therefore hangs off one shared prefix, and the API's own path continues from there per the quarkus-openapi-tmforum skill: `/{module}/{context}/{apiName}/v{major}`, e.g. `/iam/tmf-api/digitalIdentityManagement/v4`.
|
|
526
|
+
|
|
527
|
+
README §2 carries the human-readable table:
|
|
528
|
+
|
|
529
|
+
| Base path | Protocol | Visibility | Purpose |
|
|
530
|
+
|---|---|---|---|
|
|
531
|
+
| `/iam` | REST | public | north-bound TMF identity APIs |
|
|
532
|
+
|
|
533
|
+
`service.yaml` carries the same facts as structured metadata:
|
|
534
|
+
|
|
535
|
+
```yaml
|
|
536
|
+
metadata:
|
|
537
|
+
name: iam-management-ms
|
|
538
|
+
module: iam
|
|
539
|
+
gateway:
|
|
540
|
+
base-paths:
|
|
541
|
+
- path: /iam
|
|
542
|
+
protocol: REST
|
|
543
|
+
visibility: public # public | partner | internal
|
|
544
|
+
purpose: north-bound TMF identity APIs
|
|
545
|
+
```
|
|
546
|
+
|
|
547
|
+
Rules:
|
|
548
|
+
- **`service.yaml` is authoritative; the README renders the same facts for humans.** They must agree — two copies of one fact drift, and here the drift is a misrouted service. CI cross-checks them.
|
|
549
|
+
- **Only north-bound REST appears here.** Internal gRPC never crosses the gateway (internal calls are gRPC, external is REST — see "Baseline constraints"), so a `*GrpcService` contributes no gateway route.
|
|
550
|
+
- **A `-worker` app declares none** — `metadata.gateway.base-paths: []` and `N/A` in the README. It publishes no API by definition (see the quarkus-temporal-workflows skill).
|
|
551
|
+
- List **every** prefix when a service is published under more than one — a legacy alias kept during a migration, or a second public API — each with its own purpose, so nobody has to guess which is being retired.
|
|
552
|
+
- The declared path must match the ingress/gateway configuration in the GitOps repo. A README that disagrees with what is deployed is a defect, not a stale doc: it is the input infra works from.
|
|
553
|
+
|
|
512
554
|
### Slice README (inside the slice folder)
|
|
513
555
|
|
|
514
556
|
Every slice carries its own `README.md`, generated from the real code — no invented routes, fields or tables. Sections:
|
|
@@ -547,7 +589,10 @@ class ArchitectureTest {
|
|
|
547
589
|
.namingSlices("slice $1")
|
|
548
590
|
.should().notDependOnEachOther()
|
|
549
591
|
.ignoreDependency(resideInAPackage("..common.."), alwaysTrue())
|
|
550
|
-
.ignoreDependency(alwaysTrue(), resideInAPackage("..common.."))
|
|
592
|
+
.ignoreDependency(alwaysTrue(), resideInAPackage("..common.."))
|
|
593
|
+
// Temporal only: orchestration/ may reach into slices, never the reverse (deliberately
|
|
594
|
+
// asymmetric — see quarkus-temporal-workflows skill §3). Omit on non-Temporal projects.
|
|
595
|
+
.ignoreDependency(resideInAPackage("..orchestration.."), alwaysTrue());
|
|
551
596
|
|
|
552
597
|
// --- Hexagon dependency rule inside the slice ------------------------------
|
|
553
598
|
@ArchTest
|
|
@@ -646,7 +691,9 @@ static final ArchRule businessExceptionIsTheOnlyOne = classes()
|
|
|
646
691
|
.should().resideInAPackage("..common.exception..");
|
|
647
692
|
|
|
648
693
|
@ArchTest
|
|
649
|
-
static final ArchRule bannedSuffixes = noClasses()
|
|
694
|
+
static final ArchRule bannedSuffixes = noClasses()
|
|
695
|
+
.that().resideOutsideOfPackage("..orchestration..") // Temporal workflow/activity Impl pair — see below
|
|
696
|
+
.should().haveSimpleNameEndingWith("Impl")
|
|
650
697
|
.orShould().haveSimpleNameEndingWith("Util") // note: "Utils" is deliberately absent — common/util/StringUtils
|
|
651
698
|
.orShould().haveSimpleNameEndingWith("Helper")
|
|
652
699
|
.orShould().haveSimpleNameEndingWith("Manager")
|
|
@@ -657,7 +704,7 @@ static final ArchRule bannedSuffixes = noClasses().should().haveSimpleNameEnding
|
|
|
657
704
|
.orShould().haveSimpleNameEndingWith("DTO");
|
|
658
705
|
```
|
|
659
706
|
|
|
660
|
-
`common/util/StringUtils
|
|
707
|
+
`common/util/StringUtils`, the `Jdbc` namespace helper (sql skill §7) and, on Temporal projects, the `*WorkflowImpl`/`*ActivitiesImpl` pair inside `..orchestration..` (whose interface+implementation split the SDK requires — see quarkus-temporal-workflows skill §3) are the sanctioned exceptions to `bannedSuffixes`. Exclude them by package or fully-qualified name, as above, rather than weakening the rule.
|
|
661
708
|
|
|
662
709
|
Rules the compiler cannot express (enforce in code review and in the generator checklists):
|
|
663
710
|
|
|
@@ -686,7 +733,7 @@ Rules the compiler cannot express (enforce in code review and in the generator c
|
|
|
686
733
|
|
|
687
734
|
`<Slice>HandlerTest` is mandatory and mirrors the slice. Rules:
|
|
688
735
|
|
|
689
|
-
- Mockito only — `@Mock <Slice>Sql sql;`, `@Mock DataSource dataSource;`, `@Mock Connection connection;`, `@InjectMocks <Slice>Handler handler
|
|
736
|
+
- Mockito only — `@Mock <Slice>Sql sql;`, `@Mock DataSource dataSource;`, `@Mock Connection connection;`, `@InjectMocks <Slice>Handler handler;`. Stub `when(dataSource.getConnection()).thenReturn(connection)` **inside the tests that reach `execution()`**, not in `@BeforeEach`: the mandatory validation-failure test (scenario 2 below) never opens a connection, so a shared stub fails it with Mockito's `UnnecessaryStubbingException` under the default strict stubs. Use `lenient()` only if you genuinely prefer the shared setup. Never a real database, never `@QuarkusTest` for pure business rules, never both `@InjectMocks` (Mockito) and `@InjectMock` (Quarkus) in one file.
|
|
690
737
|
- Test names: `should<Result>When<Condition>` — `shouldReturnPartyIdWhenRequestIsValid`, `shouldThrowBusinessExceptionWhenGivenNameIsBlank`.
|
|
691
738
|
- Minimum scenarios per public business path:
|
|
692
739
|
|
|
@@ -130,7 +130,9 @@ That is the whole dual-write guarantee — one connection, one transaction, no p
|
|
|
130
130
|
Fire-and-forget technical messages (metrics, notifications with no consistency requirement) MAY use a direct `Emitter` — but it lives in a **`common/` capability bean**, never in a slice. `@Channel`/`Emitter` are `org.eclipse.microprofile.reactive.messaging` types, and the `handlerKnowsNoTransport` ArchUnit rule forbids them in a `Handler`:
|
|
131
131
|
|
|
132
132
|
```java
|
|
133
|
-
// common/audit/
|
|
133
|
+
// common/audit/AuditEventPublisher.java — the Handler injects this bean, not the Emitter.
|
|
134
|
+
// Distinct from AuditRecorder (common/audit), which writes the local audit_event table
|
|
135
|
+
// on the Handler's Connection — see quarkus-observability-otel skill.
|
|
134
136
|
@Inject @Channel("audit-out") Emitter<AuditEventPayload> audit;
|
|
135
137
|
```
|
|
136
138
|
|
|
@@ -314,12 +316,12 @@ mp.messaging.incoming.user-registered-in.connector=smallrye-kafka
|
|
|
314
316
|
mp.messaging.incoming.user-registered-in.topic=alva.iam.user.registered.v1
|
|
315
317
|
mp.messaging.incoming.user-registered-in.group.id=${quarkus.application.name}
|
|
316
318
|
mp.messaging.incoming.user-registered-in.auto.offset.reset=earliest
|
|
317
|
-
mp.messaging.incoming.user-registered-in.failure-strategy=
|
|
319
|
+
mp.messaging.incoming.user-registered-in.failure-strategy=dead-letter-queue
|
|
318
320
|
mp.messaging.incoming.user-registered-in.dead-letter-queue.topic=alva.iam.user.registered.v1.dlq
|
|
319
321
|
```
|
|
320
322
|
|
|
321
323
|
Rules:
|
|
322
|
-
- **Idempotency is mandatory**: `processed_event(consumer_group, event_id)` checked/inserted through the
|
|
324
|
+
- **Idempotency is mandatory**: `processed_event(consumer_group, event_id)` checked/inserted through the cross-cutting `ProcessedEventSql` in `common/messaging` — never a per-slice copy, it is the same table for every slice (see quarkus-sql-jdbc-agroal skill §6) — on the `Connection` the `Handler` already owns, in the same transaction as the side effects. Kafka is at-least-once; duplicates WILL happen.
|
|
323
325
|
- Consumer group = service name; stable across deployments.
|
|
324
326
|
- Failure strategy: bounded retries with delay, then DLQ topic `<topic>.dlq`. DLQ messages keep original headers plus `dead-letter-reason`. A DLQ must have an owner and an alert — never a silent graveyard.
|
|
325
327
|
- Poison-pill safety: deserialization failures also route to DLQ (`...deserialization-failure-handler` or failure strategy), never block the partition.
|
|
@@ -1,23 +1,29 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: quarkus-observability-otel
|
|
3
|
-
description: Observability standard for Quarkus native services using OpenTelemetry — distributed tracing with W3C Trace Context (traceparent), trace_id/span_id in every log line (JSON logs + MDC), OTLP export, metrics
|
|
3
|
+
description: Observability standard for Quarkus native services using OpenTelemetry — distributed tracing with W3C Trace Context (traceparent), trace_id/span_id in every log line (JSON logs + MDC), OTLP export, MANDATORY Micrometer metrics (`quarkus-micrometer-registry-prometheus` in every service), custom spans, and trace propagation across REST, gRPC, and Kafka. Also covers build identity in telemetry (service.version/service.instance.id, build_info gauge), the export pipeline (batch span processor, graceful-shutdown flush budget, span attribute/event/link limits) and propagation safety (propagators, baggage, untrusted inbound traceparent). Use this skill whenever the user mentions OpenTelemetry, OTel, tracing, trace_id, span_id, traceparent, distributed tracing, logging standard, log correlation, metrics, Micrometer, quarkus-micrometer, MeterRegistry, counter/timer/gauge, `@Timed`/`@Counted`, /q/metrics, scrape, dashboards or alerts, Grafana/Tempo/Jaeger/Prometheus, baggage, propagators, sampling, service.version or build metadata, telemetry lost on shutdown/SIGTERM/terminationGracePeriodSeconds, span limits or telemetry volume/cost, or debugging requests across services. Includes span, metric, logger and audit-bean naming conventions for the vertical-slice layout (one span per Handler.process()).
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# OpenTelemetry Observability Standard (Quarkus)
|
|
7
7
|
|
|
8
8
|
Every request must be traceable end-to-end: REST → Handler → JDBC → outbox → Kafka → consumer → gRPC. `trace_id`/`span_id` appear in every log line and propagate across every transport via W3C Trace Context. Applies to any Quarkus backend project; project directives (CLAUDE.md, ADRs, explicit instructions) override these defaults.
|
|
9
9
|
|
|
10
|
-
Extensions: `quarkus-opentelemetry` (
|
|
10
|
+
Extensions — **all three are mandatory in every service**, all native-compatible: `quarkus-opentelemetry` (traces), `quarkus-micrometer-registry-prometheus` (metrics — see "Metrics"), `quarkus-logging-json` (structured logs). Traces, metrics and logs are one contract; a service that ships two of the three is not observable.
|
|
11
11
|
|
|
12
12
|
## Baseline configuration
|
|
13
13
|
|
|
14
14
|
```properties
|
|
15
15
|
quarkus.application.name=iam-users # becomes service.name
|
|
16
16
|
quarkus.otel.exporter.otlp.endpoint=http://otel-collector:4317
|
|
17
|
-
quarkus.otel.resource.attributes=deployment.environment=${ENV:dev},service.namespace=alva
|
|
18
17
|
|
|
19
|
-
#
|
|
20
|
-
|
|
18
|
+
# Identity of the running artifact — see "Build identity in every signal"
|
|
19
|
+
quarkus.otel.resource.attributes=service.version=${quarkus.application.version},\
|
|
20
|
+
service.instance.id=${HOSTNAME:unknown},\
|
|
21
|
+
deployment.environment=${ENV:dev},\
|
|
22
|
+
service.namespace=alva
|
|
23
|
+
|
|
24
|
+
# Propagation: W3C Trace Context only. Add `baggage` ONLY for a documented
|
|
25
|
+
# cross-service need — see "Propagation and baggage". Never B3 unless mandated.
|
|
26
|
+
quarkus.otel.propagators=tracecontext
|
|
21
27
|
|
|
22
28
|
# Sampling: head-based ratio in prod, always_on elsewhere
|
|
23
29
|
%prod.quarkus.otel.traces.sampler=parentbased_traceidratio
|
|
@@ -29,6 +35,82 @@ quarkus.otel.traces.suppress-non-application-uris=true
|
|
|
29
35
|
|
|
30
36
|
Export to an **OTel Collector** (agent/sidecar/gateway), never directly to the vendor backend — keeps services vendor-neutral.
|
|
31
37
|
|
|
38
|
+
### Build identity in every signal
|
|
39
|
+
|
|
40
|
+
"Which build produced this?" is the first question of every incident, and it must be answerable from a trace, a log line and a dashboard *without* cross-referencing a deploy log. `service.version` is an OTel semantic-convention resource attribute; `quarkus.application.version` resolves it from the Maven project version at build time, so it is correct by construction and never hand-maintained. `service.instance.id` from `${HOSTNAME}` is the pod name under Kubernetes — it is what separates "the whole service is broken" from "one replica is broken".
|
|
41
|
+
|
|
42
|
+
Each signal carries it differently, and the difference matters:
|
|
43
|
+
|
|
44
|
+
| Signal | How the version travels | Cost |
|
|
45
|
+
|---|---|---|
|
|
46
|
+
| Traces | `service.version` resource attribute (above) | none — resource attributes are sent once per batch, not per span |
|
|
47
|
+
| Logs | an additional JSON field (see "Logs" below) | none |
|
|
48
|
+
| Metrics | a dedicated **info gauge**, never a common tag | one extra series per version |
|
|
49
|
+
|
|
50
|
+
**Do not add `version` as a Micrometer common tag.** It looks tidy and it is the standard trap: every metric in the service gets a new time series on every deploy, so cardinality grows with release frequency forever and any dashboard grouped without an explicit `sum by (…)` splits in two during a rolling deploy. The Prometheus idiom is a separate info metric pinned to 1, joined at query time (`… * on(instance) group_left(version) iam_build_info`):
|
|
51
|
+
|
|
52
|
+
```java
|
|
53
|
+
// common/observability
|
|
54
|
+
@ApplicationScoped
|
|
55
|
+
public class BuildInfoMetric {
|
|
56
|
+
|
|
57
|
+
@Inject MeterRegistry registry;
|
|
58
|
+
|
|
59
|
+
@ConfigProperty(name = "quarkus.application.version")
|
|
60
|
+
String version;
|
|
61
|
+
|
|
62
|
+
void onStart(@Observes StartupEvent event) {
|
|
63
|
+
Gauge.builder("iam_build_info", () -> 1)
|
|
64
|
+
.description("Always 1; carries the running build's identity as tags")
|
|
65
|
+
.tag("version", version)
|
|
66
|
+
.register(registry);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
`<module>_build_info` is a **sanctioned exception** to the `<module>_<entity>_<action>_total|_seconds` naming rule below — `_info` is the established Prometheus convention for this pattern and dashboards expect it. If CI injects a commit SHA (as `BUILD_COMMIT`, per the quarkus-security-standards skill's `${VAR}` rule), add it as a second tag here and as a `service.build_id` resource attribute; it is bounded by the same one-series-per-deploy budget.
|
|
72
|
+
|
|
73
|
+
## Export pipeline: batching, shutdown and hard limits
|
|
74
|
+
|
|
75
|
+
Spans are **buffered in memory** by the batch span processor and exported asynchronously. Two failure modes follow from that, and neither announces itself: a pod that dies before the buffer flushes loses the telemetry of the requests that mattered most, and an unbounded attribute turns one bad `setAttribute` call into an export-size incident.
|
|
76
|
+
|
|
77
|
+
```properties
|
|
78
|
+
# Batch span processor — the in-memory buffer
|
|
79
|
+
quarkus.otel.bsp.schedule.delay=2S # default 5S; smaller = less at risk on a hard kill
|
|
80
|
+
quarkus.otel.bsp.max.queue.size=2048 # spans buffered; overflow is DROPPED, silently
|
|
81
|
+
quarkus.otel.bsp.max.export.batch.size=512
|
|
82
|
+
quarkus.otel.bsp.export.timeout=10S # must fit the shutdown budget below
|
|
83
|
+
|
|
84
|
+
quarkus.otel.exporter.otlp.timeout=5S
|
|
85
|
+
|
|
86
|
+
# Hard caps — the backstop for a bad attribute (mirror the OTEL_* spec env vars)
|
|
87
|
+
quarkus.otel.span.attribute.value.length.limit=4096
|
|
88
|
+
quarkus.otel.span.attribute.count.limit=128
|
|
89
|
+
quarkus.otel.span.event.count.limit=128
|
|
90
|
+
quarkus.otel.span.link.count.limit=128
|
|
91
|
+
|
|
92
|
+
# Graceful shutdown — drain in-flight requests before the SDK flushes
|
|
93
|
+
quarkus.shutdown.timeout=15S
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
### The shutdown budget (this is arithmetic, not a preference)
|
|
97
|
+
|
|
98
|
+
On `SIGTERM` Quarkus stops accepting requests, waits up to `quarkus.shutdown.timeout` for in-flight ones, shuts down beans, and only then does the OTel SDK flush its buffer — up to `bsp.export.timeout`. If the orchestrator's grace period expires first, the pod is `SIGKILL`ed mid-flush and the buffered spans die with it. So:
|
|
99
|
+
|
|
100
|
+
```
|
|
101
|
+
terminationGracePeriodSeconds > quarkus.shutdown.timeout + (flush worst case) + margin
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
**`bsp.export.timeout` is per batch export, not per flush** — a full queue drains in `max.queue.size / max.export.batch.size` sequential batches, so with the values above the worst case is `2048/512 = 4` exports, i.e. up to `4 × 10S`, not `10S`. Budget `15S + 40S + margin` → **`terminationGracePeriodSeconds: 75`** in the deployment manifest, or shrink the queue if you would rather cap the tail. Sizing the grace period off a single export timeout is the common mistake here. Set both sides deliberately; the Kubernetes default of 30s silently truncates this budget, and the loss is invisible — you get a trace that simply ends, which reads like a hung request rather than a dropped export. This is sharper for native binaries, which start and stop fast enough that pods churn often (scale-downs, rollouts, spot reclaims), so shutdown is a routine path, not an edge case.
|
|
105
|
+
|
|
106
|
+
Metrics behave differently and need no budget: Prometheus **pulls**, so the last scrape interval before termination is simply never collected. Counters are cumulative and aggregation tolerates the gap — just never build an alert on the final seconds of a terminating pod's counter.
|
|
107
|
+
|
|
108
|
+
### On the limits
|
|
109
|
+
|
|
110
|
+
- Queue overflow and attribute truncation are both **silent**. The only signal is the BSP's own dropped-span counter — alert on it, or discover the gap mid-incident when the trace you need is the one that was dropped.
|
|
111
|
+
- **A limit truncates, it does not sanitize.** A 4096-character cap on an attribute value does nothing about privacy: a truncated email address is still an email address. Limits are a cost and blast-radius control; the no-PII rule below is the privacy control, and neither substitutes for the other.
|
|
112
|
+
- Sampling (`%prod` ratio above) is the primary volume lever; these caps are the per-span backstop for when a code change, not traffic, is what blew up the volume.
|
|
113
|
+
|
|
32
114
|
## Logs: trace_id and span_id in every line
|
|
33
115
|
|
|
34
116
|
Quarkus OTel injects `traceId`, `spanId`, `sampled` into the MDC automatically.
|
|
@@ -42,10 +124,14 @@ Console (dev, human-readable):
|
|
|
42
124
|
Prod: JSON logs (parseable, ship to the log pipeline):
|
|
43
125
|
|
|
44
126
|
```properties
|
|
45
|
-
%prod.quarkus.log.console.json=true
|
|
127
|
+
%prod.quarkus.log.console.json.enabled=true
|
|
46
128
|
%prod.quarkus.log.console.json.additional-field."service.name".value=${quarkus.application.name}
|
|
129
|
+
%prod.quarkus.log.console.json.additional-field."service.version".value=${quarkus.application.version}
|
|
130
|
+
%prod.quarkus.log.console.json.additional-field."service.instance.id".value=${HOSTNAME:unknown}
|
|
47
131
|
```
|
|
48
132
|
|
|
133
|
+
`service.version` and `service.instance.id` repeat the resource attributes from the baseline config on purpose: a log line has no OTel resource, so without these fields "which build logged this?" is only answerable by joining through the trace — and unsampled or pre-trace log lines cannot be joined at all.
|
|
134
|
+
|
|
49
135
|
`quarkus-logging-json` includes MDC (traceId/spanId) as fields — logs↔traces correlation in Grafana/Tempo/Loki works with zero code. Rule: NEVER log a business operation without going through JBoss Logging/`Log` — `System.out` breaks correlation.
|
|
50
136
|
|
|
51
137
|
Also return the trace id to API callers for support tickets: a tiny response filter adds header `X-Trace-Id: ${Span.current().getSpanContext().getTraceId()}` on every response (especially errors — pairs with the unified exception handler).
|
|
@@ -56,7 +142,7 @@ With the extension present, these produce/propagate spans with no code:
|
|
|
56
142
|
- HTTP server (Quarkus REST) and REST Client
|
|
57
143
|
- gRPC server & client (metadata `traceparent`)
|
|
58
144
|
- Kafka via SmallRye Reactive Messaging (header `traceparent`)
|
|
59
|
-
- Agroal/JDBC:
|
|
145
|
+
- Agroal/JDBC: set `quarkus.datasource.jdbc.telemetry=true` for SQL spans — that property is the whole mechanism, on `quarkus-opentelemetry`; there is no separate JDBC extension to add
|
|
60
146
|
|
|
61
147
|
## Mandatory: trace context extraction via the OTel SDK only
|
|
62
148
|
|
|
@@ -69,6 +155,23 @@ String spanId = Span.current().getSpanContext().getSpanId();
|
|
|
69
155
|
|
|
70
156
|
If `Span.current()` is invalid (no active trace — e.g., a batch job without instrumentation), fix the instrumentation of the entry point; do not fabricate ids.
|
|
71
157
|
|
|
158
|
+
## Propagation and baggage: what crosses the service boundary
|
|
159
|
+
|
|
160
|
+
Trace context headers are **data that leaves the trust boundary in both directions**, and OTel baggage is the part teams underestimate: it is a set of key-value pairs that the instrumented clients attach to *every* outbound call — including calls to third-party APIs. Anything placed in baggage is, in practice, published to every vendor the service talks to.
|
|
161
|
+
|
|
162
|
+
**Default: `quarkus.otel.propagators=tracecontext`** (as in the baseline config). Baggage is off unless the platform has a documented cross-service need for it, recorded in the app's `service.yaml`. Narrowing the propagator list is free and removes the whole class of problem; do not enable baggage "in case someone needs it later."
|
|
163
|
+
|
|
164
|
+
When baggage *is* enabled, these are hard rules:
|
|
165
|
+
|
|
166
|
+
- **Never PII, credentials, tokens, secrets, or free text.** Same prohibition as span attributes (below) and log lines, with a wider blast radius because baggage crosses into systems you do not operate. Pairs with the quarkus-security-standards skill §7.
|
|
167
|
+
- **Baggage is caller-controlled input — never an authorization input.** The inbound `baggage` header is unauthenticated and unvalidated; any caller can set any value. In particular, **`tenantId` never comes from baggage**: it comes from a validated JWT claim, per the tenancy rule in the quarkus-hexagonal-core skill and §4 of the security skill. Reading a tenant from baggage is the same defect as reading it from a free header, wearing a telemetry costume.
|
|
168
|
+
- Bounded, low-cardinality enumerations only, with short keys — baggage rides in an HTTP header on every hop and is a per-request bandwidth cost.
|
|
169
|
+
- Do not auto-promote baggage entries into span attributes. That launders caller-controlled values into your telemetry store, where the cardinality and PII rules below assume the values are yours.
|
|
170
|
+
|
|
171
|
+
### Inbound trace context at the public edge
|
|
172
|
+
|
|
173
|
+
North-bound REST is reachable by callers you do not control, and `traceparent` is as forgeable as any other header — an untrusted client can pin a trace id, join itself to an unrelated trace, or force sampling decisions. Internal gRPC calls come from inside the mesh and are trusted; the public REST ingress is not. Where the platform does not already strip client trace headers at the gateway, treat an inbound `traceparent` on a public endpoint as untrusted: start a new root span and **link** to the incoming context rather than continuing it. Trust the header on internal transports, never at the public boundary.
|
|
174
|
+
|
|
72
175
|
## Audit standard: `context` jsonb column in `audit_event`
|
|
73
176
|
|
|
74
177
|
Every service with a local `audit_event` table persists the active trace in a `context` column of type `jsonb`:
|
|
@@ -176,11 +279,80 @@ Canonical class-naming rules live in the quarkus-hexagonal-core skill; these are
|
|
|
176
279
|
|
|
177
280
|
Span, metric and log-field names are a contract with the dashboards — renaming one silently breaks alerts. Treat a rename like an API change.
|
|
178
281
|
|
|
179
|
-
## Metrics (Micrometer)
|
|
282
|
+
## Metrics (Micrometer — mandatory extension)
|
|
283
|
+
|
|
284
|
+
**`quarkus-micrometer` ships in every service. It is not opt-in and does not need a per-service justification** — a service with no metrics is invisible to the platform's dashboards, alerts and capacity planning, and support cannot triage it. Declare the registry, which brings the core extension transitively:
|
|
285
|
+
|
|
286
|
+
```xml
|
|
287
|
+
<dependency>
|
|
288
|
+
<groupId>io.quarkus</groupId>
|
|
289
|
+
<artifactId>quarkus-micrometer-registry-prometheus</artifactId>
|
|
290
|
+
</dependency>
|
|
291
|
+
```
|
|
292
|
+
|
|
293
|
+
Declare the **registry only** — `quarkus-micrometer` arrives as its transitive dependency, and listing both invites a version skew on a Quarkus upgrade. Prometheus is the platform default registry; `quarkus-micrometer-registry-otlp` (metrics through the same collector as traces) is the sanctioned alternative where the platform standardizes on OTLP, and swapping registries is an ADR-level decision, not a per-service preference. Never two registries in one service.
|
|
294
|
+
|
|
295
|
+
```properties
|
|
296
|
+
quarkus.micrometer.enabled=true
|
|
297
|
+
quarkus.micrometer.export.prometheus.enabled=true
|
|
298
|
+
quarkus.micrometer.export.prometheus.path=/q/metrics
|
|
299
|
+
|
|
300
|
+
# Binders: the free instrumentation. Turn them ON, do not reimplement them.
|
|
301
|
+
quarkus.micrometer.binder.jvm=true
|
|
302
|
+
quarkus.micrometer.binder.system=true
|
|
303
|
+
quarkus.micrometer.binder.http-server.enabled=true
|
|
304
|
+
quarkus.micrometer.binder.http-client.enabled=true
|
|
305
|
+
quarkus.micrometer.binder.grpc-server.enabled=true # where the app exposes gRPC
|
|
306
|
+
quarkus.micrometer.binder.kafka.enabled=true # where the app produces/consumes
|
|
307
|
+
|
|
308
|
+
# Pool metrics come from the datasource, not from a binder (see quarkus-sql-jdbc-agroal skill)
|
|
309
|
+
quarkus.datasource.metrics.enabled=true
|
|
310
|
+
```
|
|
311
|
+
|
|
312
|
+
### What you get for free — and must not rewrite by hand
|
|
313
|
+
|
|
314
|
+
| Source | Sample metrics | Enabled by |
|
|
315
|
+
|---|---|---|
|
|
316
|
+
| HTTP server | `http_server_requests_seconds` (count/sum/max, tagged `method`, `uri`, `status`, `outcome`) | `binder.http-server` |
|
|
317
|
+
| JVM / runtime | heap, GC, thread and class-loader gauges | `binder.jvm` |
|
|
318
|
+
| Agroal pool | active/available/awaiting connections, acquisition time | `quarkus.datasource.metrics.enabled` |
|
|
319
|
+
| Kafka clients | producer/consumer lag, throughput, error rates | `binder.kafka` |
|
|
320
|
+
| gRPC server | per-method call count and latency | `binder.grpc-server` |
|
|
321
|
+
|
|
322
|
+
A hand-written counter that duplicates one of these rows is a defect — it costs cardinality and drifts from the binder's semantics. Write a custom metric only for something the binders cannot know: a **business** outcome.
|
|
323
|
+
|
|
324
|
+
**Native caveat:** under GraalVM the JVM binder reports only what the substrate VM exposes (GC and heap are present; some HotSpot-specific gauges are not). Do not build an alert on a JVM gauge without confirming it appears in `/q/metrics` of the **native** binary — the JVM-mode dev run is not evidence.
|
|
325
|
+
|
|
326
|
+
### Custom business metrics
|
|
327
|
+
|
|
328
|
+
Recorded in the `Handler`, the same boundary that owns the span and the transaction — never in `Sql`, never in `Resource`:
|
|
329
|
+
|
|
330
|
+
```java
|
|
331
|
+
@ApplicationScoped
|
|
332
|
+
public class RegisterUserHandler {
|
|
333
|
+
|
|
334
|
+
@Inject MeterRegistry registry;
|
|
335
|
+
|
|
336
|
+
@Transactional
|
|
337
|
+
@WithSpan("usecase.registerUser")
|
|
338
|
+
public RegisterUserResponseDto process(RegisterUserRequestDto request) {
|
|
339
|
+
validate(request);
|
|
340
|
+
UUID userId = execution(request);
|
|
341
|
+
registry.counter("iam_user_registrations_total", "channel", request.getChannel()).increment();
|
|
342
|
+
return getResult(userId);
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
```
|
|
346
|
+
|
|
347
|
+
- **Naming is the contract** (see the naming table above): `<module>_<entity>_<action>_total` for counters, `_seconds` for timers, `snake_case`, module prefix from the semantic module name — never `bcNN`.
|
|
348
|
+
- **Tags are bounded enumerations only.** `result=success|failure`, `channel=web|app|ivr`, `party_type=Individual|Organization`. **Never** `tenantId`, `userId`, `email`, `partyId`, `trace_id`, a raw URL, or an error *message* — each distinct value is a new time series, and an unbounded tag takes down the scrape target before anyone notices. The error **code** (`PTY-400-001`) is bounded and therefore a legal tag; the rendered message is not.
|
|
349
|
+
- No PII in a metric name or tag — same rule as span attributes, same reason.
|
|
350
|
+
- `@Timed` / `@Counted` on `process()` are acceptable for plain latency/invocation counts (CDI interceptors are build-time in Quarkus, so they are native-safe). Use the explicit `MeterRegistry` call whenever the tag depends on the outcome — an annotation cannot tag `result=failure` from a thrown `BusinessException`.
|
|
351
|
+
- Renaming a metric or a tag **breaks dashboards and alerts silently**. Treat it exactly like an API change: coordinate with whoever owns the dashboard, or add the new name alongside the old one for one release.
|
|
352
|
+
|
|
353
|
+
### Exposure
|
|
180
354
|
|
|
181
|
-
|
|
182
|
-
- Custom business metrics: counters/timers via `MeterRegistry`, names `iam_user_registrations_total` style, low-cardinality tags only.
|
|
183
|
-
- Expose `/q/metrics` for Prometheus scrape or bridge Micrometer→OTLP if the platform standardizes on the collector for metrics too.
|
|
355
|
+
`/q/metrics` is an internal endpoint: scraped by Prometheus inside the cluster, **never routed through the public ingress** (see quarkus-security-standards skill §7 — it must also never echo a config value or connection string). It is excluded from tracing by `suppress-non-application-uris=true` above. The scrape path and the service's custom metrics belong in the app's `service.yaml` observability contract and its `README.md` (see quarkus-hexagonal-core skill).
|
|
184
356
|
|
|
185
357
|
## Health
|
|
186
358
|
|
|
@@ -188,9 +360,15 @@ Span, metric and log-field names are a contract with the dashboards — renaming
|
|
|
188
360
|
|
|
189
361
|
## Checklist per service
|
|
190
362
|
|
|
191
|
-
1. `quarkus-opentelemetry` + JSON logging configured as above; OTLP → collector.
|
|
363
|
+
1. `quarkus-opentelemetry` + `quarkus-micrometer-registry-prometheus` + JSON logging configured as above; OTLP → collector.
|
|
192
364
|
2. Log format includes traceId/spanId (both profiles); `X-Trace-Id` response filter registered.
|
|
193
365
|
3. JDBC telemetry enabled; `audit_event.context` jsonb populated with `trace_id`/`span_id` (+ index) on the Handler's own `Connection`; outbox rows carry `traceparent` (header) AND `traceContext` in payload; the relay forwards the header.
|
|
194
366
|
3b. No manual trace-string construction anywhere — grep-check in review; only `Span.current()` via the OTel SDK.
|
|
195
367
|
4. Every slice `Handler.process()` annotated `@WithSpan("usecase.<sliceCamelCase>")`; attributes reviewed for PII/cardinality.
|
|
196
|
-
5.
|
|
368
|
+
5. Micrometer binders enabled (jvm, system, http-server/client, plus grpc/kafka where used) and `quarkus.datasource.metrics.enabled=true`; every custom metric follows the naming table, carries only bounded tags, and duplicates no binder metric.
|
|
369
|
+
6. `/q/metrics` returns data from the **native** binary and is reachable only from inside the cluster.
|
|
370
|
+
7. `service.version` + `service.instance.id` present in resource attributes AND in the JSON log fields; `<module>_build_info` gauge registered — and `version` is **not** a common tag on other metrics.
|
|
371
|
+
8. Shutdown budget satisfied: `terminationGracePeriodSeconds > quarkus.shutdown.timeout + quarkus.otel.bsp.export.timeout`, set in the deployment manifest, not left at the 30s default by accident.
|
|
372
|
+
9. `quarkus.otel.propagators` explicitly set (`tracecontext` unless baggage is documented in `service.yaml`); no PII, token or `tenantId` in baggage; public-edge `traceparent` treated as untrusted.
|
|
373
|
+
10. Span attribute/event/link limits configured; the BSP dropped-span counter is alerted on.
|
|
374
|
+
11. Verify end-to-end in dev: one request produces a single trace spanning REST → DB → Kafka → consumer (Dev UI or Jaeger/Tempo), and increments the metrics that back its dashboard.
|