예제

Sophonz를 붙인 Flutter 앱의 전체 구성 — pubspec, 네이티브 파일, main.dart 시작 순서, traceparent를 보내는 Dio, go_router, 화면 로드를 감싸는 스팬, 처리된 오류, 사용자와 세션 정보 — 를 보여 줍니다.

이 페이지는 작은 쇼핑 앱 하나에 모든 구성 요소를 모읍니다. 주문 목록과 주문 상세 화면이 있고, Dio로 API를 호출하며, go_router로 이동합니다. 각 절은 그대로 가져다 고칠 수 있는 파일 하나입니다. SDK 저장소의 sophonz/example과 sophonz_dio/example 앱은 같은 API를 화면 하나씩 다룹니다.

프로젝트 파일

파일역할자세히
pubspec.yamlv0.1.0 태그의 SDK 패키지아래
android/settings.gradle.kts, android/build.gradle.kts, android/app/build.gradle.ktsGitHub Packages 저장소와 Sophonz Gradle 플러그인설치
android/app/src/main/sophonz-config.json수집기, 서비스 키, app_framework: flutter설정
android/app/src/main/kotlin/.../MainApplication.ktSophonz.start(this)설치
ios/PodfileSophonzIO Git 소스설치
ios/Sophonz-Info.plist수집기, 서비스 키설정
ios/Runner/AppDelegate.swiftSophonz.start(options:)설치
lib/main.dart, lib/api.dart, lib/router.dart, lib/order_page.dart, lib/account.dartDart 설정아래

pubspec.yaml

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

lib/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는 모든 요청을 기록합니다.

lib/api.dart
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를 쓴다면 라우터 모드를 쓰세요(내비게이션).

lib/router.dart
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에서 주문이 도착하기까지를 잽니다.

lib/order_page.dart
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은 새 세션을 시작하기 전에 식별자와 페르소나를 지우므로, 다음 사용자의 세션에 이전 정보가 남지 않습니다.

lib/account.dart
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의 네트워크 스팬과 처리되지 않은 오류 로그입니다.

확인

  1. 기기에서 디버그 모드로 실행하고 Sophonz Flutter SDK attached to host SDK successfully.를 찾습니다.
  2. 주문을 열고 Pay를 누른 뒤 앱을 백그라운드로 보냅니다.
  3. Sophonz에서 세션을 찾습니다. 라우트 뷰, order-detail-load 스팬, GET과 POST 네트워크 스팬, 브레드크럼, 결제 로그가 있어야 합니다.
  4. GET 네트워크 스팬을 열고 트레이스를 따라 백엔드 스팬까지 이동합니다.

다음 단계