# messaging-observability 완전 해부 > 상태: COMPLETE > 기준 revision: `21234e38cdb9a926cbc92bb97a2aee2e4a7d2916` > 분석 범위: `src/messaging/messaging-observability` > SSOT owner: `messaging-observability` > integration/family document: `analysis/19-messaging-platform.md` (secondary, INTEGRATION_ONLY) --- ## 0. SSOT identity / 커버리지와 숫자 지도 - registered leaf id: `messaging-observability` - canonical state `analysisFile`: `analysis/messaging/messaging-observability.md` - source path: `src/messaging/messaging-observability` - registry `allowed_dependencies`: `["messaging-core-api"]` - registry `runtime_memberships`: `["app-bootstrap"]` ### 숫자 | 항목 | 수 | |---|---:| | production Java 파일 | 9 | | production LOC | 838 | | 패키지 | 1 (`dev.caskeleton.messaging.observation`) | | test 파일 | 6 | | test 메서드(실행 확인) | 42 | | 외부(비프로젝트) 의존성 | 1 (`io.micrometer:micrometer-core`, **`api`**) | 아홉 타입을 세 축으로 나누면: | 축 | 타입 | leaf 밖 소비자 | |---|---|---| | **관측 seam** | `MessagingObservation`(interface) · `MessagingMetrics`(Micrometer 구현) · `MessagingTags`(record) · `DefaultMessagingObservationConvention` | seam 2 · 구현 **0** · tags 2 · convention **0** | | **경계** | `CardinalityGuard` · `MessagingRedactor` | 1 · 1 | | **추적·감사** | `MessagingTracer` · `MessagingAuditSink` · `MessagingAuditEvent` | **0** · **0** · 2 | ### Coverage ledger | scope/file group | count | disposition | reason | |---|---:|---|---| | `src/main/java/**` (9) | 9 | `FULL_READ` | 전 파일 본문 확인 | | `src/test/java/**` (6) | 6 | `FULL_READ` | 클래스 javadoc·단언·테스트명 전수 확인 | | `build.gradle` | 1 | `FULL_READ` | 주석 포함 9줄 | | `gradle.lockfile` | 1 | `STRUCTURAL_ONLY` | 잠금 파일 | | `build/**` | — | `EXCLUDED` | 빌드 산출물 | `UNCLASSIFIED` 0. --- ## 1. 모듈의 정체와 경계 이 leaf는 **"메시징이 무엇을 밖으로 내보내도 되는가"**를 소유한다. 메트릭·추적·감사 셋이 여기 있고, 셋 다 같은 제약 아래 있다 — **경계가 알려진 값만 나간다.** Micrometer를 `api`로 선언한 이유가 build.gradle에 있다. ```groovy // api: MessagingMetrics' public constructor takes a MeterRegistry, so wiring it requires // naming the type. api 'io.micrometer:micrometer-core' ``` `src/messaging/CLAUDE.md:40-43`의 vendor `api` 게이트를 통과한다. 다만 `MessagingObservation` 인터페이스 자체는 Micrometer를 모른다 — 벤더는 `MessagingMetrics` 한 클래스에만 나타난다. 즉 **seam은 중립이고 구현만 벤더에 묶인다.** 의존이 `messaging-core-api` 하나뿐인 것도 의도적이다. `MessagingTracer`가 `TraceContext`·`MessageHeaders`를 쓰고 `DefaultMessagingObservationConvention`이 `PublishCompletion`·`FailureCategory`를 쓴다. policy나 transport는 필요 없다. --- ## 2. 의존성과 런타임 배선 들어오는 것: `messaging-core-api`(api), `micrometer-core`(api). 나가는 것: `messaging-runtime-core`, `messaging-kafka`, `messaging-rabbit`, `messaging-admin-runtime`, `messaging-pulsar-experimental`, `messaging-nats-experimental`, `messaging-spring-boot-starter`. **출하 조립은 두 개뿐이다.** | bean | 라인 | 소비 | |---|---:|---| | `MessagingRedactor` | `MessagingCoreAutoConfiguration:253` | **없음** | | `CardinalityGuard` | `:264` | **없음** | 두 클래스는 `MessagingMetrics`의 생성자 인자다. 그런데 `MessagingMetrics` bean이 없다(§12.1). 즉 **재료 둘만 bean으로 있고 그것을 조립하는 것이 없다.** `MessagingTracer`·`MessagingAuditSink`·`DefaultMessagingObservationConvention`은 bean도 없고 소비자도 없다. --- ## 3. 패키지/컴포넌트 지도 ``` seam MessagingObservation (5 메서드: publish · delivery · settlement · backlog · diagnostics) ↑ 구현 MessagingMetrics ──┬── CardinalityGuard (차원당 200값 상한) ├── MessagingRedactor (키 denylist 27개) └── MeterRegistry (Micrometer) 어휘 MessagingTags (record, 6차원 고정) ↑ 생성 DefaultMessagingObservationConvention (publish/consume/settlement/deadLetter) 추적 MessagingTracer (inject / extract / shouldLinkRatherThanContinue) 감사 MessagingAuditSink (interface + InMemory) ── MessagingAuditEvent (record) ``` --- ## 4. 계약·불변식·상태 모델 ### 4.1 `MessagingTags` — 닫힌 6차원 ```java // :8-14 *

It is a fixed record rather than an open map on purpose. Every field here is bounded by * configuration or by an enum, so the cardinality of the metric is known before it is ever scraped. * Message ids, partition keys, tenant ids, and offsets are all deliberately absent: each of them is * unbounded at runtime and would multiply every series by the message volume. ``` 여섯 차원: `broker`, `destinationProfile`, `operation`, `outcome`, `failureCategory`, `retryStage`. 없는 값은 `NONE = "none"`이다 — null도 빈 문자열도 아니고 명시적 sentinel이다. **두 factory의 차이가 §12.1의 핵심이 된다.** | factory | failureCategory | retryStage | |---|---|---| | `new MessagingTags(6개 인자)` | 호출자가 지정 | 호출자가 지정 | | `MessagingTags.of(4개 인자)` | **`NONE` 고정** | **`NONE` 고정** | `asMap()`이 `LinkedHashMap`으로 순서를 고정하고 `Map.copyOf`로 불변화한다. ### 4.2 `DefaultMessagingObservationConvention` — 태그 값이 공개 계약이다 ```java // :12-18 *

Centralised because the tag values are a public contract: dashboards, alert rules, and SLOs * are written against these exact strings, so an adapter inventing its own spelling of "rejected" * silently breaks every alert that was watching for it. The conversion lives here, once, rather * than at each call site. * *

Only bounded inputs are accepted. Every parameter is an enum or a configured name, which is * what lets {@link CardinalityGuard} bound the resulting series. ``` 네 메서드와 네 상수(`PUBLISH`, `CONSUME`, `SETTLE`, `DEAD_LETTER`). `publish(...)`는 `PublishCompletion`과 `Optional`를 받아 **enum에서 문자열을 파생**한다 — 호출자가 철자를 정하지 않는다. 이 클래스는 소비자가 0이다(§12.1). ### 4.3 `CardinalityGuard` — 실패가 점진적이지 않다 ```java // :9-17 *

