예제
SDK, tracer provider, React Navigation을 함께 쓰는 React Native 시작 순서 전체와, 추적되는 API 호출, 커스텀 스팬, 처리한 에러, 에러 경계, 로그아웃 흐름을 보여 줍니다.
예제는 작은 앱 하나를 만들어 갑니다. 공통 sophonz.ts 모듈, 모든 것을 순서대로 시작하는 App.tsx, 그리고 이를 사용하는 패턴입니다. React Navigation을 쓰는 일반 React Native 앱을 기준으로 하며, 같은 시작 순서를 expo-router 레이아웃으로 옮긴 코드는 내비게이션에 있습니다.
패키지
npm install @sophonz/react-native@0.1.0 \
@sophonz/react-native-tracer-provider@0.1.0 \
@sophonz/react-native-navigation@0.1.0 \
@opentelemetry/api \
@react-navigation/native @react-navigation/native-stack
cd ios && pod install설치의 네이티브 설정은 끝났다고 가정합니다. Android는 sophonz-config.json, iOS는 아래 옵션이나 네이티브 시작입니다.
공통 모듈
화면에서 provider를 import하지 않도록 설정과 tracer를 한곳에 둡니다.
import {trace} from "@opentelemetry/api";
import type {SDKConfig} from "@sophonz/react-native";
// 참조가 고정된 객체: 이 객체가 바뀌면 훅이 다시 실행됩니다
export const SOPHONZ_CONFIG: SDKConfig = {
ios: {
collectorUrl: "https://in.sophonz.ai",
appKey: "sk_ios_replace_me",
appName: "shop-ios",
project: "shop",
deploymentEnvironment: __DEV__ ? "development" : "production",
disabledUrlPatterns: ["analytics.example.com"],
},
trackUnhandledRejections: true,
};
export const TRACER_CONFIG = {setGlobalContextManager: true};
// App이 등록하면 Sophonz provider로, 그 전에는 no-op tracer로 연결됩니다
export const tracer = () => trace.getTracer("shop", "1.4.0");시작 순서
App.tsx는 SDK를 시작하고, SDK가 실행되면 tracer provider를 만들어 전역으로 등록한 뒤에야 내비게이션 트리를 렌더링합니다. 그래야 첫 화면의 스팬을 놓치지 않습니다.
import {useEffect, useRef} from "react";
import {ActivityIndicator, View} from "react-native";
import {trace} from "@opentelemetry/api";
import {NavigationContainer, useNavigationContainerRef} from "@react-navigation/native";
import {createNativeStackNavigator} from "@react-navigation/native-stack";
import {configureSDKErrorLogging, useSophonz} from "@sophonz/react-native";
import {useSophonzNativeTracerProvider} from "@sophonz/react-native-tracer-provider";
import {SophonzNavigationTracker} from "@sophonz/react-native-navigation";
import {SOPHONZ_CONFIG, TRACER_CONFIG} from "./src/sophonz";
import {ErrorBoundary} from "./src/ErrorBoundary";
import {OrdersScreen} from "./src/screens/OrdersScreen";
import {OrderDetailScreen} from "./src/screens/OrderDetailScreen";
configureSDKErrorLogging({enabled: __DEV__, allowLogToConsole: true});
const Stack = createNativeStackNavigator();
export default function App() {
// 1. SDK를 시작하거나(네이티브에서 이미 시작했다면 그대로 사용) JS 에러 핸들러를 설치
const {isPending, isStarted} = useSophonz(SOPHONZ_CONFIG);
// 2. SDK가 실행되면 tracer provider 생성
const {tracerProvider} = useSophonzNativeTracerProvider(TRACER_CONFIG, isStarted);
// 3. trace.getTracer()가 닿도록 전역 provider로 등록
useEffect(() => {
if (tracerProvider) {
trace.setGlobalTracerProvider(tracerProvider);
}
}, [tracerProvider]);
const navigation = useNavigationContainerRef();
const trackerRef = useRef(navigation);
if (isPending || (isStarted && !tracerProvider)) {
return (
<View style={{flex: 1, justifyContent: "center"}}>
<ActivityIndicator />
</View>
);
}
// SDK 시작에 실패해도 앱은 텔레메트리 없이 동작합니다
return (
<ErrorBoundary>
<NavigationContainer ref={navigation}>
<SophonzNavigationTracker
ref={trackerRef}
tracerProvider={tracerProvider ?? undefined}
debug={__DEV__}>
<Stack.Navigator>
<Stack.Screen name="Orders" component={OrdersScreen} />
<Stack.Screen name="OrderDetail" component={OrderDetailScreen} />
</Stack.Navigator>
</SophonzNavigationTracker>
</NavigationContainer>
</ErrorBoundary>
);
}단계별로 기록되는 것:
| 단계 | 텔레메트리 |
|---|---|
useSophonz | 처리되지 않은 JS 예외, 처리되지 않은 Promise 거부, React Native와 SDK 버전. SDK를 네이티브에서 시작했다면 네이티브 수집은 이미 동작 중입니다 |
useSophonzNativeTracerProvider | 스스로 기록하는 것은 없고, 이를 통해 만든 스팬 |
SophonzNavigationTracker | Orders, 이어서 OrderDetail 같은 이름의 ux.view 스팬 |
추적되는 API 호출
작업 전체를 감싸는 커스텀 스팬을 만들고 그 ID를 traceparent로 보내, 백엔드 스팬이 그 아래에 들어가게 합니다. 요청 자체는 여전히 네이티브 SDK가 네트워크 스팬으로 기록합니다.
import {isSpanContextValid, SpanStatusCode} from "@opentelemetry/api";
import type {SophonzNativeSpan} from "@sophonz/react-native-tracer-provider";
import {tracer} from "../sophonz";
export type Order = {id: string; total: number};
export async function fetchOrders(): Promise<Order[]> {
const span = tracer().startSpan("fetch-orders", {attributes: {"spz.type": "perf"}});
try {
// Sophonz 스팬은 네이티브 쪽에서 ID를 받으므로 기다립니다
const ctx =
"spanContextAsync" in span
? await (span as SophonzNativeSpan).spanContextAsync()
: span.spanContext();
const response = await fetch("https://api.example.com/v1/orders", {
headers: isSpanContextValid(ctx) ? {traceparent: `00-${ctx.traceId}-${ctx.spanId}-01`} : {},
});
span.setAttribute("http.response.status_code", response.status);
if (!response.ok) {
throw new Error(`orders request failed with ${response.status}`);
}
const orders: Order[] = await response.json();
span.setAttribute("orders.count", orders.length);
return orders;
} catch (e) {
span.recordException(e as Error);
span.setStatus({code: SpanStatusCode.ERROR});
throw e;
} finally {
span.end();
}
}provider가 등록되기 전에는 trace.getTracer가 no-op tracer를 돌려주며, 그 스팬에는 spanContextAsync가 없고 컨텍스트는 모두 0입니다. 이때 위의 검사는 잘못된 헤더 대신 헤더를 아예 보내지 않습니다. 위의 시작 순서에서는 등록이 끝난 뒤에야 화면이 렌더링됩니다.
헤더를 직접 넣으므로 네이티브 주입 설정과 관계없이 두 플랫폼 모두에서 전송되고, 어느 SDK도 이 헤더를 바꾸지 않습니다. 네트워크를 참고하세요.
자식이 있는 커스텀 스팬
여러 단계로 된 작업을 측정하면서 await를 넘을 때 부모를 명시합니다.
import {asParent, endAsFailed} from "@sophonz/react-native-tracer-provider";
import {tracer} from "./sophonz";
export async function checkout(cartId: string) {
const t = tracer();
const root = t.startSpan("checkout", {attributes: {"cart.id": cartId}});
try {
const validate = t.startSpan("validate-cart", {}, asParent(root));
await validateCart(cartId);
validate.end();
const pay = t.startSpan("charge-card", {}, asParent(root));
try {
await chargeCard(cartId);
pay.end();
} catch (e) {
endAsFailed(pay);
throw e;
}
root.end();
} catch (e) {
endAsFailed(root);
throw e;
}
}처리한 에러와 브레드크럼
import {useCallback, useEffect, useState} from "react";
import {Button, FlatList, Text, View} from "react-native";
import {addBreadcrumb, logHandledError} from "@sophonz/react-native";
import {fetchOrders, type Order} from "../api/orders";
export function OrdersScreen() {
const [orders, setOrders] = useState<Order[]>([]);
const [failed, setFailed] = useState(false);
const load = useCallback(async () => {
addBreadcrumb("orders: load requested");
try {
setOrders(await fetchOrders());
setFailed(false);
} catch (e) {
// 사용자에게는 재시도 버튼이 보이지만, 일어난 사실은 알고 싶습니다
logHandledError(e as Error, {screen: "Orders"});
setFailed(true);
}
}, []);
useEffect(() => {
load();
}, [load]);
return (
<View>
{failed && <Button title="Retry" onPress={load} />}
<FlatList data={orders} keyExtractor={o => o.id} renderItem={({item}) => <Text>{item.id}</Text>} />
</View>
);
}속성 값은 문자열이어야 합니다. iOS에서는 숫자가 들어가면 호출이 false로 resolve됩니다.
에러 경계
에러 경계가 잡은 에러는 전역 핸들러에 도달하지 않으므로 여기서 기록합니다.
import React from "react";
import {Text} from "react-native";
import {logHandledError, logIfComponentError} from "@sophonz/react-native";
type Props = {children: React.ReactNode};
export class ErrorBoundary extends React.Component<Props, {failed: boolean}> {
state = {failed: false};
static getDerivedStateFromError() {
return {failed: true};
}
componentDidCatch(error: Error, info: React.ErrorInfo) {
logHandledError(error, {boundary: "root"});
logIfComponentError(Object.assign(error, {componentStack: info.componentStack ?? ""}));
}
render() {
return this.state.failed ? <Text>Something went wrong.</Text> : this.props.children;
}
}로그인과 로그아웃
import {
addSessionProperty,
clearAllUserPersonas,
clearUserIdentifier,
endSession,
setUserIdentifier,
addUserPersona,
} from "@sophonz/react-native";
export async function onLogin(user: {id: string; plan: "free" | "pro"}) {
await setUserIdentifier(user.id); // 이메일이 아닌 내부 ID
await addUserPersona(user.plan);
await addSessionProperty("login_method", "password", false);
}
export async function onLogout() {
await clearUserIdentifier();
await clearAllUserPersonas();
await endSession(); // 다음 사용자의 활동은 새 세션에서 시작됩니다
}TIP — 이제 시작입니다
이제 앱을 배포하세요. Sophonz가 실사용자가 겪는 문제를 포착하고, 당신의 앱은 스스로 진화합니다.