# Perf Hooks `MagicPerfHooks.sink` is the one seam a diagnostic reads magic's runtime activity through: which controller repainted and why, what a repository merged, how long a query reload, an action or an event dispatch took, which casts ran, which timers fired and which realtime messages arrived. Magic depends on no tooling package; the tool assigns the sink. - [Introduction](#introduction) - [Installing a Sink](#installing-a-sink) - [Event Kinds](#event-kinds) - [Notification Causes](#notification-causes) - [Pairing HTTP Requests](#pairing-http-requests) - [Cost and Failure Modes](#cost-and-failure-modes) ## Introduction A frame profile says a frame was slow; it does not say that a `Countdown` tick made three controllers repaint, or that a broadcast storm re-cast the same `json` attribute two hundred times. The sink reports those facts as typed `MagicPerfEvent` values so a tool can attribute a frame to the magic activity inside it. ## Installing a Sink ```dart MagicPerfHooks.sink = (MagicPerfEvent event) { switch (event) { case ControllerNotified(:final controller, :final cause): record('${controller.runtimeType}', cause.name); case QueryReloaded(:final type, :final startUs, :final endUs): span('reload $type', startUs, endUs); default: break; } }; // Uninstall. MagicPerfHooks.sink = null; ``` `MagicPerfEvent` is sealed, so a `switch` over it is exhaustive. Every timestamp is `FlutterTimeline.now`, in microseconds on the clock the engine stamps frame timings with. ## Event Kinds | Event | Fields | Reported when | |---|---|---| | `ControllerNotified` | `controller`, `cause` | `MagicController.refreshUI()` notifies (every `setState` helper and `ValidatesRequests` go through it). | | `RepositoryUpserted` | `type`, `count` | `Repository.upsertFromList` or `upsertFromShow` merged rows; `type` is the model type. | | `QueryReloaded` | `type`, `startUs`, `endUs`, `fromCache` | A `RepositoryQuery.reload()` settled; `fromCache` is `true` when `ensureFresh()` joined a first load already in flight instead of sending its own request. | | `ActionRan` | `type`, `startUs`, `endUs`, `outcome` | `RunsActions.runAction` ran an action to `ActionSucceeded` or `ActionFailed`. A refused re-entry never runs and is not reported. | | `EventDispatched` | `type`, `listenerCount`, `startUs`, `endUs` | The event dispatcher delivered an event to its typed and wildcard listeners. | | `AttributeCast` | `castType` | `Model.getAttribute` computed a cast. A memoised `datetime`/`bool`/`int`/`double` read is not reported; `json` is cast, and reported, on every read. A `CastsAttributes` instance reports its runtime type name. | | `TimerTicked` | `ownerType` | A `Countdown` tick, a `Debouncer` fire or a `Poll` read. | | `BroadcastReceived` | `event` | `BroadcastListeners` fanned out one realtime message. | ## Notification Causes `ControllerNotified.cause` is a `MagicNotifyCause`, carried by a zone value each instrumented path sets around the callback it invokes, so it follows that callback across its `await`s: | Cause | Set around | Kind | |---|---|---| | `setState` | `MagicStateMixin.setState`, so every `setSuccess`, `setError`, `setLoading`, `setEmpty` | derived | | `repositoryQuery` | a `RepositoryQuery` notifying its listeners | derived | | `timerTick` | a `Countdown` `onTick`, a `Debouncer` callback, a `Poll` read | root | | `broadcast` | each `BroadcastListeners` handler for one realtime message | root | | `direct` | nothing: the app called `refreshUI()` on its own | | A root cause replaces whatever it inherits, because a timer firing or a message arriving is where new work starts: a `Debouncer` armed inside a broadcast handler reports `timerTick` when it fires. A derived cause keeps an inherited one and applies only when there is none, so a `setSuccess` after an `await` inside a `Debouncer` callback still reports `timerTick`. A cause lasts until its callback returns, or until the `Future` it returns completes. A timer or stream subscription registered inside it runs in the same zone later, but reports `direct`: it is no longer part of the work that set the cause. A callback that starts async work without returning its `Future` (`() { reload(); }` rather than `reload`) closes the cause at once, and a `setSuccess` after its first `await` reports `setState`. ## Pairing HTTP Requests `DioNetworkDriver` stamps every request with a process-unique id before any interceptor runs. `MagicRequest.id`, `MagicResponse.id` and `MagicError.id` carry the same value for the same request, so an interceptor can pair concurrent requests with their answers even when a slow first request completes last: ```dart class TimingInterceptor extends MagicNetworkInterceptor { final Map _startedUs = {}; @override dynamic onRequest(MagicRequest request) { final int? id = request.id; if (id != null) _startedUs[id] = FlutterTimeline.now; return request; } @override dynamic onResponse(MagicResponse response) { final int? startUs = _startedUs.remove(response.id); // ... return response; } } ``` A request built by hand, and every response `Http.fake` answers, carries a `null` id. ## Cost and Failure Modes - With no sink installed, each instrumented site costs one null check and constructs no event object. - A sink that throws is reported through `debugPrint` and ignored. It runs before the notification it observes, so an unguarded throw would stop the screen repainting; a broken observer costs its own numbers, never the app's frames. - The sink is process-wide. A test that installs one resets it to `null` in `tearDown`.