Cardinality failures are not gradual. A tag that accidentally carries a message id looks fine * in a test with ten messages and takes down the metrics backend in production, and by then the * series already exist. The guard bounds each dimension at registration time and refuses the value * that would cross the limit, so the damage is one rejected tag rather than a monitoring outage. * *

It fails loudly rather than silently substituting a placeholder, because a metric that quietly * collapses distinct values is worse than one that is missing: it looks correct. ``` 기본 상한 200/차원. **두 개의 이전 결함이 코드에 남아 있다.** ```java // admit(String, String):57-59 // Size-then-add was not atomic: N threads could each read size == limit - 1 and each add, so // the configured limit was an average rather than a bound. A guard that can be exceeded under // load is no guard — load is when it matters. synchronized (values) { ... } ``` 먼저 lock 없이 `values.contains(value)`로 빠른 경로를 두고, 새 값일 때만 `synchronized`로 들어가 다시 확인한다 — double-checked 패턴이다. 테스트가 경합을 직접 재현한다(`MessagingSecretLeakTest.concurrentAdmissionNeverExceedsTheLimit`). ```java // admit(MessagingTags):81-85 // Preflight every dimension before committing any of them. // // The loop used to admit each dimension as it went, so a tag set rejected on its last // dimension had already permanently added the earlier ones — spending the budget of a bounded // dimension on a series that was never emitted. ``` `wouldAdmit`으로 전수 사전 확인 후 `admit`으로 커밋한다. **사전 확인과 커밋 사이에 lock이 없으므로** 두 스레드가 동시에 통과할 수 있고, 그 경우 두 번째 `admit`이 false를 반환해 `admitted &= ...`가 false가 된다 — 상한은 지켜지고 결과만 거절이 된다. 안전한 방향이다. ### 4.4 `MessagingRedactor` — allowlist가 아니라 denylist인 이유 ```java // :11-15 *

This is a denylist of keys that must never leave the process, not an allowlist, because * diagnostic maps are assembled ad hoc at call sites and an allowlist would quietly drop the useful * half. Two categories are removed. Secrets, for the obvious reason. And per-message identity — * message ids, keys, offsets, delivery tags — because those are what turn a bounded metric into one * series per message, and a support log into a re-identification surface. ``` 27개 키. **두 범주**를 섞어 담는다. | 범주 | 키 | |---|---| | 자격증명 (11) | `authorization`, `proxy-authorization`, `cookie`, `set-cookie`, `access_token`, `refresh_token`, `api_key`, `apikey`, `password`, `client_secret`, `credential`, `secret`, `token` | | 메시지별 신원 (10) | `messageid`, `msg.id`, `correlationid`, `causationid`, `partitionkey`, `orderingkey`, `key`, `offset`, `deliverytag`, `sequence` | | 본문·진단 (5) | `payload`, `body`, `data`, `exceptionmessage`, `stacktrace` | 두 메서드가 다른 목적을 갖는다. | 메서드 | 동작 | 언제 | |---|---|---| | `sanitize` | 거부 키를 **제거** | 값이 나가면 안 되고 키의 존재도 의미 없을 때 | | `mask` | 값을 `[redacted]`로 **대체** | "Useful where the presence of a field is itself the diagnostic signal" | `isDenied`가 소문자 정규화 후 정확 일치다. **`messaging-core-api`의 `MessageHeaders.carriesACredential`은 세그먼트 매칭 + 인접 결합**(그쪽 §4.6)인데 이쪽은 정확 일치다 — 같은 저장소에서 같은 문제를 두 강도로 푼다(§12.3). `msg.id`가 목록에 리터럴로 들어 있다. `ReservedHeaders.MESSAGE_ID` 상수가 있는데 참조하지 않는다 — `analysis/messaging/messaging-core-api.md` §12.3(c)가 이 사실을 관측했다. ### 4.5 `MessagingMetrics` — 순서가 계약이다 ```java // :19-27 *

Every tag set passes the {@link CardinalityGuard} before a meter is created. That ordering is * the whole point: a meter registry never forgets a series, so a single tag carrying a message id * permanently inflates the backend. Refused tag sets are counted under a fixed {@code * messaging.tags.rejected} counter, which makes the rejection visible without creating the series * that caused it. * *

Logical messages and physical attempts are separate meters. One message redelivered four times * is one publish and five attempts; a single counter would make a redelivery storm read as traffic * growth and hide the incident. ``` 여섯 미터: | 상수 | 이름 | 종류 | |---|---|---| | `PUBLISH_TIMER` | `messaging.publish` | Timer (histogram) | | `DELIVERY_TIMER` | `messaging.delivery` | Timer (histogram) | | `MESSAGE_COUNTER` | `messaging.messages` | Counter — **첫 시도만** | | `SETTLEMENT_COUNTER` | `messaging.settlements` | Counter | | `BACKLOG_GAUGE` | `messaging.backlog` | Gauge | | `REJECTED_TAGS_COUNTER` | `messaging.tags.rejected` | Gauge (`LongAdder`) | ```java // recordDelivery:87-89 // Only the first attempt counts as a logical message; later attempts are the same // message arriving again, and counting them would inflate throughput during a storm. if (attempt == 1) { registry.counter(MESSAGE_COUNTER, micrometerTags).increment(); } ``` **거절 카운터가 gauge인 것이 중요하다.** 거절된 태그 세트는 미터를 만들지 않으므로 그 사실을 기록할 유일한 방법이 고정 이름의 별도 미터다. 그것마저 태그를 붙이면 같은 문제가 생긴다. **`recordDiagnostics`가 가장 긴 주석을 갖는다.** ```java // :122-132 // The value never becomes a tag. // // It used to: every diagnostic key and value was attached to a counter, behind a guard that // only bounded the base dimensions. One unique message id, exception message or URL per // request created one meter series per request — permanently, in the backend and in this // process's heap — and the redactor only masks keys it recognises, so free-form text carried // whatever it carried. // // What stays is the shape: which diagnostic keys occurred, counted against the bounded base // dimensions. The values belong in a structured log or a trace event, where they are bounded // by retention rather than by cardinality. ``` 현재 구현은 **키만** 태그로 만들고(`Tag.of("diagnostic", key)`), 그 키도 `guard.admit("diagnostic", key)`를 통과해야 한다. 값은 어디에도 가지 않는다. redaction 순서도 명시돼 있다 — "Redact before anything else touches the values. Diagnostics are the one place where a caller can pass arbitrary keys." `backlogs` 맵이 `computeIfAbsent`로 gauge를 한 번만 등록하고 `AtomicLong`을 재사용한다 — Micrometer gauge는 재등록해도 첫 참조를 유지하므로 필요한 패턴이다. ### 4.6 `MessagingTracer` — 브로커 홉을 건너는 추적 ```java // :12-22 *

