feat(error-tracking): return frame releases over the resolution wire - #76413
feat(error-tracking): return frame releases over the resolution wire#76413ablaszkiewicz wants to merge 2 commits into
Conversation
PR #75456 removed the frame-derived release enrichment because it could not survive remote symbol resolution: Frame.release is #[serde(skip)] (the frame JSON shape doubles as clickhouse output), so the release the symbol-set join attached was dropped when the resolved exception was serialized back to the processing worker. With 100% of resolution traffic on cymbal-resolution, that made the whole path dead code. This brings the join back and fixes the transport instead of the field: - Restore the symbol-set→release join (for_symbol_set_ref on fresh resolves, for_symbol_set_id on PG frame-cache loads) and the in-memory Frame.release carrier, verbatim from the pre-#75456 state. - Add Done.releases_json to cymbal.resolution.v1: a JSON-array sidecar of the releases bound to the symbol sets that resolved the item's frames, deduped by release id. Additive proto3 field, so both deploy skew directions degrade to today's behavior: an older server sends empty bytes (treated as no releases), an older client ignores the field. The exception JSON shape is untouched. - Processing accumulates the sidecar per event (local, unsampled resolution reads Frame.release directly) and, only when the event-level resolver ($release_id, mobile app-metadata hash) found nothing, emits the latest release by created_at as $exception_release. Event-level resolution keeps precedence. Parity gets an explicit test: the byte-for-byte exception-list comparison cannot see any of this (the field is serde-skipped), so a release-attaching fake resolver now asserts both paths produce the same $exception_release. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
🦔 ReviewHog reviewed this pull requestFound 0 must fix, 1 should fix, 1 consider. Published 2 findings (view the review). |
|
ReviewHog Alpha 🦔 If you find any issues helpful - please reply "valid", "invalid", etc., for evaluation purposes 🙏 |
There was a problem hiding this comment.
ReviewHog Report
Business logic
Issues: 2 issues
Files (20)
rust/cymbal/src/core/types/frames/releases.rsrust/cymbal/src/core/types/frames/mod.rsrust/cymbal/src/core/symbolication/symbol/local.rsrust/cymbal/src/core/symbolication/symbol/records.rsrust/cymbal/.sqlx/query-7002c21150e6a6e42b57cb9ebf9e71e76ff7b6595c2953ed108da0bd0f2279b9.jsonrust/cymbal/.sqlx/query-eb812dd93e8af10192d414fb1d55fcb2bbd74c80b293672c4579531521e905cd.jsonrust/cymbal/src/core/types/langs/apple.rsrust/cymbal/src/core/types/langs/custom.rsrust/cymbal/src/core/types/langs/dart.rsrust/cymbal/src/core/types/langs/go.rsrust/cymbal/src/core/types/langs/hermes.rsrust/cymbal/src/core/types/langs/java.rsrust/cymbal/src/core/types/langs/js.rsrust/cymbal/src/core/types/langs/native.rsrust/cymbal/src/core/types/langs/node.rsrust/cymbal/src/core/types/langs/php.rsrust/cymbal/src/core/types/langs/python.rsrust/cymbal/src/core/types/langs/ruby.rsrust/cymbal/src/modes/processing/fingerprinting/mod.rsrust/cymbal/src/modes/processing/normalization.rs
What were the main changes
- Restores the pre-#75456 symbol-set→release join, verbatim: ReleaseRecord::for_symbol_set_ref on fresh resolves (local.rs) and for_symbol_set_id on PG frame-cache loads (records.rs)
- Adds Frame.release as an in-memory-only carrier (#[serde(skip)]) so it never leaks into the ClickHouse-bound event JSON
- Adds ReleaseRecord::collect_from_frames (dedup by id) and ReleaseRecord::latest (deterministic tie-break) helpers used by both the wire sidecar and the fallback selector
- Regenerated .sqlx offline query metadata for the restored join queries (byte-identical to pre-#75456)
- Mechanical fallout: every language's Frame struct-literal construction sites now set the new release field to None
| pub async fn for_symbol_set_ref<'c, E>( | ||
| e: E, | ||
| symbol_set_ref: &str, | ||
| team_id: i32, | ||
| ) -> Result<Option<Self>, sqlx::Error> | ||
| where | ||
| E: Executor<'c, Database = sqlx::Postgres>, | ||
| { | ||
| let row = sqlx::query_as!( | ||
| Self, | ||
| r#" | ||
| SELECT r.id, r.team_id, r.hash_id, r.created_at, r.version, r.project, r.metadata | ||
| FROM posthog_errortrackingsymbolset ss | ||
| INNER JOIN posthog_errortrackingrelease r ON ss.release_id = r.id | ||
| WHERE ss.ref = $1 AND ss.team_id = $2 | ||
| "#, | ||
| symbol_set_ref, | ||
| team_id | ||
| ) | ||
| .fetch_optional(e) | ||
| .await?; | ||
|
|
||
| Ok(row) | ||
| } |
There was a problem hiding this comment.
for_symbol_set_ref queries with the untruncated ref, but stored refs are truncated to MAX_REF_BYTES
Why we think it's a valid issue
- Checked: Fetched the actual PR-head code (releases.rs and local.rs at 0ba8f76) since the local checkout is a post-refactor(error-tracking): remove the broken $exception_releases enrichment #75456 state without the restored join; cross-read saving.rs truncation and js.rs ref derivation.
- Found: Every
SymbolSetRecordDB path truncates the ref before hittingposthog_errortrackingsymbolset.ref—loadat saving.rs:466 (let truncated_ref = truncate_ref(set_ref)→WHERE ... ref = $2), plussave/save_data_if_missing/save_failureat saving.rs:519/548/588 — withMAX_REF_BYTES = 2048(saving.rs:26-47). The storedrefis therefore never longer than 2048 bytes. - Found: The PR's
ReleaseRecord::for_symbol_set_refruns... INNER JOIN ... WHERE ss.ref = $1 AND ss.team_id = $2bound to the raw, untruncatedsymbol_set_ref— notruncate_ref. The PR call site in local.rs passes the identical&set_ref(fromframe.symbol_set_ref(debug_images)) to bothSymbolSetRecord::load(...)andReleaseRecord::for_symbol_set_ref(...)in onetokio::try_join!. - Found: For JS web frames
symbol_set_refis derived from the client-controlledsource_url(js.rs:121-124 →get_ref/source_url), which genuinely can exceed 2048 bytes — the exact casetruncate_refwas added for (its comment cites the Postgres BTree index size limit) and that the negative cache is byte-weighted against. - Impact: For any symbol set whose ref exceeds 2048 bytes and has a release bound, the fresh-resolve path silently drops the release:
loadtruncates and matches the set, whilefor_symbol_set_refcompares the truncated storedss.refagainst the full$1and returns zero rows. The release exists but never reachesFrame.release, degrading the$exception_releasefallback. Reachable, named trigger + consequence, trivial one-line fix; not speculative (long refs are a known, already-handled input). Thefor_symbol_set_idUUID path is correctly noted as unaffected.
Issue description
The restored fresh-resolve join looks up a symbol set's release by matching the raw, untruncated symbol_set_ref string against posthog_errortrackingsymbolset.ref (ReleaseRecord::for_symbol_set_ref, releases.rs:80-103, called from local.rs:159-166 with &set_ref as received from frame.symbol_set_ref(debug_images)). But posthog_errortrackingsymbolset.ref is never stored verbatim for long refs: both SymbolSetRecord::load and SymbolSetRecord::save (symbol_store/saving.rs) run the ref through truncate_ref(..) (capped at MAX_REF_BYTES = 2048) before querying/writing. So for any symbol set whose ref exceeds 2048 bytes, SymbolSetRecord::load(&self.pool, team_id, &set_ref) still succeeds (it truncates internally and matches the stored, truncated value), while the concurrently-run ReleaseRecord::for_symbol_set_ref(&self.pool, &set_ref, team_id) compares the DB's truncated ss.ref against the full untruncated $1 and returns zero rows — silently dropping the release even though the symbol set (and its bound release) genuinely exists. This is a real, reachable case: JS web frames derive their symbol_set_ref from the resolved source URL (RawJSFrame::get_ref/symbol_set_ref, langs/js.rs), which can plausibly exceed 2048 bytes (long query strings, signed CDN URLs, etc.) — exactly the scenario truncate_ref and its dedicated tests in saving.rs were written to handle. Note the PG-frame-cache load path (ReleaseRecord::for_symbol_set_id in records.rs, matching by the stored symbol_set_id UUID) is NOT affected, since it doesn't depend on ref truncation at all — only the fresh-resolve path in local.rs is impacted.
Suggested fix
Truncate set_ref the same way before calling ReleaseRecord::for_symbol_set_ref, e.g. reuse (or expose) the existing truncate_ref helper from symbol_store::saving at the call site in local.rs, or have for_symbol_set_ref truncate internally the same way SymbolSetRecord::load/save do, so both queries key off the identical truncated value that's actually persisted in posthog_errortrackingsymbolset.ref.
Prompt to fix with AI (copy-paste)
## Context
@rust/cymbal/src/core/types/frames/releases.rs#L80-103
@rust/cymbal/src/core/types/frames/releases.rs#L159-166
<issue_description>
The restored fresh-resolve join looks up a symbol set's release by matching the raw, untruncated `symbol_set_ref` string against `posthog_errortrackingsymbolset.ref` (`ReleaseRecord::for_symbol_set_ref`, releases.rs:80-103, called from local.rs:159-166 with `&set_ref` as received from `frame.symbol_set_ref(debug_images)`). But `posthog_errortrackingsymbolset.ref` is never stored verbatim for long refs: both `SymbolSetRecord::load` and `SymbolSetRecord::save` (symbol_store/saving.rs) run the ref through `truncate_ref(..)` (capped at `MAX_REF_BYTES = 2048`) before querying/writing. So for any symbol set whose ref exceeds 2048 bytes, `SymbolSetRecord::load(&self.pool, team_id, &set_ref)` still succeeds (it truncates internally and matches the stored, truncated value), while the concurrently-run `ReleaseRecord::for_symbol_set_ref(&self.pool, &set_ref, team_id)` compares the DB's truncated `ss.ref` against the full untruncated `$1` and returns zero rows — silently dropping the release even though the symbol set (and its bound release) genuinely exists. This is a real, reachable case: JS web frames derive their symbol_set_ref from the resolved source URL (`RawJSFrame::get_ref`/`symbol_set_ref`, langs/js.rs), which can plausibly exceed 2048 bytes (long query strings, signed CDN URLs, etc.) — exactly the scenario `truncate_ref` and its dedicated tests in saving.rs were written to handle. Note the PG-frame-cache load path (`ReleaseRecord::for_symbol_set_id` in records.rs, matching by the stored `symbol_set_id` UUID) is NOT affected, since it doesn't depend on ref truncation at all — only the fresh-resolve path in local.rs is impacted.
</issue_description>
<issue_validation>
- **Checked:** Fetched the actual PR-head code (releases.rs and local.rs at 0ba8f76) since the local checkout is a post-#75456 state without the restored join; cross-read saving.rs truncation and js.rs ref derivation.
- **Found:** Every `SymbolSetRecord` DB path truncates the ref before hitting `posthog_errortrackingsymbolset.ref` — `load` at saving.rs:466 (`let truncated_ref = truncate_ref(set_ref)` → `WHERE ... ref = $2`), plus `save`/`save_data_if_missing`/`save_failure` at saving.rs:519/548/588 — with `MAX_REF_BYTES = 2048` (saving.rs:26-47). The stored `ref` is therefore never longer than 2048 bytes.
- **Found:** The PR's `ReleaseRecord::for_symbol_set_ref` runs `... INNER JOIN ... WHERE ss.ref = $1 AND ss.team_id = $2` bound to the raw, untruncated `symbol_set_ref` — no `truncate_ref`. The PR call site in local.rs passes the identical `&set_ref` (from `frame.symbol_set_ref(debug_images)`) to both `SymbolSetRecord::load(...)` and `ReleaseRecord::for_symbol_set_ref(...)` in one `tokio::try_join!`.
- **Found:** For JS web frames `symbol_set_ref` is derived from the client-controlled `source_url` (js.rs:121-124 → `get_ref`/`source_url`), which genuinely can exceed 2048 bytes — the exact case `truncate_ref` was added for (its comment cites the Postgres BTree index size limit) and that the negative cache is byte-weighted against.
- **Impact:** For any symbol set whose ref exceeds 2048 bytes and has a release bound, the fresh-resolve path silently drops the release: `load` truncates and matches the set, while `for_symbol_set_ref` compares the truncated stored `ss.ref` against the full `$1` and returns zero rows. The release exists but never reaches `Frame.release`, degrading the `$exception_release` fallback. Reachable, named trigger + consequence, trivial one-line fix; not speculative (long refs are a known, already-handled input). The `for_symbol_set_id` UUID path is correctly noted as unaffected.
</issue_validation>
## Task
Investigate the issue and solve it
<potential_solution>
Truncate `set_ref` the same way before calling `ReleaseRecord::for_symbol_set_ref`, e.g. reuse (or expose) the existing `truncate_ref` helper from `symbol_store::saving` at the call site in local.rs, or have `for_symbol_set_ref` truncate internally the same way `SymbolSetRecord::load`/`save` do, so both queries key off the identical truncated value that's actually persisted in `posthog_errortrackingsymbolset.ref`.
</potential_solution>
| let mut resolved = resolved; | ||
| for r_frame in resolved.iter_mut() { | ||
| r_frame.release = release.clone(); // Enrich with release information | ||
|
|
||
| // And save back to the DB |
There was a problem hiding this comment.
Release metadata is deep-cloned once per resolved sub-frame into an entry-count-bounded cache
Why we think it's a valid issue
- Checked: PR-head
resolve_impl(local.rs) enrich/save loop, the frame cache construction, andReleaseRecord/metadatatyping in releases.rs. - Found: In
resolve_impl,r_frame.release = release.clone()runs once perresolved.iter_mut()element and is then re-cloned viar_frame.clone()intoErrorTrackingStackFrame::new(...); everyrecordis pushed and theVecis stored inself.cache, so N identical release copies (metadata included) are retained per raw frame. - Found: The frame cache is
CacheBuilder::new(config.frame_cache_size).expire_after(ttl_policy).build()(local.rs:91-93) with no.weigher(...)— bounded by entry count only, no byte-based eviction. Contrast the dedicated release cache added in this same PR, which is byte-weighed precisely becauseReleaseRecord::approx_size_bytes's doc calls the client-controlledmetadata: Option<Value>the dominant, effectively-unbounded term. The frame cache now carries that same payload without the byte bound. - Found: The duplication is genuinely redundant — inlined sibling sub-frames share an identical release, whereas their
contents/contextlegitimately differ per part, so release is the one field cloned needlessly N times. - Impact: Real but minor: meaningful memory only when client metadata is large AND inlining expansion N is high, and Arc-wrapping only saves the intra-entry N-duplication (per-entry cost stays). The fix is a trivial, idiomatic
Arcwrap (not an abstraction, not speculative — multi-part frames and large metadata both occur), consistent with the memory concern the author already engineered for. Fits the reviewer'sconsider(lowest, real-but-minor) rating, which I keep.
Issue description
r_frame.release = release.clone(); runs once per element of resolved.iter_mut(), so a single raw frame that expands into N resolved sub-frames (common for inlined JS/native/Java frames) now clones the same ReleaseRecord — including its free-form metadata: Value JSON column, which the existing estimated_size/weigher doc comment on ReleaseRecord explicitly calls out as "unbounded" and "the only reason" a byte-aware weigher exists for the release cache — N times into the Vec<ErrorTrackingStackFrame> that gets stored in self.cache (Cache<RawFrameId, Vec<ErrorTrackingStackFrame>>, built via CacheBuilder::new(config.frame_cache_size) with no .weigher(...), i.e. sized purely by entry count). The identical release payload is now redundantly duplicated in memory per part, in a cache that has no byte-based eviction to compensate. records.rs's load_all has the same per-found clone pattern.
Suggested fix
Wrap the shared release payload in an Arc<ReleaseRecord> (or at least Arc-wrap the metadata: Value field) so attaching the same release to multiple sibling frames is a cheap refcount bump instead of a deep JSON clone, especially since this cache has no byte-based sizing to absorb the extra memory.
Prompt to fix with AI (copy-paste)
## Context
@rust/cymbal/src/core/symbolication/symbol/local.rs#L176-180
<issue_description>
`r_frame.release = release.clone();` runs once per element of `resolved.iter_mut()`, so a single raw frame that expands into N resolved sub-frames (common for inlined JS/native/Java frames) now clones the same `ReleaseRecord` — including its free-form `metadata: Value` JSON column, which the existing `estimated_size`/weigher doc comment on `ReleaseRecord` explicitly calls out as "unbounded" and "the only reason" a byte-aware weigher exists for the release cache — N times into the `Vec<ErrorTrackingStackFrame>` that gets stored in `self.cache` (`Cache<RawFrameId, Vec<ErrorTrackingStackFrame>>`, built via `CacheBuilder::new(config.frame_cache_size)` with no `.weigher(...)`, i.e. sized purely by entry count). The identical release payload is now redundantly duplicated in memory per part, in a cache that has no byte-based eviction to compensate. `records.rs`'s `load_all` has the same per-`found` clone pattern.
</issue_description>
<issue_validation>
- **Checked:** PR-head `resolve_impl` (local.rs) enrich/save loop, the frame cache construction, and `ReleaseRecord`/`metadata` typing in releases.rs.
- **Found:** In `resolve_impl`, `r_frame.release = release.clone()` runs once per `resolved.iter_mut()` element and is then re-cloned via `r_frame.clone()` into `ErrorTrackingStackFrame::new(...)`; every `record` is pushed and the `Vec` is stored in `self.cache`, so N identical release copies (metadata included) are retained per raw frame.
- **Found:** The frame cache is `CacheBuilder::new(config.frame_cache_size).expire_after(ttl_policy).build()` (local.rs:91-93) with no `.weigher(...)` — bounded by entry count only, no byte-based eviction. Contrast the dedicated release cache added in this same PR, which is byte-weighed precisely because `ReleaseRecord::approx_size_bytes`'s doc calls the client-controlled `metadata: Option<Value>` the dominant, effectively-unbounded term. The frame cache now carries that same payload without the byte bound.
- **Found:** The duplication is genuinely redundant — inlined sibling sub-frames share an identical release, whereas their `contents`/`context` legitimately differ per part, so release is the one field cloned needlessly N times.
- **Impact:** Real but minor: meaningful memory only when client metadata is large AND inlining expansion N is high, and Arc-wrapping only saves the intra-entry N-duplication (per-entry cost stays). The fix is a trivial, idiomatic `Arc` wrap (not an abstraction, not speculative — multi-part frames and large metadata both occur), consistent with the memory concern the author already engineered for. Fits the reviewer's `consider` (lowest, real-but-minor) rating, which I keep.
</issue_validation>
## Task
Investigate the issue and solve it
<potential_solution>
Wrap the shared release payload in an `Arc<ReleaseRecord>` (or at least `Arc`-wrap the `metadata: Value` field) so attaching the same release to multiple sibling frames is a cheap refcount bump instead of a deep JSON clone, especially since this cache has no byte-based sizing to absorb the extra memory.
</potential_solution>
Replace the Done.releases_json sidecar with a serializable Frame.release, so the per-frame release association survives the resolution wire. Strip releases at into_resolved and in the PG frame cache save path so they never reach clickhouse-bound JSON. Also truncate the symbol set ref in the release join to match how stored refs are written. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Changes
This PR brings back the old frame-release concept, and this time it survives the service split. Symbolication attaches the symbol set's release to each frame it resolves (the pre-#75456 join, restored), and
Frame.releaseis now serializable, so it crosses the cymbal <-> cymbal-resolution wire inside each resolved frame's JSON:No new proto fields. Skew-safe in both directions: an older server just doesn't send the key (parses to
None), an older client ignores the unknown key.Then we do release resolution in this order:
$release_id? Try to resolve release from itcreated_atSince
Frame.releaseserializes now, two guards keep it out of everything clickhouse-bound:into_resolvedstrips it from all frames right after$exception_releaseselection, before any output serializationTests
DoneFrame.release, missing key parses toNone(old-server skew)$exception_releaseidentical on the local and remote paths🤖 Agent context
Autonomy: Human-driven (agent-assisted)
Written with Claude Code. First iteration shipped releases as a
Done.releases_jsonsidecar (keptFrame.releaseserde-skipped); we reworked it to per-frame serialization because the flattening lost the frame-release association and the sidecar was an extra moving part. The strip-at-into_resolvedchoke point plus the PG save-path strip replace the type-level#[serde(skip)]guarantee.