OpenTelemetry와 OTLP
@sophonz/react-native-tracer-provider로 OpenTelemetry JS API의 커스텀 스팬을 만드는 방법(설정, 컨텍스트, 스팬 컨텍스트, 플랫폼별 제한)과 @sophonz/react-native-otlp로 같은 텔레메트리를 다른 백엔드로도 보내는 방법을 설명합니다.
@sophonz/react-native-tracer-provider는 네이티브 SDK 위에 OpenTelemetry TracerProvider 인터페이스를 구현합니다. 스팬은 표준 @opentelemetry/api로 작성하고, 네이티브 SDK가 크래시, 네트워크 요청, 로그 옆에 현재 세션으로 기록합니다. @sophonz/react-native-otlp는 반대 방향입니다. 네이티브 SDK에 OTLP/HTTP 익스포터를 더해, SDK가 기록하는 모든 것을 두 번째 백엔드로도 보냅니다.
설치
npm install @sophonz/react-native-tracer-provider@0.1.0 @opentelemetry/api
cd ios && pod install패키지에 @opentelemetry/api ^1.9.0이 포함되어 있지만, 코드와 provider가 같은 사본을 쓰도록 앱 의존성에도 추가하세요. 네이티브 코드가 있는 패키지이므로 앱을 다시 빌드해야 합니다.
Provider 설정
provider는 실행 중인 SDK가 필요합니다. 훅이 이를 기다려 줍니다.
import {useEffect} from "react";
import {trace} from "@opentelemetry/api";
import {useSophonz} from "@sophonz/react-native";
import {useSophonzNativeTracerProvider} from "@sophonz/react-native-tracer-provider";
const SOPHONZ_CONFIG = {trackUnhandledRejections: true};
export default function App() {
const {isStarted} = useSophonz(SOPHONZ_CONFIG);
const {tracerProvider, tracer, isError, error} = useSophonzNativeTracerProvider(undefined, isStarted);
useEffect(() => {
if (tracerProvider) {
trace.setGlobalTracerProvider(tracerProvider);
}
}, [tracerProvider]);
// ...
}useSophonzNativeTracerProvider(config?, enabled = true)는 enabled가 false인 동안 아무것도 하지 않습니다. 켜지면 네이티브 SDK가 실행 중인지 확인하고 provider를 만듭니다. 반환값은 다음과 같습니다.
| 필드 | 설명 |
|---|---|
tracerProvider | provider. 준비되기 전에는 null |
tracer | sophonz-default-tracer라는 이름의 tracer, 또는 null |
isLoading | 확인이 끝날 때까지 true |
isError, error | @sophonz/react-native가 없거나 SDK가 시작되지 않았을 때 설정됩니다. 에러는 console.warn으로도 출력됩니다 |
React 없이 쓴다면 initialize가 resolve된 뒤 직접 만듭니다.
import {trace} from "@opentelemetry/api";
import {initialize} from "@sophonz/react-native";
import {SophonzNativeTracerProvider} from "@sophonz/react-native-tracer-provider";
await initialize();
trace.setGlobalTracerProvider(new SophonzNativeTracerProvider());생성자는 SDK 실행 여부를 확인하지 않습니다.
전역 등록
trace.setGlobalTracerProvider로 등록하면 어느 모듈에서든 provider를 넘겨받지 않고 trace.getTracer(name)을 호출할 수 있고, trace.getTracer를 쓰는 OpenTelemetry 기반 라이브러리도 Sophonz로 기록합니다. 한 번만 등록하세요. OpenTelemetry API는 두 번째 등록을 무시합니다.
커스텀 스팬
import {SpanStatusCode, trace} from "@opentelemetry/api";
const tracer = trace.getTracer("catalog", "2.3.0");
export async function loadCatalog(categoryId: string) {
const span = tracer.startSpan("load-catalog", {
attributes: {"catalog.category": categoryId},
});
try {
const items = await api.catalog(categoryId);
span.setAttribute("catalog.items", items.length);
span.addEvent("catalog-rendered");
return items;
} catch (e) {
span.recordException(e as Error);
span.setStatus({code: SpanStatusCode.ERROR, message: "catalog failed"});
throw e;
} finally {
span.end();
}
}| 스팬 메서드 | 기록 여부 |
|---|---|
setAttribute, setAttributes | 예. 문자열, 숫자, boolean. iOS에서 배열은 문자열이 됩니다 |
addEvent(name, attributes?, time?) | 예 |
recordException(exception, time?) | exception.type, exception.message, exception.stacktrace를 담은 exception 이벤트 |
setStatus | 예. iOS에서 ERROR 메시지는 otel.status_description 속성으로 남습니다 |
end(time?) | 예 |
updateName | Android만 |
addLink, addLinks | 네이티브 SDK로 전달되며, 링크가 완전히 지원되지 않는다는 콘솔 경고가 나옵니다 |
startSpan 옵션 startTime, attributes, root | 예 |
startSpan 옵션 kind | Android만. iOS는 모든 스팬을 internal로 기록합니다 |
시간은 epoch 밀리초, Date, HrTime 중 무엇이든 됩니다.
헬퍼
| 헬퍼 | 설명 |
|---|---|
recordCompletedSpan(tracer, name, {parent?, attributes?, events?, status?, startTime?, endTime?, kind?, links?}) | 스팬을 한 번에 시작하고 끝냅니다. 직접 측정한 작업에 씁니다 |
asParent(span) | span을 활성 스팬으로 둔 컨텍스트. tracer.startSpan(name, options, asParent(span))에 씁니다 |
endAsFailed(span) | ERROR 상태로 설정하고 끝냅니다 |
startView(tracer, name) | view.name과 spz.type: ux.view가 붙은 spz-screen-view 스팬. 내비게이션 패키지가 대신 해 줍니다 |
import {recordCompletedSpan} from "@sophonz/react-native-tracer-provider";
recordCompletedSpan(tracer, "image-decode", {
startTime: decodeStartedAt,
endTime: Date.now(),
attributes: {"image.bytes": bytes},
});spz.type
spz.type 속성은 포털에서 스팬을 분류합니다. 체계는 Android 계측 항목에 설명되어 있습니다. 이 속성이 없는 스팬은 perf가 됩니다. iOS에서는 이 속성이 일반 속성이 아니라 스팬 타입이 됩니다.
컨텍스트
provider는 활성 컨텍스트를 JavaScript의 스택에 두며, 이 스택은 동기 호출만 따라갑니다. 여기서 두 가지가 따라옵니다.
await를 넘을 때는 부모를 명시하세요. startActiveSpan은 콜백의 동기 구간에서만 스팬을 활성화합니다. 첫 await 뒤의 tracer.startSpan은 그 스팬을 부모로 보지 못합니다. 부모를 직접 넘기세요.
import {asParent} from "@sophonz/react-native-tracer-provider";
const checkout = tracer.startSpan("checkout");
await validateCart();
const payment = tracer.startSpan("payment", {}, asParent(checkout));
await pay();
payment.end();
checkout.end();전역 컨텍스트 매니저. config를 생략하면 provider는 자신의 컨텍스트 매니저를 @opentelemetry/api에도 등록하므로, tracer 밖에서 context.active()와 trace.getActiveSpan()이 동작합니다. {}를 포함해 어떤 config 객체든 넘기면 setGlobalContextManager: true를 넣지 않는 한 이 등록이 꺼집니다.
useSophonzNativeTracerProvider({setGlobalContextManager: true, spanContextSyncBehaviour: "throw"}, isStarted);스팬 컨텍스트
스팬 ID는 네이티브 SDK가 정하고, 브리지는 비동기입니다. startSpan 직후 네이티브 쪽이 응답하기 전에는 span.spanContext()가 돌려줄 값이 없습니다.
spanContextSyncBehaviour | 응답 전 span.spanContext() |
|---|---|
"return_empty"(기본값) | 비어 있는 트레이스 ID와 스팬 ID |
"throw" | 예외 |
ID가 필요하면 기다리세요. 예를 들어 traceparent 헤더를 만들 때입니다.
import type {SophonzNativeSpan} from "@sophonz/react-native-tracer-provider";
const {traceId, spanId} = await (span as SophonzNativeSpan).spanContextAsync();제한 사항
- 이전 세션에서 끝난 스팬은 부모가 될 수 없습니다. 앱이 포그라운드와 백그라운드 사이를 오갈 때 완료된 스팬이 해제되므로, 그 뒤에 시작한 자식 스팬에는 부모 ID가 없습니다.
- iOS에서는 tracer의 이름, 버전, 스키마 URL이 기록되지 않고,
kind와updateName은 무시되며, 배열 속성 값은 문자열이 됩니다. OpenTelemetryTracerProvider가 없는 Apple SDK 공개 API의 한계입니다. - 스팬은 네이티브 SDK가 기록하므로 그 배치와 함께 전송되고 세션이 업로드될 때 나타납니다. iOS에서는 앱이 백그라운드로 갈 때입니다.
추가 OTLP 익스포터
@sophonz/react-native-otlp는 Sophonz 컬렉터와 함께 OTLP/HTTP 트레이스 익스포터와 로그 익스포터를 붙여 네이티브 SDK를 시작합니다. 네이티브 수집, JavaScript 로그, tracer provider의 스팬까지 SDK가 기록하는 모든 것이 양쪽으로 전송됩니다. Sophonz 컬렉터로의 전송에는 영향이 없습니다.
npm install @sophonz/react-native-otlp@0.1.0
cd ios && pod installJavaScript가 SDK를 시작할 때
initialize나 useSophonz에 exporters를 넘깁니다. 익스포터가 있으면 코어 패키지는 자체 시작 대신 OTLP 패키지를 통해 SDK를 시작합니다.
import {initialize} from "@sophonz/react-native";
await initialize({
sdkConfig: {
ios: {
collectorUrl: "https://in.sophonz.ai",
appKey: "sk_ios_replace_me",
disabledUrlPatterns: ["otlp.example.com"],
},
exporters: {
traceExporter: {
endpoint: "https://otlp.example.com/v1/traces",
headers: [{key: "Authorization", token: "Bearer <token>"}],
timeout: 30,
},
logExporter: {
endpoint: "https://otlp.example.com/v1/logs",
headers: [{key: "Authorization", token: "Bearer <token>"}],
},
},
},
});| 필드 | 설명 |
|---|---|
endpoint | /v1/traces 또는 /v1/logs까지 포함한 전체 URL. 넘기는 익스포터마다 필수 |
headers | 모든 전송에 붙는 {key, token} 쌍. token은 헤더 값 전체입니다 |
timeout | 초 단위. 생략하면 OpenTelemetry 익스포터의 기본값이 적용됩니다 |
익스포터는 하나만 넘겨도 됩니다.
CAUTION — 익스포터 설정이 잘못되면 시작 자체가 멈춥니다
exporters가 객체가 아니거나, 익스포터에 문자열 endpoint가 없거나, headers가 배열이 아니면 OTLP 패키지가 [Sophonz] Invalid ...를 출력하고 SDK는 아예 시작되지 않습니다. initialize는 false로 resolve됩니다. 익스포터 설정을 바꾼 뒤에는 콘솔을 확인하세요.
iOS에서 collectorUrl/appKey도 Sophonz-Info.plist도 없이 익스포터만 있으면 SDK는 Sophonz로는 아무것도 보내지 않는 익스포터 전용 모드로 시작합니다. Android에서는 sophonz-config.json의 Sophonz 컬렉터도 항상 함께 쓰입니다.
Metro에서 require.context 허용
코어 패키지는 OTLP 패키지가 없는 앱의 번들링이 실패하지 않도록 Metro의 require.context로 OTLP 패키지를 찾습니다. Expo는 이 기능을 켜 두지만, 일반 React Native 앱은 metro.config.js에서 켜야 합니다.
const {getDefaultConfig, mergeConfig} = require("@react-native/metro-config");
module.exports = mergeConfig(getDefaultConfig(__dirname), {
transformer: {
unstable_allowRequireContext: true,
},
});켜지 않으면 콘솔에 an error ocurred when checking if @sophonz/react-native-otlp was installed가 나오고, SDK는 익스포터 없이 시작하며 initialize는 여전히 true로 resolve됩니다. 탐색은 두 패키지가 node_modules/@sophonz 안에 나란히 있다고 가정합니다. npm과 Yarn은 이렇게 설치하며, 한 패키지가 다른 패키지 안에 중첩되는 구조에서도 같은 방식으로 실패합니다.
전송 요청을 네트워크 수집에서 제외
두 SDK는 익스포터 자신의 요청까지 포함해 HTTP 요청을 수집합니다. 그대로 두면 전송할 때마다 네트워크 스팬이 생기고 그 스팬이 또 전송됩니다. SDK는 자기 컬렉터는 자동으로 제외하지만 두 번째 백엔드는 제외하지 않습니다.
- Android:
sophonz-config.json의sdk_config.networking.disabled_url_patterns에 호스트를 추가하세요. - iOS:
sdkConfig.ios.disabledUrlPatterns에, 네이티브로 시작한다면URLSessionCaptureService.Options(ignoredURLs:)에 추가하세요.
SDK를 네이티브에서 시작할 때
Expo 플러그인, 설치 마법사, 또는 직접 작성한 네이티브 시작을 쓰면 JavaScript의 exporters는 무시됩니다. 익스포터는 네이티브 코드에서 start 전에 등록하세요. 익스포터 라이브러리는 OTLP 패키지가 이미 앱에 넣어 둡니다.
import io.opentelemetry.exporter.otlp.http.logs.OtlpHttpLogRecordExporter
import io.opentelemetry.exporter.otlp.http.trace.OtlpHttpSpanExporter
import io.sophonz.android.sophonzsdk.Sophonz
import io.sophonz.android.sophonzsdk.otel.java.addJavaLogRecordExporter
import io.sophonz.android.sophonzsdk.otel.java.addJavaSpanExporter
override fun onCreate() {
super.onCreate()
Sophonz.addJavaSpanExporter(
OtlpHttpSpanExporter.builder()
.setEndpoint("https://otlp.example.com/v1/traces")
.addHeader("Authorization", "Bearer <token>")
.build()
)
Sophonz.addJavaLogRecordExporter(
OtlpHttpLogRecordExporter.builder()
.setEndpoint("https://otlp.example.com/v1/logs")
.addHeader("Authorization", "Bearer <token>")
.build()
)
Sophonz.start(this)
// ...
}import Foundation
import OpenTelemetryProtocolExporterHttp
import SophonzIO
@objcMembers class SophonzInitializer: NSObject {
static func start() {
let otel = Sophonz.OTelOptions(
spanExporters: [OtlpHttpTraceExporter(endpoint: URL(string: "https://otlp.example.com/v1/traces")!)],
logExporters: [OtlpHttpLogExporter(endpoint: URL(string: "https://otlp.example.com/v1/logs")!)]
)
do {
try Sophonz.start(
options: .withCollector(
url: "https://in.sophonz.ai",
appKey: "sk_ios_replace_me",
platform: .reactNative,
otel: otel
)
)
} catch {
print("[sophonz] failed to start: \(error)")
}
}
}Expo 앱에서 SophonzInitializer.swift를 고쳤다면 파일을 다시 만드는 npx expo prebuild --clean을 피하거나, 변경 내용을 직접 만든 설정 플러그인으로 관리하세요.