Messaging breaks in-process trace propagation: the publish and the consume happen in different * processes, often minutes apart, so the only way the two spans meet is if the context travels in * the message headers. W3C {@code traceparent}/{@code tracestate} are used rather than a private * format so that a non-Java consumer, or a broker-side tool, can still join the trace. * *

The consume side is deliberately a link rather than a child span in the general case. * A batch consume can draw messages from many unrelated traces, and forcing them into one parent * would invent a causal relationship that does not exist. Retry and dead-letter hops keep the * original trace so a message's whole journey stays one story. ``` `inject`가 `MessageHeaders.platform(values)`를 쓴다 — 예약 이름을 쓸 수 있는 factory다. ```java // inject:38-40 *

Written as platform headers, not application headers, so that an application cannot * overwrite them and silently sever the trace. ``` `messaging-core-api`의 두 factory 분리(그쪽 §4.8)를 실제로 쓰는 **두 번째** production 지점이다(첫 번째는 `messaging-policy`의 `DeadLetterEnvelopeFactory`). `shouldLinkRatherThanContinue(batchSize)`가 `batchSize > 1`이다 — 단일 전달은 계속, 배치는 링크. 테스트가 두 경우를 각각 확인한다. `inject`가 `traceparent`가 비면 **헤더를 건드리지 않고 그대로 반환**한다. 활성 추적이 없을 때 빈 헤더를 만들지 않는다. ### 4.7 감사 — 메트릭과 분리된 이유 ```java // MessagingAuditSink.java:10-13 *

Separate from metrics and from application logs. An audit trail answers "who authorised this * destructive operation", which is a different retention, access, and integrity requirement from * "how slow was publish yesterday"; mixing them means either the audit gets dropped with the * metrics or the metrics inherit the audit's retention cost. ``` `MessagingAuditEvent`가 여섯 필드를 요구하고 넷은 빈 문자열을 거절한다 — `operation`, `subject`, `destination`, `approvalTicket`. **승인 티켓이 필수**인 것이 설계다. ```java // MessagingAuditEvent.java:10-13 *

Audit covers the operations that change state an application cannot: replay, redrive, offset * reset, purge, and delete. The subject is the operator identity and the details are passed through * {@link MessagingRedactor}, so an audit trail proves who did what without becoming a second copy * of the payload. ``` `MessagingAuditSink.inMemory()`가 `CopyOnWriteArrayList` 기반 구현을 준다 — "for tests and for a deployment that has no external audit store yet". **javadoc이 "details are passed through `MessagingRedactor`"라고 하지만 `MessagingAuditEvent` 생성자는 redactor를 부르지 않는다.** `Map.copyOf`만 한다. 즉 redaction은 호출자 책임이고 타입이 강제하지 않는다 — §17. --- ## 5. 주요 실행 경로 **메트릭:** 호출자가 `MessagingTags`를 만들어 `MessagingObservation`의 다섯 메서드 중 하나를 호출 → `MessagingMetrics.admitted(tags)` → `guard.admit(tags)` → 통과하면 Micrometer `Tags`로 변환 후 미터 기록, 거절되면 `rejectedTagSets.increment()` **추적(발행):** `tracer.inject(context, headers)` → `traceparent` 없으면 그대로 반환 → 있으면 세 헤더를 `platform` factory로 추가 **추적(수신):** `tracer.extract(headers)` → `traceparent` 없으면 `TraceContext.none()` → 있으면 세 값으로 `TraceContext` 재구성(**core-api의 W3C 검증을 통과해야 함**) **감사:** 호출자가 `MessagingAuditEvent`를 만들어 sink에 `record` --- ## 6. 실패 경로와 복구/번역 이 leaf는 `MessagingException`을 하나도 던지지 않는다. 실패를 **값으로 표현**한다. | 상황 | 결과 | |---|---| | 태그 세트가 상한 초과 | 미터를 만들지 않고 `messaging.tags.rejected` 증가 | | 진단 키가 상한 초과 | 그 키만 건너뜀 | | 진단 키가 denylist | `sanitize`가 제거 | | `traceparent` 없음 | `TraceContext.none()` | `IllegalArgumentException`을 던지는 곳은 셋 — `CardinalityGuard` 생성자(`limitPerDimension < 1`), `MessagingMetrics.recordDelivery`(`attempt < 1`), `MessagingTracer.shouldLinkRatherThanContinue`(`batchSize < 1`), `MessagingAuditEvent` 생성자(빈 필드). 전부 호출자의 프로그래밍 오류다. **`extract`가 W3C 검증에 걸릴 수 있다.** `new TraceContext(traceparent, tracestate, baggage)`가 core-api의 정규식·바이트 상한·all-zero 검사를 돌리므로(그쪽 §4.11), 다른 시스템이 보낸 손상된 `traceparent`는 `IllegalArgumentException`이 된다. 그 예외는 `MessagingException`이 아니고 `extract`는 그것을 잡지 않는다. `messaging-cloudevents`의 id 파싱과 같은 형태다(`analysis/messaging/messaging-cloudevents.md` §17). §17. --- ## 7. 트랜잭션·동시성·수명주기 트랜잭션 없음. 이 leaf는 messaging family에서 `messaging-transport-spi` 다음으로 동시성이 조밀하다. | 지점 | 도구 | 보호 | |---|---|---| | `CardinalityGuard.observed` | `ConcurrentHashMap` + `ConcurrentHashMap.newKeySet()` | 차원별 값 집합 | | `CardinalityGuard.admit` | 빠른 경로 `contains` + `synchronized(values)` 재확인 | 상한이 평균이 아니라 경계 | | `MessagingMetrics.backlogs` | `ConcurrentHashMap` + `computeIfAbsent` | gauge 한 번만 등록 | | `MessagingMetrics.rejectedTagSets` | `LongAdder` | 경합 하 카운트 | | `MessagingAuditSink.InMemory.events` | `CopyOnWriteArrayList` | 읽기 우세 | `MessagingRedactor`·`MessagingTracer`·`DefaultMessagingObservationConvention`은 상태가 없다. `MessagingTags`는 불변 record다. **`synchronized(values)`가 `Set` 인스턴스를 락으로 쓴다.** 그 `Set`은 `ConcurrentHashMap.newKeySet()`이고 외부에 노출되지 않으므로(`observed` 맵이 private) 외부 락 경합은 없다. 차원별로 락이 분리되는 효과도 있다. 수명주기 참여 없음. --- ## 8. 설정·기능 플래그·환경 차이 | 상수 | 값 | 위치 | |---|---:|---| | `CardinalityGuard.DEFAULT_LIMIT` | 200 | `:21` (private) | | `MessagingTags.NONE` | `"none"` | public | | 미터 이름 6개 | `messaging.*` | `MessagingMetrics` public 상수 | | 연산 이름 4개 | `publish`/`consume`/`settle`/`deadLetter` | `DefaultMessagingObservationConvention` public 상수 | | W3C 헤더 3개 | `traceparent`/`tracestate`/`baggage` | `MessagingTracer` public 상수 | | denylist | 27개 키 | `MessagingRedactor` private | starter가 `CardinalityGuard`를 기본 생성자로 만든다(`:264-265`) — 상한 200이 설정 불가다. --- ## 9. 퍼시스턴스/외부 시스템 세부 없다. `MeterRegistry`가 유일한 외부 접점이고 인터페이스로 주입된다. --- ## 10. 테스트 레인과 실제 증명 범위 레인: `./gradlew :messaging:messaging-observability:test`. **BUILD SUCCESSFUL, 42 tests, 0 skipped, 0 failures**. | 클래스 | 수 | 실제로 증명하는 것 | 증명하지 않는 것 | |---|---:|---|---| | `MessagingMetricCardinalityTest` | 8 | 상한 도달 시 미터 미생성, 거절 카운트, 첫 시도만 message counter | 실제 backend 동작 | | `MessagingRedactorTest` | 6 | denylist 동작, `sanitize`/`mask` 차이 | — | | `MessagingSecretLeakTest` | 9 | 금지 헤더 전수, 대소문자 무관, 메시지별 신원 제거, payload/예외 제거, **비밀이 meter registry에 도달하지 않음**, 서로 다른 진단 값이 새 series를 만들지 않음, **경합 하 상한 유지**, mask의 존재 신호 유지, 감사 이벤트가 payload를 안 담음 | — | | `MessagingTraceLinkTest` | 8 | 브로커 홉 왕복, tracestate/baggage 보존, platform 헤더로 기록, 기존 헤더 보존, 추적 없음 처리, 단일=계속/배치=링크 | 실제 collector | | `SecretLeakStaticScanTest` | 6 | **messaging 소스 트리 전체를 정적 스캔** — 콘솔 출력 없음, 민감 식별자 문자열 연결 없음, 스캐너 자체 동작 3건 | 런타임 유출 | | `SecretLeakScannerCharacterizationTest` | 5 | 스캐너 분류기의 현재 판정을 고정 | — | ### 10.1 정적 스캔 테스트 이 저장소에서 드문 형태다 — **테스트가 소스 트리를 읽는다.** ```java // SecretLeakStaticScanTest.java:16-21 *

