Skip to content

feat(error-tracking): scope Flutter AOT source uploads - #124249

Merged
trunk-io[bot] merged 6 commits into
masterfrom
feat/flutter-aot-symbol-upload
Oct 28, 2026
Merged

trunk-io[bot] merged 6 commits into
masterfrom
feat/flutter-aot-symbol-upload

Conversation

@hpouillot

@hpouillot hpouillot commented Oct 27, 2026 •

Copy link
Copy Markdown
Contributor

Problem

Flutter developers using obfuscated Android and iOS builds cannot inspect Dart stack frames or app source in error tracking (SDK request).
The existing source collector can include Flutter framework files from the build machine when it packages Dart debug symbols.

Changes

  • Developers can opt in to app-only source context with posthog-cli symbol-sets upload --include-source --source-root <app-directory>.
  • The CLI restricts loose symbols and dSYM bundles to canonical paths under the source root. Omitting --source-root preserves existing uploads.
  • A shared CLI helper applies source filtering and reports when no app sources match.
  • Dart AOT frames resolve from their reported PC; the cache keeps that lookup separate from native return-address lookups.
  • The approach uses native debug images and PostHog's existing symbol-set upload path. No application UI changes in this PR.

Note

This change pairs with posthog-flutter#677. Android event-to-UI behavior is verified locally; iOS AOT runtime verification remains pending.

How did you test this code?

  • Ran the CLI suite and targeted Cymbal parser and database-backed resolver tests against invented Flutter fixtures.
  • Ran Rust formatting checks and hogli ci:preflight --strict on a branch based on current master.
  • Test rationale: The packaged-upload test catches lost source-root scoping; Cymbal tests catch wrong function-entry lookup and cross-mode cache reuse.
  • An invented Android emulator app produced obfuscated and non-obfuscated release exceptions. The local issue UI resolved both builds to main.dart@33 with uploaded app source.
  • Repeated captures grouped into one issue. An async AOT trace resolved both app frames around its synthetic suspension frame.
  • Missing or mismatched debug images left the invented events intact and their frames unresolved. No symbols were uploaded to a remote project.
  • Not checked: iOS AOT runtime capture; release mode does not run on the iOS simulator. An unsigned iOS device release build generated Mach-O symbols.

Flutter AOT app frame and source context

👉 Stay up-to-date with PostHog coding conventions for a smoother review.

Release status

  • No feature flag controls this change

Docs update

cli/README.md documents the new flag. No existing docs/ guide covers CLI source-root scoping; Flutter onboarding remains unchanged until the coordinated SDK and backend changes are ready to release.

🤖 Agent context

Autonomy: Human-driven (agent-assisted)

Agent: pi, gpt-6-sol

  • Session: Cockpit f4faad3e-b1d7-4e72-90f1-eab640e344b9 (no public session link available). Tools: read, edit, write, bash, codemode.
  • Skills: /pr, /writing-pr-descriptions, /writing-tests, /writing-code-comments, /writing-user-facing-copy, /running-ci-preflight, /announcing-behavior-changes.
  • An invented sample app supplied the Flutter fixtures. Their current DWARF paths use neutral replacements while preserving debug IDs; earlier PR commits still contain local build paths. Searches for open Flutter-symbol PRs found no duplicate.

@hpouillot hpouillot self-assigned this Oct 27, 2026
@trunk-io

trunk-io Bot commented Oct 27, 2026 •

Copy link
Copy Markdown

😎 Merged successfully - details.

@github-actions

github-actions Bot commented Oct 27, 2026 •

Copy link
Copy Markdown
Contributor

🤖 CI report

✅ Trunk lane — non-backend lane

This PR is assigned to the non-backend lane. It does not run backend Python tests and may merge in parallel with PRs in other lanes.

⚠️ Comment density — 3% of added code lines are comments (11 of 333)

This section warns when comments are more than 3% of the code lines a PR adds, and alerts above 6%. Before agent-assisted PRs, the typical share was about 2%. Only full-line comments count. Docstrings, generated files, snapshots, migrations, and workflow files are left out.

Comments that restate the code, record how the change came about, or narrate the next line add noise for the next reader. Keep the comments that explain a reason the code cannot show, and remove the rest. See .agents/skills/writing-code-comments/SKILL.md for the house rules.

Files with the most added comment lines:

File Comment lines Added lines
rust/cymbal/src/core/types/langs/native.rs 6 55
cli/src/debug_symbols/mod.rs 3 28
cli/src/debug_symbols/upload.rs 1 25
rust/cymbal/src/core/symbolication/symbol_store/native.rs 1 60

This check does not block merging. It updates on every push and clears when the share drops.

@hpouillot
hpouillot marked this pull request as ready for review October 27, 2026 17:58
@parameterai

parameterai Bot commented Oct 27, 2026 •

Copy link
Copy Markdown

Risk: No findings

This follow-up cleans up the CLI source-path filtering (consolidating it into filter_source_paths_for_upload with unchanged behavior), replaces the Flutter DWARF test fixtures with redacted synthetic-path versions plus an in-test guard against home-directory leaks, and adds a dart-exact-pc hash-domain to frame IDs. No new security issues; the previously reported home-directory leak in the fixtures is fixed and verified.

Sentinel reviewed d59171e · Review settings

@posthog posthog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PostHog Review · Standard · reviewed 2cfd329: 1 Should fix.

See this review and its findings in PostHog Review. Comment @posthog review --deep for a Deep review. A Deep review takes longer and looks deeper than a Standard review.

