예제
Sophonz를 붙인 Flutter 앱의 전체 구성 — pubspec, 네이티브 파일, main.dart 시작 순서, traceparent를 보내는 Dio, go_router, 화면 로드를 감싸는 스팬, 처리된 오류, 사용자와 세션 정보 — 를 보여 줍니다.
이 페이지는 작은 쇼핑 앱 하나에 모든 구성 요소를 모읍니다. 주문 목록과 주문 상세 화면이 있고, Dio로 API를 호출하며, go_router로 이동합니다. 각 절은 그대로 가져다 고칠 수 있는 파일 하나입니다. SDK 저장소의 sophonz/example과 sophonz_dio/example 앱은 같은 API를 화면 하나씩 다룹니다.
프로젝트 파일
| 파일 | 역할 | 자세히 |
|---|---|---|
pubspec.yaml | v0.1.0 태그의 SDK 패키지 | 아래 |
android/settings.gradle.kts, android/build.gradle.kts, android/app/build.gradle.kts | GitHub Packages 저장소와 Sophonz Gradle 플러그인 | 설치 |
android/app/src/main/sophonz-config.json | 수집기, 서비스 키, app_framework: flutter | 설정 |
android/app/src/main/kotlin/.../MainApplication.kt | Sophonz.start(this) | 설치 |
ios/Podfile | SophonzIO Git 소스 | 설치 |
ios/Sophonz-Info.plist | 수집기, 서비스 키 | 설정 |
ios/Runner/AppDelegate.swift | Sophonz.start(options:) | 설치 |
lib/main.dart, lib/api.dart, lib/router.dart, lib/order_page.dart, lib/account.dart | Dart 설정 | 아래 |
pubspec.yaml
name: shop
publish_to: none
version: 1.4.0+12
environment:
sdk: ">=3.4.0 <4.0.0"
flutter: ">=3.22.0"
dependencies:
flutter:
sdk: flutter
dio: ^5.4.0
go_router: ^14.0.0 # sophonz_go_router는 go_router 16 미만을 지원합니다
sophonz:
git: {url: git@github.com:sophonz-labs/sophonz-flutter-sdk.git, path: sophonz, ref: v0.1.0}
sophonz_dio:
git: {url: git@github.com:sophonz-labs/sophonz-flutter-sdk.git, path: sophonz_dio, ref: v0.1.0}
sophonz_go_router:
git: {url: git@github.com:sophonz-labs/sophonz-flutter-sdk.git, path: sophonz_go_router, ref: v0.1.0}
sophonz_platform_interface:
git: {url: git@github.com:sophonz-labs/sophonz-flutter-sdk.git, path: sophonz_platform_interface, ref: v0.1.0}
dependency_overrides:
sophonz:
git: {url: git@github.com:sophonz-labs/sophonz-flutter-sdk.git, path: sophonz, ref: v0.1.0}
sophonz_platform_interface:
git: {url: git@github.com:sophonz-labs/sophonz-flutter-sdk.git, path: sophonz_platform_interface, ref: v0.1.0}order_page.dart가 ErrorCode를 import하므로 sophonz_platform_interface를 적었습니다. 오버라이드가 필요한 이유는 설치에 있습니다.
main.dart
import 'package:flutter/material.dart';
import 'package:sophonz/sophonz.dart';
import 'account.dart';
import 'router.dart';
Future<void> main() async {
// 네이티브 SDK는 이미 실행 중입니다. MainApplication(Android)과 AppDelegate(iOS)가 시작했습니다.
await Sophonz.instance.start(action: () async {
// action 안에서는 SDK가 연결되어 있고, 이후의 오류도 수집됩니다.
Sophonz.instance.addSessionProperty(
'app.flavor',
const String.fromEnvironment('FLAVOR', defaultValue: 'production'),
);
final account = await Account.restore();
if (account != null) onSignedIn(account);
runApp(const ShopApp());
});
}
class ShopApp extends StatelessWidget {
const ShopApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp.router(title: 'Shop', routerConfig: router);
}
}순서가 중요합니다. start가 action 실행 전에 네이티브 SDK에 연결하므로, action 안에서 설정한 세션 속성과 사용자 식별자가 버려지지 않습니다.
api.dart
앱 전체에서 Dio 인스턴스 하나를 씁니다. TraceparentInterceptor는 앱의 API로 traceparent를 보내 백엔드 트레이스가 요청에 이어지게 하고, SophonzInterceptor는 모든 요청을 기록합니다.
import 'package:dio/dio.dart';
import 'package:sophonz/sophonz.dart';
import 'package:sophonz_dio/sophonz_dio.dart';
const _apiHost = 'api.example.com';
final Dio api = Dio(
BaseOptions(
baseUrl: 'https://$_apiHost',
connectTimeout: const Duration(seconds: 10),
receiveTimeout: const Duration(seconds: 20),
),
)..interceptors.addAll([
TraceparentInterceptor({_apiHost}),
SophonzInterceptor(),
]);
/// SophonzInterceptor가 기록하기 전에 [hosts]로 가는 요청에 traceparent를 붙입니다.
class TraceparentInterceptor extends Interceptor {
TraceparentInterceptor(this.hosts);
final Set<String> hosts;
@override
void onRequest(RequestOptions options, RequestInterceptorHandler handler) async {
if (hosts.contains(options.uri.host) &&
!options.headers.containsKey('traceparent')) {
final value = await Sophonz.instance.generateW3cTraceparent(null, null);
if (value != null) options.headers['traceparent'] = value;
}
handler.next(options);
}
}인터셉터가 하나 더 필요한 이유는 네트워크에 있습니다.
router.dart
셸 라우트가 없는 앱이므로 옵저버를 observers에 넣습니다. StatefulShellRoute를 쓴다면 라우터 모드를 쓰세요(내비게이션).
import 'package:go_router/go_router.dart';
import 'package:sophonz_go_router/sophonz_go_router.dart';
import 'order_list_page.dart';
import 'order_page.dart';
final GoRouter router = GoRouter(
observers: [SophonzGoRouterObserver()],
routes: [
GoRoute(
path: '/',
name: 'orders',
builder: (context, state) => const OrderListPage(),
routes: [
GoRoute(
path: 'orders/:id',
name: 'order',
builder: (context, state) => OrderPage(id: state.pathParameters['id']!),
),
],
),
],
);처음 실행한 뒤 Sophonz에서 뷰와 화면 로드 스팬 이름을 확인하세요. 주문 ID가 들어 있다면 내비게이션처럼 routeSettingsExtractor로 정규화합니다.
화면 로드를 감싸는 스팬
옵저버의 화면 로드 스팬은 첫 프레임에서 끝납니다. 이 스팬은 사용자가 실제로 기다리는 구간, 즉 API에서 주문이 도착하기까지를 잽니다.
import 'package:dio/dio.dart';
import 'package:flutter/material.dart';
import 'package:sophonz/sophonz.dart';
import 'package:sophonz_platform_interface/sophonz_platform_interface.dart' show ErrorCode;
import 'api.dart';
import 'order.dart';
class OrderPage extends StatefulWidget {
const OrderPage({super.key, required this.id});
final String id;
@override
State<OrderPage> createState() => _OrderPageState();
}
class _OrderPageState extends State<OrderPage> {
late final Future<Order> _order = _load();
Future<Order> _load() async {
final span = await Sophonz.instance.startSpan('order-detail-load');
await span?.addAttribute('order.id', widget.id);
try {
final response = await api.get<Map<String, dynamic>>('/orders/${widget.id}');
final order = Order.fromJson(response.data!);
await span?.addAttribute('order.item_count', '${order.items.length}');
await span?.stop();
return order;
} catch (error, stack) {
await span?.stop(errorCode: ErrorCode.failure);
Sophonz.instance.logHandledDartError(error, stack);
rethrow;
}
}
Future<void> _pay(Order order) async {
Sophonz.instance.addBreadcrumb('Tapped pay');
try {
await api.post<void>('/orders/${order.id}/payment');
Sophonz.instance.logInfo('Payment completed', properties: {'order.id': order.id});
} on DioException catch (error, stack) {
Sophonz.instance.logError(
'Payment failed',
properties: {
'order.id': order.id,
'http.status_code': '${error.response?.statusCode ?? 0}',
},
);
Sophonz.instance.logHandledDartError(error, stack);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Payment failed. Try again.')),
);
}
}
}
@override
Widget build(BuildContext context) {
return FutureBuilder<Order>(
future: _order,
builder: (context, snapshot) {
final order = snapshot.data;
if (snapshot.hasError) return const Scaffold(body: Center(child: Text('Could not load the order')));
if (order == null) return const Scaffold(body: Center(child: CircularProgressIndicator()));
return Scaffold(
appBar: AppBar(title: Text('Order ${order.id}')),
body: OrderSummary(order: order),
bottomNavigationBar: FilledButton(
onPressed: () => _pay(order),
child: const Text('Pay'),
),
);
},
);
}
}스팬 이름은 고정하고 주문 ID는 속성에 넣었으므로, 모든 주문 상세 로딩이 스팬 이름 하나를 공유합니다.
이 화면을 한 번 방문하면 도착하는 것:
| 기록 | 출처 |
|---|---|
| 라우트의 뷰와 화면 로드 스팬 | SophonzGoRouterObserver |
order.id, order.item_count가 붙은 order-detail-load 스팬 | 위의 startSpan |
보낸 traceparent가 담긴 GET /orders/<id> 네트워크 스팬 | SophonzInterceptor |
| 그 헤더와 같은 트레이스의 백엔드 서버 스팬 | 백엔드 SDK |
| 실패 시: 실패로 표시된 스팬과 처리된 오류 로그 | stop(errorCode:), logHandledDartError |
account.dart
사용자 식별과 로그아웃입니다. endSession은 새 세션을 시작하기 전에 식별자와 페르소나를 지우므로, 다음 사용자의 세션에 이전 정보가 남지 않습니다.
import 'package:sophonz/sophonz.dart';
void onSignedIn(Account account) {
// 이메일 주소가 아니라 불투명한 ID를 씁니다.
Sophonz.instance.setUserIdentifier(account.id);
if (account.hasSubscription) {
Sophonz.instance.setUserAsPayer();
}
}
void onSignedOut() {
Sophonz.instance.endSession(); // clearUserInfo 기본값은 true
}iOS에서 페르소나는 프로세스 수명 동안만 유지되므로, main.dart는 실행할 때마다 계정을 복원한 뒤 onSignedIn을 다시 호출합니다.
코드 없이 기록되는 오류
start(action: ...)만 있으면 다음은 앱에 다른 코드 없이 기록됩니다.
// 처리되지 않은 비동기 오류: PlatformDispatcher.onError로 기록됩니다.
onPressed: () async {
await api.get<void>('/does-not-exist'); // 잡지 않은 DioException
},
// 프레임워크 오류: FlutterError.onError로 기록됩니다.
Row(children: [Text('a very long label ' * 20)]), // RenderFlex 오버플로위의 DioException은 두 가지 형태로 기록됩니다. SophonzInterceptor의 네트워크 스팬과 처리되지 않은 오류 로그입니다.
확인
- 기기에서 디버그 모드로 실행하고
Sophonz Flutter SDK attached to host SDK successfully.를 찾습니다. - 주문을 열고 Pay를 누른 뒤 앱을 백그라운드로 보냅니다.
- Sophonz에서 세션을 찾습니다. 라우트 뷰,
order-detail-load스팬,GET과POST네트워크 스팬, 브레드크럼, 결제 로그가 있어야 합니다. GET네트워크 스팬을 열고 트레이스를 따라 백엔드 스팬까지 이동합니다.