A runtime redactor only protects the values that pass through it. A {@code toString()} that * concatenates a credential, or a log line that interpolates a payload, bypasses it entirely and is * invisible to every unit test — the leak only shows up in a production log, after the fact. A * static scan is the cheapest way to make that class of mistake fail in CI instead. ``` 분류기가 네 단계로 오탐을 줄인다 — 문자열 리터럴 제거, `+` 주변 피연산자 추출, 안전한 파생(`.length`/`.size`/`getSimpleName`…) 제외, 산술(`+ 1`) 제외, 서술형 접미사(`Id`/`Name`/`Count`…) 제외. `theScanActuallyReachesTheSourceTree`라는 테스트가 있다 — **스캔이 실제로 파일을 읽었는지 확인한다.** 경로 탐색이 실패해 0개 파일을 스캔하고 통과하는 것을 막는다. 이 저장소가 반복하는 주제(게이트가 아무것도 검사하지 않는 것을 막기)의 좋은 예다. ### 10.2 특성화 테스트의 자기 서술 `SecretLeakScannerCharacterizationTest`의 javadoc이 자기 존재 이유와 **제거 조건**을 적는다. ```java // :12-27 * Records exactly what {@link SecretLeakStaticScanTest}'s line classifier does today, so the fix * that removes its two false positives can be checked against the detection power it must keep. * *

The classifier below is a verbatim copy of the one under test. A characterization test that * called the real method would be the better design, and Wave 2 makes that possible by extracting * the classifier; until then a copy is the only way to assert on the decision procedure at all, * because every part of it is private and static. The copy is deleted in the same change that * proves the extracted classifier agrees with it. * *

Two cases here were the offenders that failed the full {@code test} run at HEAD, and naming * them as characterization turned "the build is red" into "the scanner cannot see a method call's * suffix, and cannot see that {@code + 1} is arithmetic". ``` **분류기가 두 파일에 복제돼 있고, 그 복제를 지울 조건("Wave 2")이 명시돼 있으며, 그 Wave 2는 아직 일어나지 않았다.** §12.3. --- ## 11. 빌드/ArchUnit/CI 강제 지점 | 게이트 | 이 leaf에 대해 | |---|---| | `verifyCleanArchitectureDependencies` | `["messaging-core-api"]` | | `verifyRuntimeModuleMembership` | `["app-bootstrap"]` | | vendor `api` 규칙 | Micrometer가 `MessagingMetrics` 생성자에 등장 → `api`. **통과** | | **`SecretLeakStaticScanTest`** | messaging 소스 트리 전체에 대해 콘솔 출력·민감 문자열 연결을 금지. `:messaging-observability:test`로 실행 | | ArchUnit | 전용 규칙 없음 | 네 번째가 특이하다 — **한 leaf의 테스트가 family 전체 소스를 검사한다.** 스캔 루트가 `messaging-core-api` 디렉터리를 찾아 올라가는 방식이므로 messaging 전체가 대상이다. 즉 이 leaf의 테스트 레인이 family 수준 게이트를 겸한다. --- ## 12. 실제 사용 여부와 negative-space probes 원시 증거: `evidence/raw/285-observability-tag-vocabulary-bypass.txt`. ### 12.1 Public surface reachability > **방법 주의.** 단어 검색은 `CardinalityGuard`에서 **오탐 9건**을 냈다. 저장소에 같은 이름의 클래스가 둘 있다. 아래는 import로 확인한 값이다. ``` src/application-core/.../notification/platform/observation/CardinalityGuard.java:24 ← 다른 클래스 src/messaging/messaging-observability/.../observation/CardinalityGuard.java:19 ← 이 leaf ``` import 기준으로 이 leaf의 `CardinalityGuard`를 쓰는 파일은 **한 개**다(`MessagingCoreAutoConfiguration:6`). 나머지 다섯은 notification 쪽 동명 클래스를 import한다. `messaging-schema-api`의 `SchemaRegistry`와 같은 함정이다(그쪽 §12.1). 교정 후 표: | 타입 | leaf 밖 소비자 | 판정 | |---|---:|---| | `MessagingObservation` | 2 (`DefaultMessagePublisher` + 그 테스트) | 사용됨 | | `MessagingTags` | 2 (같음) | 사용됨 | | `MessagingAuditEvent` | 2 production (`RedriveService`, `ReplayService`) + 1 test | 사용됨 | | `MessagingRedactor` | 1 (starter bean) | bean만 | | `CardinalityGuard` | 1 (starter bean) | bean만 | | **`MessagingMetrics`** | **0** | 구현이 조립되지 않음 | | **`MessagingTracer`** | **0** | | | **`MessagingAuditSink`** | **0** | | | **`DefaultMessagingObservationConvention`** | **0** | | **(a) 관측 구현이 조립되지 않는다** `MessagingMetrics`는 `MessagingObservation`의 유일한 구현이고 저장소 전체에서 자기 테스트에서만 생성된다. starter는 그 **두 생성자 인자**(`MessagingRedactor:253`, `CardinalityGuard:264`)를 bean으로 만들고 그 둘을 합칠 bean은 만들지 않는다. 그리고 `DefaultMessagePublisher`는 6인자 생성자로 조립되어 `NO_OBSERVATION`을 쓴다. 상세는 `analysis/messaging/messaging-runtime-core.md` §12.1(b)가 소유한다. 이 leaf 쪽 사실은 **구현·재료·seam이 다 있는데 조립만 없다**는 것이다. **(b) 태그 어휘가 존재하고 유일한 호출부가 우회한다** `DefaultMessagingObservationConvention`은 "the tag values are a public contract … an adapter inventing its own spelling of 'rejected' silently breaks every alert"를 이유로 만들어졌고, 소비자가 0이다. 유일한 production 호출부가 이렇게 쓴다. ```java // DefaultMessagePublisher.observe:260-269 observation.recordPublish( MessagingTags.of( profile.broker(), profile.name().value(), "publish", // ← 리터럴 result.completion().name().toLowerCase(Locale.ROOT)), // ← 직접 파생 elapsedSince(startedAt)); ``` 두 가지가 어긋난다. 1. `"publish"`가 리터럴이다. `DefaultMessagingObservationConvention.PUBLISH` 상수가 같은 값으로 존재한다. 2. **4인자 `MessagingTags.of(...)`를 쓰므로 `failureCategory`가 항상 `NONE`이다.** convention의 `publish(broker, dest, completion, Optional)`는 정확히 그 값을 채우려고 있다. 결과: 메트릭이 배선되더라도 **실패한 발행의 실패 분류가 기록되지 않는다.** `MessagingTags`가 6차원을 선언하고 실제로 채워지는 것은 4차원이다. `retryStage`도 마찬가지이지만 그쪽은 소비 경로가 없으므로 채울 주체 자체가 없다. **(c) 추적과 감사 sink는 소비자가 없다** `MessagingTracer`는 브로커 홉을 건너는 추적의 유일한 수단인데 참조가 0이다. 어댑터(`messaging-kafka`, `messaging-rabbit`)가 헤더를 매핑하지만 `MessagingTracer`를 쓰지 않는다 — 각 leaf SSOT가 무엇을 대신 하는지 답해야 한다. `MessagingAuditSink`는 인터페이스 참조가 0이다. 그런데 `MessagingAuditEvent`는 `messaging-admin-runtime`이 **production에서 쓴다**(`RedriveService:126`, `ReplayService:73`). 즉 이벤트 타입은 쓰고 sink 인터페이스는 안 쓴다 — §12.3(c). **한계.** 정적 검색이다. 파생 프로젝트가 `MessagingObservation` 구현을 제공할 수 있으나, `DefaultMessagePublisher`의 6인자 조립을 대체하려면 publisher bean 전체를 바꿔야 한다(`@ConditionalOnMissingBean(MessagePublisher.class)`). ### 12.2 Conditional sibling comparison 이 leaf에 bean은 없다. starter 쪽 sibling 셋을 비교하면 비대칭이 드러난다. | starter가 만드는 것 | 조건 | 이 leaf 소속 | 주입처 | |---|---|---|---| | `MessagingRedactor` (`:253`) | `@ConditionalOnMissingBean` | o | **0** | | `CardinalityGuard` (`:264`) | `@ConditionalOnMissingBean` | o | **0** | | `MessagingMetrics` | — | o | **만들지 않음** | **두 재료는 만들고 그것을 쓰는 것은 만들지 않는다.** 조건은 동일하고 결과가 다르다. `messaging-policy`의 `RetryDecisionEngine`/`DeadLetterOrchestrator`(그쪽 §12.2)와 같은 형태이되, 여기서는 **만들어진 것조차 주입처가 없다** — 더 이른 단계에서 끊겼다. ### 12.3 Duplicate mechanism sweep **(a) 자격증명 판정이 두 강도로 존재한다** | 위치 | 방식 | 예 | |---|---|---| | `messaging-core-api` `MessageHeaders.carriesACredential` | 정확 일치 9개 + **세그먼트 매칭 10개 + 인접 결합** | `x-api-key` 거절, `tokenizer-version` 통과 | | 이 leaf `MessagingRedactor.isDenied` | **정확 일치 27개만** | `x-api-key` **통과**(목록에 없음) | `MessagingRedactor`의 denylist에 `api_key`와 `apikey`는 있지만 `x-api-key`는 없다. core-api가 세그먼트 매칭으로 잡는 형태를 이쪽은 놓친다. 두 곳이 다른 표면을 보호하므로(헤더 vs 진단 맵) 같은 규칙일 필요는 없지만, **더 약한 쪽이 더 자유로운 입력을 받는다** — 진단 맵은 "the one place where a caller can pass arbitrary keys"라고 이 leaf 자신이 적는다. §17. **(b) 정적 스캐너 분류기가 두 파일에 복제돼 있다** `SecretLeakStaticScanTest`의 private static 분류기(5개 `Pattern` + 판정 로직)가 `SecretLeakScannerCharacterizationTest`에 **글자 그대로 복사**돼 있다. 후자의 javadoc이 그 사실과 제거 조건을 명시한다 — "The copy is deleted in the same change that proves the extracted classifier agrees with it." 그 change("Wave 2")는 일어나지 않았다. 의도된 임시 중복이고 조건이 문서화돼 있으므로 결함으로 분류하지 않는다. 다만 두 복사본이 갈라지면 특성화 테스트가 실제 스캐너와 다른 것을 고정하게 된다. **(c) 감사 sink 인터페이스가 사용처에서 다시 선언된다** ```java // messaging-admin-runtime/RedriveService.java:208 void record(dev.caskeleton.messaging.observation.MessagingAuditEvent event); ``` `MessagingAuditSink.record(MessagingAuditEvent)`와 같은 시그니처다. `messaging-admin-runtime`의 `allowed_dependencies`에 `messaging-observability`가 **포함돼 있으므로** 인터페이스를 쓸 수 있는데 쓰지 않는다. 결과: `MessagingAuditSink.inMemory()`가 제공하는 구현을 admin-runtime이 쓸 수 없고, 두 인터페이스가 구조적으로 호환되지만 타입 수준에서는 무관하다. **(d) 관측 seam이 family 밖에도 있다** notification 플랫폼이 자기 `CardinalityGuard`·`NotificationObservationConvention`·`MicrometerNotificationMetrics`를 갖는다. 같은 문제(카디널리티 경계 + 태그 어휘 + Micrometer 바인딩)를 두 family가 각자 푼다. 책임 경계가 다르므로 중복 경쟁은 아니지만, `CardinalityGuard`라는 **이름이 겹쳐** reachability 판정에 오탐을 만들었다(§12.1). 저장소 전역 판단이므로 cross-scope가 소유한다. ### 12.4 Documentation / measured-count drift | 문서 주장 | 재측정 | 결과 | |---|---|---| | `DefaultMessagingObservationConvention` javadoc: "The conversion lives here, once, rather than at each call site" | 소비자 0, 유일한 호출부가 리터럴 사용 | **불일치** | | `MessagingAuditEvent` javadoc: "the details are passed through `MessagingRedactor`" | 생성자가 redactor를 부르지 않음 | **미강제** — 호출자 책임 | | `MessagingMetrics` javadoc: 태그가 guard를 먼저 통과 | `admitted(tags)`가 모든 record 메서드의 첫 단계 | **일치** | | `MessagingRedactor` javadoc: denylist인 이유 | 27키 정확 일치 | **일치** | | `MessagingTracer` javadoc: platform 헤더로 기록 | `MessageHeaders.platform` 사용 | **일치** | | build.gradle 주석: Micrometer가 public 생성자에 등장 | `MessagingMetrics(MeterRegistry, …)` | **일치** | | `SecretLeakScannerCharacterizationTest` javadoc: Wave 2에서 복사본 제거 | 복사본 존재 | **미실현** | | `support-matrix.md:23`: 모든 messaging leaf가 unwired | 이 leaf는 `["app-bootstrap"]` | **불일치**(family drift) | --- ## 13. Git/설계 문서에서 확인한 변화와 실패 기록 | 위치 | 이전 상태 | 그것이 만든 실패 | |---|---|---| | `CardinalityGuard.admit` 주석 | size-then-add가 비원자적 | N개 스레드가 각각 `size == limit-1`을 읽고 각각 추가 → 설정된 상한이 경계가 아니라 **평균**. "A guard that can be exceeded under load is no guard — load is when it matters." | | `CardinalityGuard.admit(tags)` 주석 | 차원을 순회하며 즉시 커밋 | 마지막 차원에서 거절된 태그 세트가 앞 차원의 예산을 **영구히** 소비 — 방출된 적 없는 series에 | | `MessagingMetrics.recordDiagnostics` 주석 | 진단 키와 **값**을 전부 카운터 태그로 | 요청당 고유 message id/예외 메시지/URL 하나가 요청당 미터 series 하나를 영구 생성. redactor는 아는 키만 마스킹하므로 자유형 텍스트는 그대로 | | `SecretLeakScannerCharacterizationTest` javadoc | 스캐너가 메서드 호출 접미사와 `+ 1` 산술을 구분 못 함 | 전체 `test` 실행이 red | 세 번째가 가장 무겁다 — **경계가 있었는데 기본 차원만 보호했고 진단 값은 그 밖이었다.** 현재는 값이 태그가 되지 않고 키만 별도 guard 차원(`"diagnostic"`)을 통과한다. 첫 두 개는 같은 주제의 두 형태다 — **경계는 예산을 정확히 소비할 때만 경계다.** `messaging-policy`의 슬롯 누수 방지, `messaging-transport-spi`의 `endWork` clamp와 같은 계열이고 각 leaf §13이 소유한다. --- ## 14. 런타임·터미널 Evidence | id | 종류 | 파일 | 무엇을 보여주는가 | 한계 | |---|---|---|---|---| | EVD-285 | command | `evidence/raw/285-observability-tag-vocabulary-bypass.txt` | 9타입 단어검색 원본값, `CardinalityGuard` 동명 클래스 둘과 import별 실제 소유자, 소비자 0인 네 타입, convention의 `publish()`와 `MessagingTags.of()`와 유일한 호출부 나란히, 감사 sink 재선언과 admin-runtime의 허용 의존 | 정적 검색. 파생 프로젝트 미포함 | | EVD-286 | command | `./gradlew :messaging:messaging-observability:test --rerun-tasks` | BUILD SUCCESSFUL, 42 / 0 / 0 | `SimpleMeterRegistry` 사용. 실제 backend 없음 | --- ## 15. 명시적 설계 이유와 추론을 구분한 정리 **명시적** - 태그를 닫힌 record로 두는 이유와 무엇을 뺐는지 — `MessagingTags` javadoc - 태그 값이 공개 계약인 이유 — `DefaultMessagingObservationConvention` javadoc - 카디널리티 실패가 점진적이지 않은 이유, 조용한 대체보다 시끄러운 거절이 나은 이유 — `CardinalityGuard` javadoc - 두 개의 이전 경합/예산 결함 — 두 인라인 주석 - denylist를 고른 이유와 두 범주 — `MessagingRedactor` javadoc - guard가 미터 생성보다 먼저인 이유 — `MessagingMetrics` javadoc - 논리 메시지와 물리 시도를 분리한 이유 — 같은 javadoc + `MessagingObservation` javadoc - 진단 값이 태그가 되지 않는 이유 — `recordDiagnostics` 주석 - 메시징이 in-process 추적을 끊는 이유, W3C를 쓰는 이유 — `MessagingTracer` javadoc - 배치가 링크인 이유 — 같은 javadoc - 추적 헤더를 platform 헤더로 쓰는 이유 — `inject` javadoc - 감사를 메트릭·로그와 분리한 이유 — `MessagingAuditSink` javadoc - 정적 스캔이 필요한 이유 — `SecretLeakStaticScanTest` javadoc - 특성화 테스트의 복사본이 임시인 이유와 제거 조건 — 그 javadoc - Micrometer를 `api`로 선언한 이유 — build.gradle 주석 **추론** - `MessagingMetrics` bean이 없는 것이 미완인지 → **미상**. 두 재료가 bean으로 있다는 점이 미완을 시사한다. - `DefaultMessagePublisher`가 convention을 쓰지 않는 것이 의도인지 → **미상**. - 어댑터가 `MessagingTracer` 대신 무엇을 쓰는지 → **미확인**(각 어댑터 leaf 소유). --- ## 16. 확인한 것 / 확인하지 못한 것 **확인한 것** - 9개 타입 838줄 전문의 계약 - 42개 테스트가 통과하고 무엇을 단언하는지, 정적 스캔 테스트가 무엇을 검사하는지 - `CardinalityGuard`가 동명의 다른 클래스와 혼동된다는 것과 import 기준 실제 소비자가 1개라는 것 - `MessagingMetrics`·`MessagingTracer`·`MessagingAuditSink`·`DefaultMessagingObservationConvention` 넷이 소비자 0이라는 것 - 태그 어휘가 존재하고 유일한 호출부가 리터럴과 4인자 factory로 우회하며, 그 결과 `failureCategory`가 항상 `none`이 된다는 것 - starter가 `MessagingMetrics`의 두 재료만 bean으로 만든다는 것 - `MessagingAuditEvent`는 admin-runtime이 쓰고 `MessagingAuditSink`는 재선언된다는 것 **확인하지 못한 것** - **`MessagingMetrics` bean이 없는 것이 미완인지 확장점인지.** 저장소 안에 답이 없다. - 어댑터들이 추적 헤더를 어떻게 다루는지 — `MessagingTracer`를 쓰지 않는 것은 확인했고 무엇을 대신 하는지는 각 leaf가 답한다. - `SecretLeakStaticScanTest`의 스캔 루트가 어떤 디렉터리 집합을 실제로 덮는지 — 코드상 `messaging-core-api`를 찾아 올라가지만 실행 시 파일 수를 남기지 않았다. - `extract`가 손상된 `traceparent`를 만났을 때의 실제 빈도. - `CardinalityGuard` 상한 200이 실제 배포에서 충분한지. --- ## 17. 손볼 것 ### P2 — 태그 어휘가 존재하고 유일한 호출부가 우회해, 실패 분류가 기록되지 않는다 - **사실.** `DefaultMessagingObservationConvention`은 소비자가 0이다. 유일한 production 호출부(`DefaultMessagePublisher.observe:260-269`)가 `"publish"` 리터럴과 **4인자** `MessagingTags.of(...)`를 쓴다. 그 factory는 `failureCategory`와 `retryStage`를 `NONE`으로 고정한다. convention의 `publish(broker, dest, completion, Optional)`는 정확히 `failureCategory`를 채우려고 존재한다. - **근거.** `evidence/raw/285` §C·§D. - **왜 문제인가.** convention javadoc이 "an adapter inventing its own spelling of 'rejected' silently breaks every alert that was watching for it"를 이유로 중앙화를 선언했고, 첫 호출부가 그것을 지나쳤다. 그리고 결과가 철자 문제에 그치지 않는다 — **`MessagingTags`가 선언한 6차원 중 4개만 채워진다.** 메트릭이 배선되더라도(§다음 항목) 실패한 발행이 `failureCategory=none`으로 기록되어, "왜 실패했는가"를 메트릭에서 나눌 수 없다. `PublishResult.failure()`에 `FailureDescriptor`가 이미 있으므로 값은 손에 있다. - **확인 방법.** `evidence/raw/285` §D 재실행. 또는 `git grep -n -w DefaultMessagingObservationConvention -- src`. - **후보.** `observe(...)`가 convention의 `publish(profile.broker(), profile.name().value(), result.completion(), result.failure().map(FailureDescriptor::category))`를 호출하게 바꾼다. - **다음 단계.** **CASE 후보.** 그리고 "중앙 어휘는 첫 호출부가 쓸 때만 어휘다"가 **REFERENCE 후보**다. ### P2 — 관측 구현이 조립되지 않고, 그 재료 둘만 bean으로 존재한다 - **사실.** `MessagingMetrics`는 `MessagingObservation`의 유일한 구현이고 저장소 전체에서 자기 테스트에서만 생성된다. starter는 그 생성자 인자 둘(`MessagingRedactor:253`, `CardinalityGuard:264`)을 bean으로 만들고 `MessagingMetrics` bean은 만들지 않는다. `DefaultMessagePublisher`는 `NO_OBSERVATION`을 쓰는 6인자 생성자로 조립된다. - **근거.** `evidence/raw/285` §A·§C. `evidence/raw/283` §D(runtime-core 쪽 증거). - **왜 문제인가.** 재료·구현·seam·호출부가 전부 있고 조립 한 줄이 없다. 그리고 두 재료 bean은 주입처가 0이므로 컨텍스트에 앉아만 있다 — bean 존재 검사는 통과한다. - **확인 방법.** `git grep -n -E 'new ([a-zA-Z0-9_.]+\.)?MessagingMetrics\s*\(' -- src` → 테스트만. - **다음 단계.** `analysis/messaging/messaging-runtime-core.md` §17 첫 항목과 **동일 사건**이다. 그 leaf가 CASE를 소유하고 여기서는 이 leaf 쪽 사실(재료만 bean, 구현 미조립)을 기여한다. ### P3 — 브로커 홉 추적기가 소비자를 갖지 않는다 - **사실.** `MessagingTracer`의 leaf 밖 참조 0. 이 클래스가 존재하는 이유는 "the only way the two spans meet is if the context travels in the message headers"다. - **근거.** `evidence/raw/285` §C. - **왜 문제인가.** `messaging-core-api`의 `TraceContext`가 봉투 필드로 있고(그쪽 §4.11), 어댑터가 헤더를 매핑한다. 그런데 `traceparent`/`tracestate`/`baggage`를 헤더로 옮기는 **명시된 수단**을 아무도 쓰지 않는다. 어댑터가 각자 하고 있다면 `MessageHeaders.platform` 사용 여부와 빈 추적 처리가 어댑터마다 다를 수 있다. - **확인 방법.** `git grep -n -w MessagingTracer -- src` → 이 leaf만. 어댑터의 헤더 매퍼가 세 이름을 어떻게 다루는지 확인 필요. - **후보.** 어댑터가 `MessagingTracer`를 쓰게 하거나, 어댑터가 대신 하고 있음을 확인하고 이 클래스를 정리한다. - **다음 단계.** **OPEN QUESTION 후보.** 판정이 `messaging-kafka`·`messaging-rabbit` leaf의 사실에 걸린다. ### P3 — 감사 sink 인터페이스가 사용처에서 다시 선언된다 - **사실.** `MessagingAuditSink.record(MessagingAuditEvent)`와 같은 시그니처를 `RedriveService:208`이 자기 중첩 인터페이스로 선언한다. `messaging-admin-runtime`은 `messaging-observability`에 의존할 수 있다(registry 확인). - **근거.** `evidence/raw/285` §E. - **왜 문제인가.** `MessagingAuditSink.inMemory()`가 제공하는 구현을 admin-runtime이 쓸 수 없다. 그리고 감사 sink의 계약(분리된 보존·접근·무결성 요구)이 문서화된 곳과 실제로 구현되는 곳이 다르다. - **확인 방법.** 두 시그니처 대조. - **후보.** `RedriveService`가 `MessagingAuditSink`를 받게 한다. - **다음 단계.** **CASE 후보.** `messaging-admin-runtime` leaf SSOT와 공동 소유. ### P3 — 자격증명 판정이 core-api보다 약하다 - **사실.** `MessagingRedactor.isDenied`는 27키 **정확 일치**다. `messaging-core-api`의 `MessageHeaders.carriesACredential`은 세그먼트 매칭 + 인접 결합으로 `x-api-key`·`auth-token`·`db_password`를 잡는다. redactor의 denylist에 `api_key`·`apikey`는 있으나 `x-api-key`는 없다. - **근거.** 두 구현 대조. `MessagingRedactor.java:21-50`, `MessageHeaders.java:142-160`. - **왜 문제인가.** 두 표면이 다르지만 **더 자유로운 입력을 받는 쪽이 더 약하다.** 이 leaf 자신이 진단 맵을 "the one place where a caller can pass arbitrary keys"라고 부른다. 그리고 `recordDiagnostics`가 redaction을 첫 단계로 두는 이유가 바로 그것이다. - **확인 방법.** `redactor.isDenied("x-api-key")`가 false임을 확인. - **후보.** core-api의 세그먼트 매칭을 공유하거나 이쪽 denylist를 같은 방식으로 바꾼다. - **다음 단계.** **CASE 후보 + REFERENCE 후보**(같은 규칙을 두 강도로 구현하면 자유로운 입력 쪽을 강한 것으로 맞춘다). ### P3 — 감사 이벤트가 redaction을 강제하지 않는다 - **사실.** `MessagingAuditEvent` javadoc이 "the details are passed through `MessagingRedactor`"라고 하지만 생성자는 `Map.copyOf`만 한다. - **근거.** `MessagingAuditEvent.java:30-38`. - **왜 문제인가.** 감사 기록은 "often retained far longer than the source topic"이고 운영자가 읽는다. redaction이 호출자 책임이면 새 호출부가 그것을 잊을 수 있다. `messaging-core-api`의 `FailureDescriptor`가 512자 절단을 생성자에서 하는 것과 대비된다. - **확인 방법.** 생성자 본문 확인. `RedriveService:126`·`ReplayService:73`이 redactor를 부르는지 확인. - **후보.** 생성자가 `MessagingRedactor.sanitize`를 적용하거나, javadoc을 "호출자가 통과시켜야 한다"로 고친다. - **다음 단계.** **REFERENCE 후보**(타입이 문서화한 불변식은 타입이 강제한다). ### P3 — `extract`가 손상된 추적 헤더에 분류되지 않은 예외를 던진다 - **사실.** `MessagingTracer.extract`가 `new TraceContext(...)`를 부르고, 그 생성자는 W3C 문법·바이트 상한·all-zero를 검사해 `IllegalArgumentException`을 던진다. `extract`는 잡지 않는다. - **근거.** `MessagingTracer.java:67-75`, `TraceContext.java:60-72`. - **왜 문제인가.** 다른 시스템이 보낸 메시지의 헤더는 신뢰할 수 없는 입력이다. 손상된 `traceparent` 하나가 `MessagingException`이 아닌 예외로 소비 경로를 끊는다 — 추적이 없어야 할 자리에서 메시지 처리가 실패한다. `messaging-cloudevents`의 id 파싱과 같은 형태다(그쪽 §17). - **확인 방법.** `tracer.extract`에 잘못된 `traceparent` 헤더를 넣어 확인. - **후보.** `extract`가 검증 실패를 `TraceContext.none()`으로 강등한다 — 추적 손실이 메시지 손실보다 낫다. - **다음 단계.** **CASE 후보.** 다만 `MessagingTracer` 소비자가 0이므로 오늘의 사고는 아니다. ### 확인된 설계(문제 아님) - 태그를 닫힌 6차원 record로 두고 message id·partition key·tenant·offset을 명시적으로 배제한 것 - guard가 미터 생성보다 먼저이고, 거절을 고정 이름 미터로만 기록하는 것 - 논리 메시지 카운터를 첫 시도에만 증가시키는 것 - 진단의 **값**을 태그로 만들지 않고 키만 별도 차원으로 세는 것, redaction을 첫 단계로 두는 것 - 경합 하에서도 상한이 경계로 유지되는 double-checked 구조와, 그것을 재현하는 테스트 - 태그 세트를 사전 확인 후 커밋해 거절된 세트가 예산을 안 먹게 하는 것 - 추적을 platform 헤더로 써서 애플리케이션이 덮지 못하게 하는 것 - 배치 소비를 부모가 아니라 링크로 두는 것 - 감사를 메트릭·로그와 분리하고 승인 티켓을 필수로 둔 것 - 소스 트리를 정적 스캔하는 테스트와, 그 스캔이 실제로 파일을 읽었는지 확인하는 테스트 - Micrometer를 `api`로 선언하고 seam은 벤더 중립으로 유지한 것 --- ## Source anchors | id | kind | path | revision | what it proves | limitations | |---|---|---|---|---|---| | MOB-001 | registry | `src/config/architecture/modules.json` | `21234e38` | deps 1개, memberships `["app-bootstrap"]` | 선언 | | MOB-002 | build | `messaging-observability/build.gradle` | same | Micrometer `api`와 그 이유 | — | | MOB-003 | code | `.../observation/MessagingTags.java` | same | 닫힌 6차원, 두 factory의 차이 | — | | MOB-004 | code | `.../observation/DefaultMessagingObservationConvention.java` | same | 태그 어휘 중앙화 의도 | 소비자 0(§12.1b) | | MOB-005 | code | `.../observation/CardinalityGuard.java` | same | 상한 강제와 두 이전 결함 | 동명 클래스 존재(§12.1) | | MOB-006 | code | `.../observation/MessagingRedactor.java` | same | 27키 denylist, `sanitize`/`mask` | core-api보다 약함(§17) | | MOB-007 | code | `.../observation/MessagingMetrics.java` | same | 여섯 미터, guard 우선 순서, 진단 값 배제 | 조립되지 않음 | | MOB-008 | code | `.../observation/MessagingObservation.java` | same | seam 5메서드 | — | | MOB-009 | code | `.../observation/MessagingTracer.java` | same | W3C 왕복, platform 헤더, 배치 링크 | 소비자 0 | | MOB-010 | code | `.../observation/{MessagingAuditSink,MessagingAuditEvent}.java` | same | 감사 분리와 필수 필드 | sink 소비자 0, redaction 미강제 | | MOB-011 | test | `MessagingSecretLeakTest` (9) | same | 비밀·신원이 meter registry에 도달 못 함, 경합 하 상한 | `SimpleMeterRegistry` | | MOB-012 | test | `MessagingMetricCardinalityTest` (8) | same | 상한 동작과 거절 카운트 | — | | MOB-013 | test | `MessagingTraceLinkTest` (8) | same | 추적 왕복과 링크 판정 | 실제 collector 없음 | | MOB-014 | test | `MessagingRedactorTest` (6) | same | denylist 동작 | — | | MOB-015 | test | `SecretLeakStaticScanTest` (6) | same | messaging 소스 전체 정적 스캔 + 스캔 도달 확인 | 런타임 유출 미포함 | | MOB-016 | test | `SecretLeakScannerCharacterizationTest` (5) | same | 분류기 판정 고정 | 분류기 복사본(§12.3b) | | MOB-017 | assembly | `messaging-spring-boot-starter/.../MessagingCoreAutoConfiguration.java:253,264` | same | 두 재료 bean, `MessagingMetrics` 부재 | 해당 leaf SSOT가 소유 | | MOB-018 | cross-leaf code | `messaging-runtime-core/.../DefaultMessagePublisher.java:258-269` | same | 유일한 관측 호출부와 그 우회 | 해당 leaf SSOT가 소유 | | MOB-019 | cross-leaf code | `messaging-admin-runtime/.../RedriveService.java:126,208`, `ReplayService.java:73` | same | 이벤트 사용, sink 재선언 | 해당 leaf SSOT가 소유 | | EVD-285 | command | `evidence/raw/285-observability-tag-vocabulary-bypass.txt` | same | §12.1·§12.3(c) | 정적 검색 | | EVD-286 | command | `./gradlew :messaging:messaging-observability:test --rerun-tasks` | same | 42 / 0 / 0 | `SimpleMeterRegistry` |