Comment on lines +243 to +246
let lookup_addr = if self.lang.as_deref() == Some("dart") {
relative_addr
} else {
relative_addr.saturating_sub(1)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 · Include the Dart lookup mode in the frame cache identity

When otherwise identical native frames arrive with and without lang: "dart", this branch resolves different addresses, but RawNativeFrame::frame_id omits the lookup mode. LocalSymbolResolver therefore reuses the first frame's memory/database result, including a symbol miss at a function entry, for the other frame. Include the Dart lookup mode in the frame identity.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 23ae44f: the frame cache key now separates Dart exact-PC lookups from other native return-address lookups. The key remains stable across ASLR slides, and existing non-Dart keys stay unchanged.

// Dart AOT reports direct program counters, which can point to a
// function entry. Other native traces report return addresses, so
// look up the preceding call instruction for those frames.
let lookup_addr = if self.lang.as_deref() == Some("dart") {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

question: Does every Dart AOT frame really report an exact PC? posthog-flutter#677 tags every frame it parses from an AOT trace with lang: "dart", so this branch skips the -1 for all of them, not just the top one.

For caller frames the printed pc is normally a return address: the instruction after the bl. Looking that up without -1 returns the line after the call, or the next function when the call is the last instruction. That wrong function name then feeds the issue fingerprint.

The tests can't tell the two modes apart. In the Android fixture, 0x205210 (resolver test), 0x205217 (parser test), and 0x205218 (the SDK's own test) all resolve to main.dart:7.

If only some frames carry exact PCs (async awaiter frames, say), could the SDK mark those frames explicitly instead of the server keying off lang? That also keeps lang a display hint and makes the frame_id cache gap easy to close. Either way, a resolver test at a mid-stack return address where -1 and exact lookup give different lines would establish which behavior is correct.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I compiled an invented Dart AOT program with separate DWARF and captured synchronous caller and asynchronous frames. Each reported PC resolved with atos to the expected Dart function and line. On these ARM64 caller frames, PC and PC minus one mapped to the same location, so that capture alone does not distinguish the policies. The existing function-entry regression does: subtracting one there resolves the preceding function. Dart's native_stack_traces decoder uses the reported PC directly. This PR keeps exact lookup for Dart and now separates its cache key; a captured Flutter event-to-UI check is still pending.

Comment thread cli/src/dsym/mod.rs Outdated
let filtered = source_bundle::filter_source_paths(&all_paths);
let filtered = match source_root {
Some(root) => {
source_bundle::filter_source_paths_under_root(&all_paths, root)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion: The choice between filter_source_paths_under_root and filter_source_paths, plus the "No readable sources found inside the source root" warning, lives in two places: here and in cli/src/debug_symbols/mod.rs:141-144 (warning at :91-96). The copies differ only in the variable they pass. Any change to the root rule (such as a different warning or adding --source-root to dsym upload) has to be made twice.

Move the choice into source_bundle and call it from both sites:

pub fn filter_source_paths_scoped<'a>(paths: &'a [String], root: Option<&Path>) -> Vec<&'a str> {
    match root {
        Some(root) => filter_source_paths_under_root(paths, root),
        None => filter_source_paths(paths),
    }
}

The empty-result warning can move in too if you don't need the binary path in the message.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in 23ae44f. Both upload paths now use source_bundle::filter_source_paths_for_upload for root selection, counts, and the empty-result warning. Loose native symbols keep their existing toolchain exclusions.

.find(|f| f.debug_id == "850c70a2-6592-a70c-3e49-c0e443794d23")
.unwrap();
let upload = file.into_upload(None, false).unwrap();
let upload = file.into_upload(None, false, None).unwrap();

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion: Nothing tests that --source-root actually limits what gets uploaded. Every into_upload and package_dsym_bundles call in this file passes include_source=false and None, and the new unit test calls filter_source_paths_under_root directly. If into_upload or zip_dwarf_binary dropped the root and passed None, framework sources would ship again and the suite would stay green.

go_dwarf_source_extraction_uses_line_table_paths already builds a DWARF fixture with real sources on disk. Copy it with include_source=true and a root that excludes one of those files, then assert on __source/manifest.json in the packaged zip:

let upload = file.into_upload(None, true, Some(&root)).unwrap();
// read __source/manifest.json out of upload.data
assert!(!manifest.files.is_empty());
assert!(manifest.files.keys().all(|p| Path::new(p).canonicalize().unwrap().starts_with(&root)));

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added to the existing ELF packaging test in 23ae44f. It creates readable sources inside and outside the selected root, calls into_upload(None, true, Some(root)), then checks the packaged ZIP manifest includes only the in-root source and its original bytes. The CLI test suite and clippy pass.

@trunk-io

trunk-io Bot commented Oct 28, 2026

Copy link
Copy Markdown

Static Badge   Static Badge   Static Badge

View Full Report ↗︎ ⋅ Docs

@hpouillot hpouillot added the cross-lane-change Allow for cross lane (i.e. django + rust) changes to merge at once label Oct 28, 2026
@trunk-io
trunk-io Bot merged commit e1e70f8 into master Oct 28, 2026
217 of 218 checks passed
@trunk-io
trunk-io Bot deleted the feat/flutter-aot-symbol-upload branch October 28, 2026 13:26
@hpouillot hpouillot removed the cross-lane-change Allow for cross lane (i.e. django + rust) changes to merge at once label Oct 28, 2026
@deployment-status-posthog

deployment-status-posthog Bot commented Oct 28, 2026 •

Copy link
Copy Markdown

Deploy status

Environment Status Deployed At Workflow
dev ✅ Deployed 2026-10-28 13:47 UTC Run
prod-us ✅ Deployed 2026-10-28 14:08 UTC Run
prod-eu ✅ Deployed 2026-10-28 13:58 UTC Run

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants