The keycloak project ended with four open questions that design could not
settle. A two-VM lab was built to answer them by measurement, and this is
that material: 26 experiments, 125 raw command outputs, 22 browser captures.
Follows the import procedure in README.md.
source/ the originating repository verbatim — 78 documents, 28 SVGs,
8 manifests, plus .source-revision recording the commit
final/ the SSOT
document.md 729 lines written from the 29 experiment documents, not
concatenated: what was predicted, what was measured, and
where the measurement itself was wrong
evidence/raw 125 outputs, flattened to <experiment>__<file> because
the originals collided (01-baseline.txt appeared three
times) and the audit only globs the top level
evidence/meta one per raw file; command and exitCode are null and the
README says why rather than inventing them
evidence/browser 22 captures
assets/ three diagrams through techviz
.techviz/ their VizSpecs
A separate project rather than an addition to keycloak: the B-layer answers
that project's four questions, but the A, C and D layers are about cluster
failure, SSO and operations, and one document.md should hold one subject.
The four question records there can point here through 관계.
Recorded rather than papered over: only three of the 28 diagrams were
remade. The repository forbids hand-drawn SVG and forbids titles inside the
canvas; all 28 originals carry both, so converting them is redrawing, not
reformatting. They stay in source/ and the gap is written into the document.
verify-pipeline.py passes. audit-records.py reports no issues.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
50 KiB
messaging-observability 완전 해부
상태: COMPLETE 기준 revision:
21234e38cdb9a926cbc92bb97a2aee2e4a7d2916분석 범위:src/messaging/messaging-observabilitySSOT owner:messaging-observabilityintegration/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에 있다.
// 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차원
// :8-14
* <p>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 — 태그 값이 공개 계약이다
// :12-18
* <p>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.
*
* <p>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<FailureCategory>를 받아 enum에서 문자열을 파생한다 — 호출자가 철자를 정하지 않는다.
이 클래스는 소비자가 0이다(§12.1).
4.3 CardinalityGuard — 실패가 점진적이지 않다
// :9-17
* <p>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.
*
* <p>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/차원.
두 개의 이전 결함이 코드에 남아 있다.
// 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).
// 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인 이유
// :11-15
* <p>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 — 순서가 계약이다
// :19-27
* <p>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.
*
* <p>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) |
// 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가 가장 긴 주석을 갖는다.
// :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 — 브로커 홉을 건너는 추적
// :12-22
* <p>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.
*
* <p>The consume side is deliberately a <em>link</em> 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다.
// inject:38-40
* <p>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 감사 — 메트릭과 분리된 이유
// MessagingAuditSink.java:10-13
* <p>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. 승인 티켓이 필수인 것이 설계다.
// MessagingAuditEvent.java:10-13
* <p>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 정적 스캔 테스트
이 저장소에서 드문 형태다 — 테스트가 소스 트리를 읽는다.
// SecretLeakStaticScanTest.java:16-21
* <p>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이 자기 존재 이유와 제거 조건을 적는다.
// :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.
*
* <p>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.
*
* <p>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 호출부가 이렇게 쓴다.
// DefaultMessagePublisher.observe:260-269
observation.recordPublish(
MessagingTags.of(
profile.broker(),
profile.name().value(),
"publish", // ← 리터럴
result.completion().name().toLowerCase(Locale.ROOT)), // ← 직접 파생
elapsedSince(startedAt));
두 가지가 어긋난다.
"publish"가 리터럴이다.DefaultMessagingObservationConvention.PUBLISH상수가 같은 값으로 존재한다.- 4인자
MessagingTags.of(...)를 쓰므로failureCategory가 항상NONE이다. convention의publish(broker, dest, completion, Optional<FailureCategory>)는 정확히 그 값을 채우려고 있다.
결과: 메트릭이 배선되더라도 실패한 발행의 실패 분류가 기록되지 않는다. 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 인터페이스가 사용처에서 다시 선언된다
// 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로 두는 이유와 무엇을 뺐는지 —
MessagingTagsjavadoc - 태그 값이 공개 계약인 이유 —
DefaultMessagingObservationConventionjavadoc - 카디널리티 실패가 점진적이지 않은 이유, 조용한 대체보다 시끄러운 거절이 나은 이유 —
CardinalityGuardjavadoc - 두 개의 이전 경합/예산 결함 — 두 인라인 주석
- denylist를 고른 이유와 두 범주 —
MessagingRedactorjavadoc - guard가 미터 생성보다 먼저인 이유 —
MessagingMetricsjavadoc - 논리 메시지와 물리 시도를 분리한 이유 — 같은 javadoc +
MessagingObservationjavadoc - 진단 값이 태그가 되지 않는 이유 —
recordDiagnostics주석 - 메시징이 in-process 추적을 끊는 이유, W3C를 쓰는 이유 —
MessagingTracerjavadoc - 배치가 링크인 이유 — 같은 javadoc
- 추적 헤더를 platform 헤더로 쓰는 이유 —
injectjavadoc - 감사를 메트릭·로그와 분리한 이유 —
MessagingAuditSinkjavadoc - 정적 스캔이 필요한 이유 —
SecretLeakStaticScanTestjavadoc - 특성화 테스트의 복사본이 임시인 이유와 제거 조건 — 그 javadoc
- Micrometer를
api로 선언한 이유 — build.gradle 주석
추론
MessagingMetricsbean이 없는 것이 미완인지 → 미상. 두 재료가 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는 재선언된다는 것
확인하지 못한 것
MessagingMetricsbean이 없는 것이 미완인지 확장점인지. 저장소 안에 답이 없다.- 어댑터들이 추적 헤더를 어떻게 다루는지 —
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>)는 정확히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으로 만들고MessagingMetricsbean은 만들지 않는다.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-rabbitleaf의 사실에 걸린다.
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-runtimeleaf 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을 강제하지 않는다
- 사실.
MessagingAuditEventjavadoc이 "the details are passed throughMessagingRedactor"라고 하지만 생성자는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 |