Skip to content

fix(bloc): omit synthetic BlocBase.addError frame from auto-captured stack traces#4808

Open
realmeylisdev wants to merge 3 commits into
felangel:masterfrom
realmeylisdev:fix/bloc-omit-add-error-frame
Open

fix(bloc): omit synthetic BlocBase.addError frame from auto-captured stack traces#4808
realmeylisdev wants to merge 3 commits into
felangel:masterfrom
realmeylisdev:fix/bloc-omit-add-error-frame

Conversation

@realmeylisdev
Copy link
Copy Markdown
Contributor

Status

READY

Breaking Changes

NO

Description

Closes #3585.

When BlocBase.addError(error) is called without an explicit StackTrace, the trace auto-captured via StackTrace.current always has BlocBase.addError itself as its top frame. This obscures the caller's actual call site in error reporters such as Crashlytics/Sentry, and forces every project to ship a custom BlocObserver that strips the frame back out (the workaround documented in the issue).

This PR strips the synthetic top frame from the auto-captured trace at the source, so the resulting StackTrace begins at the caller's call site.

Reproducing the original bug (before this fix)

class MyCubit extends Cubit<int> {
  MyCubit() : super(0);

  void doWork() {
    addError(Exception('boom'));
  }
}

void main() {
  Bloc.observer = _PrintObserver();
  MyCubit().doWork();
}

class _PrintObserver extends BlocObserver {
  @override
  void onError(BlocBase<dynamic> bloc, Object error, StackTrace stackTrace) {
    print(stackTrace.toString().split('\n').first);
    super.onError(bloc, error, stackTrace);
  }
}

Before: prints #0 BlocBase.addError (package:bloc/src/bloc_base.dart:145:33) — the bloc framework hides the actual call site.

After: prints #0 MyCubit.doWork (file:///path/to/my_cubit.dart:5:5) — the user's call site is the top frame.

Implementation notes

  • StackTrace.current is captured inline inside addError (not via a helper), which pins the synthetic-frame depth at exactly 1 across VM, AOT, and dart2js, regardless of inlining decisions.
  • A new private static helper BlocBase._withoutTopFrame performs string-only manipulation (StackTrace.fromString after dropping the first textual line) — it doesn't capture a trace itself, so it doesn't affect frame depth.
  • The fix is position-based, not symbol-based: it strips the first textual frame regardless of what symbol that frame resolves to. This makes it robust under code obfuscation, where the symbol-based workaround in the issue (filtering by member == 'BlocBase.addError') breaks. @maRci002 raised exactly this concern in the issue thread.
  • A newline < 0 fallback returns the trace unchanged for the single-frame edge case (defensive — covers any unexpected platform behavior such as web traces collapsing to one line).

Why a minor (9.3.0) version bump

The StackTrace flowing into BlocObserver.onError is part of the observable public surface; tooling that buckets/asserts on trace shape will see a one-frame-shorter trace. SemVer-strict reading argues for minor. Happy to drop to 9.2.1 (patch) if you'd rather treat this as a pure bug fix — flagging this for your call.

Type of Change

  • 🛠️ Bug fix (non-breaking change which fixes an issue)
  • ✨ New feature (non-breaking change which adds functionality)
  • ❌ Breaking change (fix or feature that would cause existing functionality to change)
  • 🧹 Code refactor
  • ✅ Build configuration change
  • 📝 Documentation
  • 🗑️ Chore

Test Plan

Three tests added to the existing group('addError', …) in packages/bloc/test/cubit_test.dart:

  1. omits BlocBase.addError frame when no stackTrace is passed — calls addError(error) and asserts the captured trace's first line contains neither BlocBase.addError nor bloc_base.dart.
  2. passes through an explicit stackTrace unchanged — regression guard for the explicit-trace path; asserts same(explicit).
  3. captured stackTrace is non-empty when no stackTrace is passed — sanity guard against over-trimming.

Verification:

  • dart test from packages/bloc/119/119 passing (all existing + 3 new).
  • dart analyze from packages/bloc/No issues found.
  • Browser run (dart test -p chrome) couldn't be executed locally (no Chrome installed in dev environment) and CI doesn't currently exercise browser tests for pkg:bloc. The newline < 0 fallback keeps web safe-by-default — worst case we don't trim, we don't regress.

Notes

  • No new runtime dependency (kept package:bloc dependent on meta only).
  • No changes needed to flutter_bloc, bloc_test, hydrated_bloc, replay_bloc, or Emitter.forEach/Emitter.onEach. Verified that none of those paths route errors through BlocBase.addError's auto-capture branch — they either use completer.completeError or pass user-supplied stack traces through.

@realmeylisdev
Copy link
Copy Markdown
Contributor Author

@felangel — would appreciate a review when you have time. One open question flagged in the description: should this go out as 9.3.0 (minor, since the StackTrace shape delivered to BlocObserver.onError changes by one frame) or 9.2.1 (patch, treating it as a pure bug fix)? Currently set to 9.3.0 in the PR but happy to retarget to patch if you prefer.

@codecov
Copy link
Copy Markdown

codecov Bot commented May 9, 2026

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@felangel
Copy link
Copy Markdown
Owner

felangel commented May 9, 2026

Thanks for the PR! I'm not sure if this is the correct fix though 🤔
I'll take a closer look at this shortly because I'd love to avoid having to manually manipulate stack traces.

@maRci002
Copy link
Copy Markdown

I tested this on https://dartpad.dev/

void main() {
  print('hello');

  print(Test.callThis(true));
}

class Test {
  static StackTrace callThis(bool withoutTopFrame) {
    return thenCallThis(withoutTopFrame);
  }

  static StackTrace thenCallThis(bool withoutTopFrame) {
    if (withoutTopFrame) {
      return _withoutTopFrame(StackTrace.current);
    }

    return StackTrace.current;
  }

  static StackTrace _withoutTopFrame(StackTrace trace) {
    final raw = trace.toString();
    final newline = raw.indexOf('\n');
    if (newline < 0) return trace;
    return StackTrace.fromString(raw.substring(newline + 1));
  }
}

withoutTopFrame = false prints:

hello
Error
    at get current (https://stable.api.dartpad.dev/artifacts/dart_sdk_new.js:157851:30)
    at Test.thenCallThis (blob:null/cfc11c3f-19a9-45a0-87a9-725c9c8372ef:97:30)
    at Test.callThis (blob:null/cfc11c3f-19a9-45a0-87a9-725c9c8372ef:91:24)
    at Proxy.main$ (blob:null/cfc11c3f-19a9-45a0-87a9-725c9c8372ef:126:26)
    at Object.main$ [as main] (blob:null/cfc11c3f-19a9-45a0-87a9-725c9c8372ef:53:10)

withoutTopFrame = true prints:

hello
    at get current (https://stable.api.dartpad.dev/artifacts/dart_sdk_new.js:157851:30)
    at Test.thenCallThis (blob:null/35263d50-647c-43d5-a0d3-6f089c510ca8:95:59)
    at Test.callThis (blob:null/35263d50-647c-43d5-a0d3-6f089c510ca8:91:24)
    at Proxy.main$ (blob:null/35263d50-647c-43d5-a0d3-6f089c510ca8:126:26)
    at Object.main$ [as main] (blob:null/35263d50-647c-43d5-a0d3-6f089c510ca8:53:10)

I was expecting Test.thenCallThis is removed but the Error string was removed instead.

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.

feat: ignoring addError in reported stacktrace

3 participants