recordAppEvent function

void recordAppEvent(
  1. AppEvent event, {
  2. Object? source,
})

Reports event to every surface that is listening.

Inside a scenario it lands on the current transition, and the step's Events pane shows it. Inside a running app it lands on a mounted devbar's tabs. Both at once, if both are there; neither is wired by the project.

A no-op — one null check and an empty list — when nothing is listening, which is every bare flutter test run. That is what makes it safe for a project to leave these calls in its shared fakes forever, and it holds all the way: a listener that throws is reported to the Zone and skipped rather than surfacing here, and one that unregisters itself mid-report costs its neighbours nothing.

class FakeApi implements Api {
  Future<User> login(String email) async {
    recordAppEvent(AppEvent.request(
        method: 'POST', url: '/login', status: 200));
    return User(email);
  }
}

source names the reporter, for the one case where a surface has already been handed this event by another route: a listener registered with a matching ignoreSource skips it. Everybody else — the buffer, and every other listener — sees it normally. See addAppEventListener.

Implementation

void recordAppEvent(AppEvent event, {Object? source}) {
  // The stack is captured here and resolved much later, and the split is the
  // whole reason this is affordable. Measured JIT at a hundred frames — the
  // depth of a widget test — capturing costs 3.5 µs and resolving it costs
  // 78.6 µs, so resolving every event at the run cap would be ~390 ms a
  // scenario. Captured here and resolved in [AppEventBuffer.drain], the
  // expensive half is paid only for the events that survive the per-step cap
  // and reach a file.
  //
  // Behind the buffer's null check, which is already the first thing this
  // function does: a production app and a plain `flutter test` have no buffer
  // and pay nothing at all.
  appEventBuffer?.add(event, StackTrace.current);
  if (_listeners.isEmpty) return;
  // Over a copy, and never over the live list. A listener that unregisters on
  // its way out — a devbar disposing mid-report — shifts everything behind it
  // down a slot, so a walk by index silently *skips* its neighbour and a
  // for-in throws a concurrent modification. Neither is acceptable: the whole
  // promise of this call is that reporting cannot disturb the app.
  for (var registration in List.of(_listeners)) {
    if (source != null && registration.ignoreSource == source) continue;
    try {
      registration.listener(event);
    } on Object catch (e, stack) {
      // A surface that breaks may not take the app's own call with it — this
      // is a line in somebody's fake, not a place to fail. Loud, but not here:
      // the zone reports it the way an unhandled async error is reported.
      Zone.current.handleUncaughtError(e, stack);
    }
  }
}