Background
The current StackWatch pipeline already has the right high-level shape:
ErrorEvent
-> Fingerprinter
-> L1 fingerprint cache
-> L2 vector similarity
-> L3 LLM RCA
The existing Fingerprinter already does useful deterministic work such as filtering framework frames, removing line-number volatility through Class#method, using SHA-256, and versioning fingerprints.
However, several gaps prevent L1 from acting as a durable error identity / aggregation layer:
- Normalization is implicit inside
StackFrame / Fingerprinter instead of being an explicit preprocessing stage.
ErrorEvent only carries the outer exception and outer stack; Caused by / root-cause chains are lost.
- L1 history is currently backed by Caffeine TTL cache, so restart / expiry loses exact-match history.
- L1 cache hits reuse RCA but do not update
memberCount / lastSeen / occurrence history.
- Application-frame detection relies mainly on framework denylist; third-party libraries can be misclassified as business frames.
- Message content is currently excluded from exact fingerprints. This avoids volatile IDs, but can over-merge different exceptions thrown from the same location.
- L2 persistence still depends on in-memory cluster state for actual cosine matching, so historical grouping is not fully durable across restart.
The goal of this issue is to make the deterministic path a stable, explainable and persistent identity layer before vector similarity and LLM reasoning.
Target architecture
Error source
|
v
ErrorEvent // raw facts
|
v
ErrorNormalizer // deterministic normalization
|
v
NormalizedError
|
v
Fingerprinter V2
|
+--> strict fingerprint exact match
| |
| +--> record occurrence + reuse RCA
|
+--> loose fingerprint exact match
| |
| +--> candidate merge / grouping
|
v
Vector similarity
|
v
LLM RCA
Principle:
- Fingerprint = deterministic error identity / deduplication
- Vector = semantic similarity / nearby error family
- LLM = root-cause reasoning
P0 — Preserve the full cause chain in ErrorEvent
Problem
Both the Logback IThrowableProxy path and direct Throwable path currently only extract the outer exception.
For example:
RuntimeException
at OrderService...
Caused by: RedisCommandTimeoutException
at Redisson...
StackWatch should not fingerprint this only as RuntimeException.
Proposed model
Introduce a structured throwable representation:
record ThrowableInfo(
String type,
String message,
List<RawStackFrame> frames,
ThrowableInfo cause
) {}
Update ErrorEvent conceptually toward:
record ErrorEvent(
String eventId,
String appName,
String env,
String release,
Instant occurredAt,
ThrowableInfo exception,
String loggerName,
String threadName,
String traceId,
String spanId,
Map<String, String> attributes
) {}
The exact field expansion can be incremental, but the cause chain is required.
Collection rules
- Logback path: recursively traverse
IThrowableProxy#getCause().
- Direct
Throwable path: recursively traverse Throwable#getCause().
- Preserve raw values; do not normalize in the collector.
- Add recursion depth / cycle safety.
P0 — Add explicit ErrorNormalizer
Normalization should be a first-class preprocessing stage instead of being embedded in the hash generator.
Introduce:
record NormalizedError(
String outerExceptionType,
String effectiveExceptionType,
String normalizedMessage,
String normalizedRootCauseMessage,
List<NormalizedFrame> applicationFrames,
List<NormalizedFrame> rootCauseFrames,
int causeDepth
) {}
Suggested components:
preprocess/
ErrorNormalizer
CauseResolver
MessageNormalizer
StackFrameNormalizer
Fingerprinter
Fingerprinter should hash an already-normalized structure and should not own parsing / classification policy.
P0 — Cause resolution rules
Resolve the effective exception before fingerprinting.
Suggested policy:
outer exception
-> cause
-> cause
-> deepest meaningful cause
Known wrapper types may be skipped when selecting the effective type, e.g.:
CompletionException
ExecutionException
InvocationTargetException
UndeclaredThrowableException
Fingerprint selection priority:
- root-cause application frames
- outer application frames
- root-cause top frames
- outer top frames
Do not immediately fall back from too few application frames to arbitrary outer framework frames if a more meaningful root-cause frame set exists.
P0 — Message normalization
Do not hash the raw exception message directly.
Normalize volatile tokens into placeholders, for example:
Order 981273 not found
-> Order <NUM> not found
requestId=550e8400-e29b-41d4-a716-446655440000
-> requestId=<UUID>
Failed at 2026-08-29T10:22:12
-> Failed at <TIME>
Initial normalization candidates:
- UUID ->
<UUID>
- IPv4 / IPv6 ->
<IP>
- timestamps / dates ->
<TIME>
- long hex / hashes ->
<HEX>
- long business IDs ->
<NUM>
- selected URL query values ->
<VALUE>
- repeated whitespace -> single whitespace
Do not blindly replace every number.
Preserve semantic codes where possible, including:
- HTTP status
- SQLState
- errno / DB error code
- gRPC status
- stable application error codes
The implementation should be deterministic, testable and versioned.
P0 — Application frame detection: prefer allowlist
Current framework-prefix denylist is useful as a fallback, but it can treat third-party libraries such as Hikari, MySQL, Redisson, Jackson or MyBatis as application frames.
Add configuration such as:
stackwatch:
fingerprint:
application-packages:
- com.mycompany
- cn.mycompany
Selection rule:
configured application package match
-> application frame
else
-> library/framework frame
Keep the denylist only as a fallback when no application package is configured.
P0 — Fingerprint V2
Keep V1 compatibility and introduce V2 rather than silently changing existing hashes.
Strict fingerprint
Purpose: identify the same deterministic error.
Canonical input proposal:
version=v2
app=order-service
type=java.lang.NullPointerException
message=cannot invoke <VALUE> because <VALUE> is null
frame=com.foo.OrderService#create
frame=com.foo.OrderFacade#submit
frame=com.foo.OrderController#create
Then:
Recommended strict inputs:
appName
+ effective/root-cause exception type
+ normalized message template
+ top N normalized application frames
Loose fingerprint
Purpose: catch same exception/call-path variants without relying on semantic vector search yet.
Recommended input:
appName
+ effective/root-cause exception type
+ top N normalized application frames
No normalized message.
Scope rules
- Include
appName in the fingerprint identity.
- Do not include
env in the fingerprint identity; keep environment as an aggregation dimension.
- Keep
FingerprintVersion in persistence / lookup keys.
- Keep user-facing fingerprint record parts for explainability.
P0 — Persistent fingerprint history / ErrorGroup
Caffeine must remain an accelerator, not the source of truth.
Introduce a persistent repository, e.g.:
interface ErrorGroupRepository {
Optional<ErrorGroup> findByFingerprint(String appName,
FingerprintVersion version,
String fingerprint);
ErrorGroup create(...);
ErrorGroup recordOccurrence(...);
}
Suggested persisted error_group fields:
id
app_name
fingerprint
fingerprint_version
loose_fingerprint
exception_type
root_cause_type
message_template
first_seen
last_seen
occurrence_count
cluster_id
analysis_id
created_at
updated_at
Recommended uniqueness:
UNIQUE(app_name, fingerprint_version, fingerprint)
Lookup path:
Caffeine / Redis
-> miss
persistent ErrorGroupRepository
Cache may expire. Error identity must not.
P0 — Record every occurrence on L1 hit
Current exact-cache-hit behavior should not return immediately without updating the group.
Expected behavior:
exact fingerprint hit
-> occurrence_count += 1
-> last_seen = event.occurredAt
-> optionally persist / sample the occurrence
-> reuse prior RCA
This should make the following correct:
first event -> count 1
second event -> count 2
third event -> count 3
regardless of whether the RCA came from cache.
P1 — Add ErrorOccurrence
Separate the durable error identity from individual events.
Concept:
ErrorGroup = what error is this?
ErrorOccurrence = this concrete occurrence of that error
Suggested occurrence fields:
id
group_id
event_id
occurred_at
env
trace_id
span_id
exception_message
raw_stack_trace
Retention may be sampled / capped later; group counters must remain accurate.
This will support:
- first seen / last seen
- frequency trends
- weekly digest
- environment breakdown
- trace drill-down
P1 — Durable L2 history
The current PgVector implementation still uses an in-memory clusterIndex for actual cosine matching. After restart, saved vector documents do not reconstruct the full historical matching index.
After the deterministic layer is fixed, evolve L2 so historical cluster similarity survives restart.
Options may include:
- true pgvector nearest-neighbor query
- repository-level vector lookup independent of process memory
- startup reconstruction only as an interim fallback
This is intentionally lower priority than persistent L1 identity.
Proposed runtime flow
ErrorEvent
|
v
ErrorNormalizer.normalize(event)
|
v
NormalizedError
|
v
Fingerprinter.generateV2(normalized)
|
+--> strict exact lookup
| |
| +--> hit -> record occurrence -> reuse RCA
|
+--> loose exact lookup
| |
| +--> optional candidate grouping policy
|
v
Embedding / vector similarity
|
+--> hit -> merge group/cluster -> persist occurrence
|
v
LLM RCA
|
+--> create new cluster + ErrorGroup + first occurrence
Suggested implementation order
Acceptance criteria
Cause chain
Stack normalization
Message normalization
Persistence
Occurrences
Compatibility
Non-goals for this issue
- Replacing the LLM RCA pipeline.
- Designing automatic remediation / runbook execution.
- Performing semantic clustering entirely in the normalization layer.
- Treating vector similarity as a replacement for deterministic fingerprinting.
The intent is to strengthen the deterministic foundation so later semantic grouping and RCA operate on stable, durable error identities.
Background
The current StackWatch pipeline already has the right high-level shape:
The existing
Fingerprinteralready does useful deterministic work such as filtering framework frames, removing line-number volatility throughClass#method, using SHA-256, and versioning fingerprints.However, several gaps prevent L1 from acting as a durable error identity / aggregation layer:
StackFrame/Fingerprinterinstead of being an explicit preprocessing stage.ErrorEventonly carries the outer exception and outer stack;Caused by/ root-cause chains are lost.memberCount/lastSeen/ occurrence history.The goal of this issue is to make the deterministic path a stable, explainable and persistent identity layer before vector similarity and LLM reasoning.
Target architecture
Principle:
P0 — Preserve the full cause chain in
ErrorEventProblem
Both the Logback
IThrowableProxypath and directThrowablepath currently only extract the outer exception.For example:
StackWatch should not fingerprint this only as
RuntimeException.Proposed model
Introduce a structured throwable representation:
Update
ErrorEventconceptually toward:The exact field expansion can be incremental, but the cause chain is required.
Collection rules
IThrowableProxy#getCause().Throwablepath: recursively traverseThrowable#getCause().P0 — Add explicit
ErrorNormalizerNormalization should be a first-class preprocessing stage instead of being embedded in the hash generator.
Introduce:
Suggested components:
Fingerprintershould hash an already-normalized structure and should not own parsing / classification policy.P0 — Cause resolution rules
Resolve the effective exception before fingerprinting.
Suggested policy:
Known wrapper types may be skipped when selecting the effective type, e.g.:
Fingerprint selection priority:
Do not immediately fall back from too few application frames to arbitrary outer framework frames if a more meaningful root-cause frame set exists.
P0 — Message normalization
Do not hash the raw exception message directly.
Normalize volatile tokens into placeholders, for example:
Initial normalization candidates:
<UUID><IP><TIME><HEX><NUM><VALUE>Do not blindly replace every number.
Preserve semantic codes where possible, including:
The implementation should be deterministic, testable and versioned.
P0 — Application frame detection: prefer allowlist
Current framework-prefix denylist is useful as a fallback, but it can treat third-party libraries such as Hikari, MySQL, Redisson, Jackson or MyBatis as application frames.
Add configuration such as:
Selection rule:
Keep the denylist only as a fallback when no application package is configured.
P0 — Fingerprint V2
Keep V1 compatibility and introduce V2 rather than silently changing existing hashes.
Strict fingerprint
Purpose: identify the same deterministic error.
Canonical input proposal:
Then:
Recommended strict inputs:
Loose fingerprint
Purpose: catch same exception/call-path variants without relying on semantic vector search yet.
Recommended input:
No normalized message.
Scope rules
appNamein the fingerprint identity.envin the fingerprint identity; keep environment as an aggregation dimension.FingerprintVersionin persistence / lookup keys.P0 — Persistent fingerprint history / ErrorGroup
Caffeine must remain an accelerator, not the source of truth.
Introduce a persistent repository, e.g.:
Suggested persisted
error_groupfields:Recommended uniqueness:
Lookup path:
Cache may expire. Error identity must not.
P0 — Record every occurrence on L1 hit
Current exact-cache-hit behavior should not return immediately without updating the group.
Expected behavior:
This should make the following correct:
regardless of whether the RCA came from cache.
P1 — Add
ErrorOccurrenceSeparate the durable error identity from individual events.
Concept:
Suggested occurrence fields:
Retention may be sampled / capped later; group counters must remain accurate.
This will support:
P1 — Durable L2 history
The current PgVector implementation still uses an in-memory
clusterIndexfor actual cosine matching. After restart, saved vector documents do not reconstruct the full historical matching index.After the deterministic layer is fixed, evolve L2 so historical cluster similarity survives restart.
Options may include:
This is intentionally lower priority than persistent L1 identity.
Proposed runtime flow
Suggested implementation order
ThrowableInfoand collect full cause chainNormalizedErrorCauseResolverStackFrameNormalizerMessageNormalizerapplication-packagesallowlistErrorGroupRepositoryoccurrence_count/last_seenErrorOccurrenceAcceptance criteria
Cause chain
RuntimeExceptions with different meaningful root causes produce different strict fingerprints when appropriate.CompletionException/ExecutionExceptionresolve to the same effective underlying type and fingerprint when stacks/messages normalize identically.Stack normalization
Message normalization
Order 12345 not foundandOrder 98765 not foundcan produce the same normalized message template.Persistence
Occurrences
lastSeen.firstSeenremains unchanged after subsequent occurrences.Compatibility
Non-goals for this issue
The intent is to strengthen the deterministic foundation so later semantic grouping and RCA operate on stable, durable error identities.