Skip to content

Instantly share code, notes, and snippets.

@AndrewDongminYoo
Created November 30, 2025 02:19
Show Gist options
  • Select an option

  • Save AndrewDongminYoo/572357d72ea0172c7f84a2948a605956 to your computer and use it in GitHub Desktop.

Select an option

Save AndrewDongminYoo/572357d72ea0172c7f84a2948a605956 to your computer and use it in GitHub Desktop.
A minimal frame timing logger that records slow frames to the console/Logcat.
// 🎯 Dart imports:
import 'dart:io';
import 'dart:ui';
// 🐦 Flutter imports:
import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart';
// 📦 Package imports:
import 'package:logger/logger.dart';
/// Global [Logger] instance configured with a [PrettyPrinter] and a [DebugLogFilter].
///
/// This logger aims to be:
/// - Developer-friendly in local runs (rich, colored output, low log level)
/// - Noise-free in tests and production (filtering based on build mode and env flags)
///
/// Typical usage:
///
/// ```dart
/// logger.d('Something happened');
/// logger.w('This frame is slow');
/// logger.e('Unexpected error', time: DateTime.now(), error: error, stackTrace: stackTrace);
/// ```
final logger = Logger(printer: printer, filter: filter);
/// Shared [PrettyPrinter] configuration for the global [logger].
///
/// Key choices:
/// - `dateTimeFormat`: Always prints timestamp for easier log correlation across files.
/// - `stackTraceBeginIndex`: Skips the logger wrapper so stack traces point to your call site.
/// - `methodCount` / `errorMethodCount`: Keep stack traces short but actionable.
/// - `noBoxingByDefault`: Avoids ASCII boxes around logs for better readability in IDE consoles.
/// - `colors`: Enabled only when running on non-web platforms with ANSI support.
///
/// This configuration is optimized for:
/// - Android/iOS development and Logcat/Xcode consoles
/// - Readability when scanning logs for performance issues
final printer = PrettyPrinter(
dateTimeFormat: DateTimeFormat.dateAndTime,
stackTraceBeginIndex: 1,
methodCount: 3,
errorMethodCount: 6,
noBoxingByDefault: true,
/// Whether the current stdout supports ANSI escape sequences (colored output).
///
/// This is intentionally guarded by [kIsWeb] because this utility is
/// intended for mobile targets and `dart:io` is not available on the web.
colors: !kIsWeb && stdout.supportsAnsiEscapes,
);
/// Global [LogFilter] instance used by [logger].
///
/// Policy:
/// - In debug builds (`kDebugMode == true`): log from [Level.debug] and above.
/// - In non-debug builds: log only from [Level.warning] and above.
/// - In test mode: suppress all logs.
///
/// Test mode is controlled via a compile-time environment flag:
/// - Set `--dart-define=FLUTTER_TEST=true` when running tests to silence logger output.
final filter = DebugLogFilter(
isDebugMode: kDebugMode,
isTestMode: const bool.fromEnvironment('FLUTTER_TEST'),
);
/// A log filter that adjusts the minimum log level based on build/test mode.
///
/// The intent is:
/// - During local development, be verbose enough to help debugging.
/// - During release/staging, avoid spamming logs and keep only warnings/errors.
/// - During tests, keep output deterministic and quiet unless explicitly needed.
class DebugLogFilter extends LogFilter {
DebugLogFilter({
required this.isDebugMode,
required this.isTestMode,
});
/// Whether the app is running in debug mode (`kDebugMode`).
final bool isDebugMode;
/// Whether the app is running under tests with logging disabled.
///
/// This is typically wired via `--dart-define=FLUTTER_TEST=true`.
final bool isTestMode;
/// Effective minimum log level given the current mode.
///
/// - Debug mode: [Level.debug]
/// - Non-debug mode: [Level.warning]
Level get minLevel => isDebugMode ? Level.debug : Level.warning;
/// Returns whether the given [event] should be logged.
///
/// - If [isTestMode] is `true`, no logs are emitted.
/// - Otherwise, logs are emitted when `event.level >= minLevel`.
@override
bool shouldLog(LogEvent event) {
if (isTestMode) {
return false;
}
return event.level.index >= minLevel.index;
}
}
// cspell:words jank
/// A minimal frame timing logger that records slow frames to the console/Logcat.
///
/// This widget is intended to be wrapped around parts of your widget tree
/// when investigating **jank or rendering issues on real devices**.
///
/// Design goals:
/// - Only enabled in environments where performance diagnostics are useful
/// (e.g. staging release builds), so it does not pollute production logs.
/// - Emit a warning when a frame exceeds [warnBudgetMs] (default: 16ms for 60fps).
/// - Every 60 frames, print an aggregate summary with average and worst frame time.
///
/// Usage examples:
///
/// ```dart
/// // 1) Explicitly enabled:
/// FrameTimingLogger(
/// enabled: true,
/// label: 'home',
/// child: MyApp(),
/// );
///
/// // 2) Only enabled in staging flavor release builds:
/// FrameTimingLogger(
/// child: MyApp(),
/// );
/// ```
///
/// To use the flavor-based default, pass a compile-time define:
///
/// ```bash
/// flutter run --release --dart-define=FLUTTER_APP_FLAVOR=staging
/// ```
class FrameTimingLogger extends StatefulWidget {
const FrameTimingLogger({
super.key,
required this.child,
this.enabled,
this.label = 'forms',
this.warnBudgetMs = 16,
});
/// The subtree whose frame timings will be observed.
final Widget child;
/// Explicit enable flag.
///
/// - When `true`, frame timings are always logged.
/// - When `false`, frame timings are never logged.
/// - When `null`, the decision is delegated to [isStagingRelease].
final bool? enabled;
/// A label used to distinguish log lines for different parts of the app.
///
/// This is helpful when you have multiple [FrameTimingLogger] instances,
/// for example `forms`, `feed`, `checkout`, etc.
final String label;
/// Budget threshold in milliseconds for a "slow" frame.
///
/// - Default: 16ms (60fps budget).
/// - Example: set to 8ms when targeting 120fps, or to 32ms for softer warnings.
final int warnBudgetMs;
/// Convenience flag for staging release builds.
///
/// Returns `true` when:
/// - The app is running in `kReleaseMode`, and
/// - The compile-time environment `FLUTTER_APP_FLAVOR` equals `'staging'`.
///
/// This allows you to enable frame logging in staging builds without
/// changing source code:
///
/// ```bash
/// flutter run --release --dart-define=FLUTTER_APP_FLAVOR=staging
/// ```
static bool get isStagingRelease => kReleaseMode && const String.fromEnvironment('FLUTTER_APP_FLAVOR') == 'staging';
@override
State<FrameTimingLogger> createState() => _FrameTimingLoggerState();
}
class _FrameTimingLoggerState extends State<FrameTimingLogger> {
int _frameCount = 0;
Duration _accumulated = Duration.zero;
Duration _worst = Duration.zero;
/// Computes the effective enabled flag from a nullable [enabled] value.
///
/// If [enabled] is `null`, [FrameTimingLogger.isStagingRelease] is used
/// as the default policy.
bool _computeEnabled(bool? enabled) => enabled ?? FrameTimingLogger.isStagingRelease;
/// Whether this particular widget instance is currently enabled.
bool get _enabled => _computeEnabled(widget.enabled);
/// Callback invoked by [WidgetsBinding.addTimingsCallback] for each frame.
///
/// When disabled, this callback returns early to avoid unnecessary work.
/// When enabled, it:
/// - Accumulates frame timings to compute a moving average.
/// - Tracks the worst (slowest) frame in the current 60-frame window.
/// - Logs individual frames that exceed [FrameTimingLogger.warnBudgetMs].
/// - Every 60 frames, logs a summary with average and worst timings,
/// then resets the counters.
void _onTimings(List<FrameTiming> timings) {
if (!_enabled) {
return;
}
for (final t in timings) {
final total = t.totalSpan;
_frameCount++;
_accumulated += total;
if (total > _worst) {
_worst = total;
}
if (total.inMilliseconds > widget.warnBudgetMs) {
logger.w(
'[frame][${widget.label}] '
'slow=${total.inMilliseconds}ms '
'(build=${t.buildDuration.inMilliseconds}ms, '
'raster=${t.rasterDuration.inMilliseconds}ms)',
);
}
if (_frameCount % 60 == 0) {
final avgMs = _accumulated.inMicroseconds / _frameCount / 1000.0;
logger.w(
'[frame][${widget.label}] 60f '
'avg=${avgMs.toStringAsFixed(2)}ms '
'worst=${_worst.inMilliseconds}ms',
);
_frameCount = 0;
_accumulated = Duration.zero;
_worst = Duration.zero;
}
}
}
@override
void initState() {
super.initState();
if (_enabled) {
WidgetsBinding.instance.addTimingsCallback(_onTimings);
}
}
@override
void didUpdateWidget(covariant FrameTimingLogger oldWidget) {
super.didUpdateWidget(oldWidget);
final oldEnabled = _computeEnabled(oldWidget.enabled);
final newEnabled = _enabled;
if (oldEnabled == newEnabled) {
return;
}
if (newEnabled) {
WidgetsBinding.instance.addTimingsCallback(_onTimings);
} else {
WidgetsBinding.instance.removeTimingsCallback(_onTimings);
}
}
@override
void dispose() {
if (_enabled) {
WidgetsBinding.instance.removeTimingsCallback(_onTimings);
}
super.dispose();
}
@override
Widget build(BuildContext context) => widget.child;
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment