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가 필요합니다. 훅이 이를 기다려 줍니다.

App.tsx
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를 만듭니다. 반환값은 다음과 같습니다.

필드설명
tracerProviderprovider. 준비되기 전에는 null
tracersophonz-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?)예
updateNameAndroid만
addLink, addLinks네이티브 SDK로 전달되며, 링크가 완전히 지원되지 않는다는 콘솔 경고가 나옵니다
startSpan 옵션 startTime, attributes, root예
startSpan 옵션 kindAndroid만. 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은 무시되며, 배열 속성 값은 문자열이 됩니다. OpenTelemetry TracerProvider가 없는 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 install

JavaScript가 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에서 켜야 합니다.

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 패키지가 이미 앱에 넣어 둡니다.

MainApplication.kt
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)
  // ...
}
ios/MyApp/SophonzInitializer.swift
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을 피하거나, 변경 내용을 직접 만든 설정 플러그인으로 관리하세요.