네트워크
SophonzHttpClient, sophonz_dio 인터셉터, recordNetworkRequest로 Dart의 HTTP 요청을 기록하고, W3C traceparent 헤더를 전파해 백엔드 트레이스를 앱의 요청에 잇는 방법을 설명합니다.
Dart에서 보낸 요청은 OkHttp나 URLSession을 거치지 않으므로 네이티브 SDK는 이를 보지 못합니다. 대신 클라이언트를 감싸서 기록합니다. http 패키지는 SophonzHttpClient, Dio는 SophonzInterceptor, 그 밖의 방식은 recordNetworkRequest입니다. 요청마다 URL, 메서드, 상태 코드, 크기, 서버로 보낸 traceparent 헤더 값을 담은 네트워크 스팬이 만들어집니다.
클라이언트 고르기
| 클라이언트 | 요청 기록 | traceparent 전송 |
|---|---|---|
SophonzHttpClient (http) | 예 | 모든 요청 |
SophonzInterceptor (Dio) | 예 | OpenTelemetry 스팬이 현재 스팬일 때만. 또는 아래의 인터셉터를 추가했을 때 |
recordNetworkRequest | 넘긴 내용 | 헤더를 직접 추가 |
http 패키지
SophonzHttpClient는 http.BaseClient이므로 http.Client를 받는 곳이면 어디든 쓸 수 있습니다.
import 'package:http/http.dart' as http;
import 'package:sophonz/sophonz.dart';
final http.Client client = SophonzHttpClient();
final response = await client.get(Uri.parse('https://api.example.com/orders'));재시도나 커스텀 HttpClient를 쓰는 기존 클라이언트는 감싸서 유지합니다.
final client = SophonzHttpClient(internalClient: RetryClient(http.Client()));close()는 감싼 클라이언트를 닫습니다.
요청마다 기록되는 내용:
| 항목 | 값 |
|---|---|
| URL | request.url 전체. iOS는 쿼리 문자열과 프래그먼트를 제거 |
| 메서드 | GET, POST, PUT, DELETE, PATCH. 그 밖에는 OTHER |
| 시작, 끝 | send 호출부터 응답 헤더 도착까지. 본문 읽기는 포함되지 않습니다 |
| 보낸 바이트 | request.contentLength, 모르면 0 |
| 받은 바이트 | 응답의 Content-Length, 없으면 0 |
| 상태 | 응답 상태 코드 |
| 오류 | ClientException(연결 거부, DNS 실패)은 미완료 요청으로 기록한 뒤 다시 던집니다 |
Dio
설치에 설명한 대로 dependency_overrides 항목과 함께 sophonz_dio를 추가합니다. Dio 4.x와 5.x를 지원합니다.
import 'package:dio/dio.dart';
import 'package:sophonz_dio/sophonz_dio.dart';
final dio = Dio(BaseOptions(baseUrl: 'https://api.example.com'))
..interceptors.add(SophonzInterceptor());| 결과 | 기록 형태 |
|---|---|
| 응답 | 상태 코드가 있는 완료 요청 |
응답이 있는 DioException(기본 validateStatus에서의 404 등) | 그 상태 코드가 있는 완료 요청 |
응답이 없는 DioException(연결 오류, 타임아웃, 취소) | 오류 메시지가 있는 미완료 요청 |
크기는 추정값입니다. 보낸 바이트는 String이나 List<int> 본문의 길이, Map이나 List를 JSON으로 인코딩한 길이이며 FormData와 스트림은 0입니다. 받은 바이트는 Content-Length 헤더이고, 헤더가 없으면 plain·JSON 응답 본문의 길이입니다.
SophonzInterceptor는 URL이나 헤더를 바꾸는 인터셉터 뒤에 추가해야 실제로 보낸 요청이 기록됩니다.
CAUTION — Android에서는 다른 HTTP 메서드가 기록되지 않습니다
Android SDK는 표준 메서드 이름만 받는데, Dart 계층은 GET, POST, PUT, DELETE, PATCH 외의 메서드를 모두 OTHER로 바꿉니다. 그래서 Android에서 HEAD나 OPTIONS 요청은 버려집니다. iOS는 OTHER로 기록합니다.
요청 직접 기록하기
gRPC 채널이나 네이티브 네트워크로 가는 플랫폼 채널처럼 래퍼가 없는 클라이언트는 요청이 끝난 뒤 직접 만들어 기록합니다.
import 'package:sophonz/sophonz.dart';
import 'package:sophonz/sophonz_api.dart';
final start = DateTime.now().millisecondsSinceEpoch;
try {
final result = await grpcClient.getOrder(request);
Sophonz.instance.recordNetworkRequest(
SophonzNetworkRequest.fromCompletedRequest(
url: 'https://grpc.example.com/orders.OrderService/GetOrder',
httpMethod: HttpMethod.post,
startTime: start,
endTime: DateTime.now().millisecondsSinceEpoch,
bytesSent: request.writeToBuffer().length,
bytesReceived: result.writeToBuffer().length,
statusCode: 200,
),
);
} catch (error) {
Sophonz.instance.recordNetworkRequest(
SophonzNetworkRequest.fromIncompleteRequest(
url: 'https://grpc.example.com/orders.OrderService/GetOrder',
httpMethod: HttpMethod.post,
startTime: start,
endTime: DateTime.now().millisecondsSinceEpoch,
errorDetails: error.toString(),
),
);
rethrow;
}| 파라미터 | fromCompletedRequest | fromIncompleteRequest |
|---|---|---|
url, httpMethod, startTime, endTime | 필수 | 필수 |
bytesSent, bytesReceived, statusCode | 필수 | 받지 않음. -1로 전송 |
errorDetails | 받지 않음 | 필수 |
traceId | 선택 | 선택 |
w3cTraceparent | 선택. 실제로 보낸 헤더 | 선택 |
시간은 epoch 밀리초입니다.
트레이스 전파
요청에 traceparent 헤더가 있으면 백엔드가 같은 트레이스를 이어 갑니다. 백엔드의 서버 스팬은 헤더에 담긴 스팬 ID의 자식이 되고, 앱의 네트워크 스팬은 보낸 헤더 값을 기록하므로 앱의 요청과 서버의 처리를 함께 찾을 수 있습니다.
헤더를 만드는 곳
래퍼는 요청에 이미 있는 traceparent를 덮어쓰지 않습니다. 없을 때는 다음과 같습니다.
| 상황 | SophonzHttpClient | SophonzInterceptor |
|---|---|---|
| OpenTelemetry 스팬이 현재 스팬일 때(아래 참고) | 그 스팬의 헤더. Android에서는 네이티브 SDK가 새 값을 대신 만듭니다 | Dart에서 만든 그 스팬의 헤더 |
| 현재 스팬이 없을 때 | 네이티브 SDK가 만든 새 값 | 보내지 않습니다. 응답 뒤에 값을 만들어 스팬에만 기록합니다 |
새 값은 무작위 트레이스·스팬 ID에 sampled 플래그가 붙은 형태입니다. 예: 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01.
Dio에서 traceparent 보내기
모든 Dio 요청에 헤더를 보내려면 SophonzInterceptor 앞에 인터셉터를 하나 둡니다. 헤더를 받을 호스트를 제한할 수도 있습니다.
import 'package:dio/dio.dart';
import 'package:sophonz/sophonz.dart';
import 'package:sophonz_dio/sophonz_dio.dart';
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);
}
}
final dio = Dio()
..interceptors.addAll([
TraceparentInterceptor({'api.example.com'}),
SophonzInterceptor(),
]);SophonzInterceptor는 이미 설정된 헤더를 보고 그 값을 네트워크 스팬에 기록합니다.
직접 만든 스팬에 잇기
OpenTelemetry API로 시작한 스팬에 요청을 포함하려면 그 스팬을 현재 스팬으로 두고 요청을 실행합니다.
import 'package:dartastic_opentelemetry_api/dartastic_opentelemetry_api.dart';
final tracer = OTelAPI.tracerProvider().getTracer('checkout');
final span = tracer.startSpan('submit-order');
try {
await tracer.withSpanAsync(span, () => dio.post('/orders', data: order));
} finally {
span.end();
}startSpan만으로는 현재 스팬이 되지 않습니다. OpenTelemetry API로 만든 스팬의 제약은 API 레퍼런스를 참고하세요.
헤더를 받는 호스트
SophonzHttpClient는 서드파티 API를 포함한 모든 요청에 헤더를 붙입니다. 트레이스 ID를 받으면 안 되는 호스트에는 일반 http.Client를 쓰거나, 위처럼 호스트를 제한한 인터셉터와 Dio를 쓰세요.
백엔드 설정
백엔드에는 W3C Trace Context 전파기를 쓰는 OpenTelemetry SDK가 필요합니다. Sophonz 백엔드 SDK와 OpenTelemetry 전반의 기본값입니다. Node.js, Python, Go, Java를 참고하세요.
CORS는 해당하지 않습니다. CORS는 브라우저의 규칙이고 Android·iOS의 Flutter 앱은 요청을 직접 보내므로, 서버에 traceparent용 Access-Control-Allow-Headers를 둘 필요가 없습니다. 백엔드 앞의 리버스 프록시나 API 게이트웨이는 이 헤더를 그대로 넘겨야 합니다.