계측
코드를 더 쓰지 않아도 Flutter 앱이 보고하는 것 — Flutter 계층의 Dart 오류, 프레임, 아이솔레이트 멈춤, 시작과 수명 주기, 네이티브 SDK의 크래시, ANR, 세션 — 과 Android·iOS의 차이를 정리합니다.
Flutter 앱에서는 두 계층이 데이터를 수집합니다. Dart 계층은 오류, 프레임, UI 아이솔레이트, 라우트, 감싼 HTTP 클라이언트처럼 Dart에서 일어나는 일을 봅니다. 네이티브 SDK는 세션, 네이티브 크래시, ANR, 앱 시작, 네이티브 네트워크 호출처럼 프로세스 수준의 일을 봅니다. Dart 계층은 직접 업로드하지 않고 모든 기록을 메서드 채널로 네이티브 SDK에 넘기며, 저장과 전송은 네이티브 SDK가 맡습니다.
Dart 계층이 수집하는 것
아래 항목은 모두 Sophonz.instance.start()가 실행되면 시작됩니다.
| 신호 | 기록 형태 | 필요한 것 |
|---|---|---|
| 처리되지 않은 Flutter 프레임워크 오류 | error 로그, spz.exception_handling: unhandled | start(action: ...) 또는 installErrorHandlers |
| 처리되지 않은 비동기·플랫폼 오류 | error 로그, spz.exception_handling: unhandled | start(action: ...) 또는 installErrorHandlers |
| 첫 프레임까지의 시간 | spz-time-to-first-frame-flutter 스팬 | start(action: ...) |
| 상호작용 가능까지의 시간 | spz-time-to-interactive-flutter 스팬, 프로세스당 한 번 | 내비게이션 옵저버 |
| 느린 프레임 | info 로그 slow-frames, 묶어서 기록 | 없음 |
| 멈춘 프레임 | warning 로그 frozen-frame | 없음 |
| UI 아이솔레이트 멈춤 | spz-dart-isolate-hang 스팬 | 없음 |
| 백그라운드 시간 | spz-app-background 스팬 | 없음 |
| 라우트 | 뷰 스팬, 화면 로드 스팬 | SophonzNavigationObserver 또는 SophonzGoRouterObserver |
| HTTP 요청 | 네트워크 스팬 | SophonzHttpClient 또는 SophonzInterceptor |
라우트는 내비게이션, HTTP는 네트워크에서 설명합니다.
오류
start(action: ...)을 쓰면 핸들러 두 개가 설치됩니다.
FlutterError.onError— 프레임워크가 보고하는 오류입니다.RenderFlex오버플로 같은 빌드·레이아웃·페인트 오류가 여기에 해당합니다. Sophonz가 기록한 뒤 기존 핸들러도 실행되므로 디버그 콘솔 출력은 그대로입니다.PlatformDispatcher.instance.onError— 아무도 await하지 않은async콜백에서 던진 예외처럼 어디에서도 잡히지 않은 오류입니다. 핸들러가false를 돌려주므로 기본 처리도 그대로 이어집니다.
오류마다 다음 속성을 담은 error 수준 로그가 기록됩니다.
| 속성 | 값 |
|---|---|
exception.type | StateError 같은 런타임 타입 |
exception.message | 오류의 toString() 또는 프레임워크가 준 요약 |
exception.stacktrace | Dart 스택 트레이스 |
exception.context, exception.library | 프레임워크 오류의 FlutterErrorDetails context와 library |
spz.exception_handling | unhandled, logHandledDartError로 기록하면 handled |
Android에서는 로그 본문이 Dart error이고 메시지는 exception.message에 들어갑니다. iOS에서는 본문이 메시지 자체입니다.
직접 잡은 오류는 기록하지 않으면 남지 않습니다.
try {
await repository.sync();
} catch (error, stack) {
Sophonz.instance.logHandledDartError(error, stack);
}처리된 오류로 기록한 오류는 오류 없는 세션 비율 계산에 포함되지 않습니다.
CAUTION — Dart 오류는 크래시가 아닙니다
Flutter는 처리되지 않은 Dart 예외 뒤에도 계속 실행되므로 이 오류들은 크래시가 아니라 error 로그이며, getLastRunEndState()도 cleanExit로 남습니다. 크래시는 네이티브 SDK가 수집하는 처리되지 않은 JVM·Objective-C 예외나 네이티브 시그널입니다.
아이솔레이트
핸들러는 루트 아이솔레이트에만 설치됩니다. 직접 띄운 아이솔레이트의 예외는 수집되지 않습니다. 단, compute는 오류를 호출한 아이솔레이트에서 다시 던지므로 거기서 잡지 않으면 수집됩니다.
Isolate.spawn은 onError로 오류를 루트 아이솔레이트에 넘기고 거기서 기록합니다.
final errors = ReceivePort();
errors.listen((message) {
final pair = message as List<dynamic>;
Sophonz.instance.logDartError(
pair[0] as Object,
StackTrace.fromString(pair[1] as String),
);
});
await Isolate.spawn(parseLargeFile, path, onError: errors.sendPort);start()는 루트 아이솔레이트에서만 실행되므로, 띄운 아이솔레이트 안에서 한 Sophonz.instance 호출은 버려집니다.
시작
| 스팬 | 시작 | 끝 |
|---|---|---|
spz-time-to-first-frame-flutter | Dart에서 start()가 시작될 때 | 첫 프레임이 래스터화될 때 |
spz-time-to-interactive-flutter | 같은 시작 시각 | 첫 라우트가 push된 다음 프레임. route 속성 포함 |
둘 다 Dart 기준으로 잽니다. 프로세스 시작이나 엔진 초기화처럼 Dart 이전의 시간은 네이티브 시작 트레이스에 있으며, 이 트레이스는 Android의 Application.onCreate()나 iOS의 AppDelegate 초기화 메서드에서 네이티브 SDK를 시작해야 기록됩니다.
프레임
프레임 타이밍은 SchedulerBinding.addTimingsCallback에서 받습니다. 빌드와 래스터 시간 중 긴 쪽으로 판단합니다.
| 경우 | 기준 | 기록 |
|---|---|---|
| 느린 프레임 | 16ms 초과 | 개수를 셉니다. 느린 프레임 60개마다 count, worst_build_ms, worst_raster_ms를 담은 info 로그 slow-frames 하나 |
| 멈춘 프레임 | 700ms 이상 | 프레임마다 build_ms, raster_ms를 담은 warning 로그 frozen-frame 하나 |
SophonzNavigationObserver를 쓰면 둘 다 현재 라우트를 route 속성으로 담습니다. 60개를 채우지 못한 느린 프레임 묶음은 앱이 끝날 때 기록되지 않습니다.
UI 아이솔레이트 멈춤
Flutter의 UI는 플랫폼 메인 스레드가 아니라 Dart 아이솔레이트에서 돌기 때문에, Android ANR 감지와 iOS 멈춤 감지는 Dart 이벤트 루프가 멈춘 것을 보지 못합니다. SDK는 UI 아이솔레이트에서 100ms마다 타이머를 돌리고, 이전 틱보다 700ms 이상 늦게 도착한 틱이 있으면 그 간격을 duration_ms 속성이 붙은 완료된 spz-dart-isolate-hang 스팬으로 기록합니다.
앱이 백그라운드에 있는 동안은 감시를 멈추고 다시 포그라운드로 오면 새로 시작하므로, 일시 정지된 프로세스가 멈춤으로 보고되지 않습니다.
수명 주기
앱이 백그라운드로 가면(AppLifecycleState.paused) spz-app-background 스팬이 시작되고, resumed나 detached에서 끝납니다.
네이티브 SDK가 수집하는 것
아래 항목은 Dart와 무관하며 네이티브 SDK에서 설정합니다.
| 신호 | Android | iOS |
|---|---|---|
| 세션 | 예 | 예 |
| 네이티브 크래시(JVM, NDK / Mach, 시그널) | 예 | 예 |
| ANR / 메인 스레드 멈춤 | ANR 기본 켜짐 | 멈춤 수집 기본 꺼짐 |
| 앱 시작 트레이스 | Application.onCreate()에서 시작했을 때 | AppDelegate 초기화 메서드에서 시작했을 때 |
| 네이티브 코드의 네트워크 호출(OkHttp, HttpURLConnection / URLSession) | 예 | 예 |
| 기기·앱 리소스 | 예 | 예 |
| 푸시 알림 | FCM 계측을 켰을 때 | PushNotificationCaptureService를 추가했을 때 |
| 네이티브 뷰의 탭과 화면 | 예 | 예 |
세부 항목과 스위치는 Android 계측과 iOS 계측에 있습니다.
NOTE — Dart HTTP는 네이티브 네트워크 수집에 보이지 않습니다
dart:io는 자체 소켓을 쓰므로 http나 Dio의 요청은 OkHttp나 URLSession을 거치지 않고, Dart 래퍼로만 기록됩니다. cupertino_http처럼 URLSession에 위임하는 클라이언트는 Apple SDK에도 보여 같은 요청이 두 번 기록될 수 있습니다.
네이티브의 탭과 화면은 호스트 액티비티나 뷰 컨트롤러를 기준으로 하며, Flutter 앱에서는 FlutterActivity나 FlutterViewController 하나입니다. Flutter 안의 화면 이름은 내비게이션 옵저버가 기록합니다.
직접 호출해야 하는 것
| 원하는 것 | 호출 |
|---|---|
| 잡은 오류 | logHandledDartError(error, stack) |
| 띄운 아이솔레이트의 오류 | 루트로 넘겨 logDartError |
| 직접 작성한 작업의 시간 | startSpan / stop 또는 recordCompletedSpan |
| 타임라인의 메시지나 이벤트 | logInfo, logWarning, logError, addBreadcrumb |
| 사용자 정보 | setUserIdentifier, addUserPersona |
| 세션 맥락 | addSessionProperty |
| 라우트가 아닌 화면(탭, 바텀 시트) | startView / endView |
| 래퍼 밖에서 보낸 요청 | recordNetworkRequest |
| Dart에서 처리한 푸시 알림 | logPushNotification |
모두 API 레퍼런스에 있습니다.
플랫폼별 차이
| 동작 | Android | iOS |
|---|---|---|
| 플러그인의 네이티브 시작 | 앱이 시작하지 않았으면 플러그인이 시작(시작 시간 측정 없음) | 하지 않습니다. AppDelegate에서 시작하지 않으면 모든 호출이 무시됩니다 |
| 업로드 시점 | Android SDK의 전송 방식을 따름 | 세션 구간이 끝날 때(백그라운드 전환, 다음 실행) |
setUserName, setUserEmail | 기록 | 받기만 하고 무시 |
| 페르소나 | Android SDK가 유지 | 프로세스 수명. 다시 실행하면 다시 설정해야 합니다 |
endSession | 즉시 | 5초에 한 번까지 |
addSpanExporter, addLogRecordExporter | start() 때 전달 | 무시. 시작 옵션의 otel: 사용 |
Flutter 라우트와 app.screen.name | — | 바뀌지 않음. 다른 스팬에는 Flutter 뷰 컨트롤러 이름이 남습니다 |
| 네트워크 스팬 URL | 받은 그대로 | 쿼리 문자열과 프래그먼트 제거 |
| GET, POST, PUT, DELETE, PATCH 외 HTTP 메서드 | 기록되지 않음 | OTHER로 기록 |
| 푸시 알림 필드 | from, messageId, priority, hasNotification, hasData | title, body(둘 다 필수), subtitle, badge, category |
| Flutter SDK·Dart 버전 | 연결할 때 Android SDK에 전달 | 프로세스 속성 spz_flutter_sdk_version, spz_dart_runtime_version |
CAUTION — iOS에서 포그라운드 디버그 실행 중에는 아직 아무것도 보이지 않습니다
Apple SDK는 스팬과 로그를 디스크에 쓰고 세션 구간이 끝날 때 업로드합니다. 데이터를 찾기 전에 앱을 백그라운드로 보내거나 다시 실행하세요.
WebView
네이티브 SDK는 브리지로 WebView 안의 페이지와 세션을 공유할 수 있습니다(WebView 연동). 0.1.0에서는 이 브리지가 webview_flutter에 연결되어 있지 않습니다. 플러그인이 플랫폼 WebView 인스턴스를 받지 못하기 때문이며, Flutter WebView 안의 페이지는 Browser SDK에서 별도 세션으로 시작합니다.
수집 확인
package:sophonz/sophonz_samples.dart에는 빌드를 시험하는 트리거가 있습니다. 디버그나 내부 빌드에서만 사용하세요.
import 'package:sophonz/sophonz_samples.dart';
SophonzSamples.triggerUncaughtException(); // Dart 오류 로그
SophonzSamples.triggerUncaughtExceptionAsync();
SophonzSamples.triggerNativeSdkError(); // 두 플랫폼 모두 네이티브 크래시
SophonzSamples.triggerAnr(); // Android 전용: 메인 스레드를 10초 막음
SophonzSamples.triggerRaisedSignal(); // iOS 전용: SIGABRT네이티브 크래시 뒤에는 앱을 다시 실행하세요. 크래시는 다음 실행에서 업로드됩니다.