콘텐츠로 건너뛰기
Telemetry
문서 찾아보기

가이드업데이트된 2026년 10월 2일8 최소 읽기

코딩 에이전트와 함께 이 문서를 사용하세요.

Claude Code, Codex, Cursor 또는 다른 코딩 에이전트에 대한 집중 프롬프트 팩을 연 다음 여기에서 다루는 워크플로에 맞게 조정하세요.

이 페이지에서
  1. 이 패턴이 맞을 때
  2. 클라이언트 계약 정의
  3. 애플리케이션 소유 엔드포인트 구현
  4. 4개의 서버 측 컨트롤 적용
  5. 인증하다
  6. 허용 목록
  7. 비율 제한
  8. 신뢰할 수 있는 컨텍스트 추가
  9. 모바일 전송 동작 선택
  10. 결과 쿼리 및 확인
  11. 계획해야 할 실패 모드
  12. 주요 참고문헌

보안 모바일 Telemetry 프록시

모바일 애플리케이션은 귀하가 제어하지 않는 장치에 배포됩니다. React Native 번들, Swift 앱, Kotlin 앱 또는 Flutter 바이너리로 컴파일된 모든 항목을 최종적으로 검사할 수 있습니다. 앱에 Telemetry API 키, 앱에 전달된 원격 구성 값 또는 클라이언트가 읽을 수 있는 환경 파일을 제공하지 마세요.

대신, 애플리케이션이 소유한 인증된 엔드포인트에 작은 이벤트를 보내세요. 해당 엔드포인트는 고정 계약의 유효성을 검사하고, 신뢰할 수 있는 서버 컨텍스트를 추가하고, 서버 측 키를 사용하여 이벤트를 전달합니다.

Telemetry 이전에 인증, 허용 목록 작성 및 속도 제한이 포함된 애플리케이션 API를 통한 모바일 앱의 개념적 모바일 텔레메트리 흐름

모바일 앱은 Telemetry 수집 키를 수신하지 않습니다. API는 인증, 검증, 샘플링 및 전달을 소유합니다.

이 패턴이 맞을 때

제품 이정표, 제한된 성능 측정, 동기화 결과, 제어된 오류 범주 및 릴리스 품질 신호를 위해 모바일 프록시를 사용하세요. 예는 다음과 같습니다:

  • 로컬-서버 동기화가 터미널 결과에 도달한 후 mobile_sync_completed.
  • mobile_screen_ready는 명명된 화면과 측정된 지속 시간의 작은 집합입니다.
  • 서버가 내구성 있는 결과를 확인한 후 mobile_purchase_flow_completed입니다.
  • 샘플링되고 분류된 종속성 결과에 대한 mobile_api_request_completed입니다.

이는 충돌 기호화, 장치 로그, 분산 추적 또는 정확한 감사 원장을 대체하지 않습니다. 모바일 전송은 연결, 백그라운드 실행 제한, 앱 종료, 동의 및 클라이언트 시계 품질의 영향을 받습니다. 전문가 시스템에 전문가 진단을 유지하고 SQL 분석에 필요한 결과만 보냅니다.

클라이언트 계약 정의

클라이언트는 버전이 지정된 이벤트 이름 및 필드 목록에서 선택해야 합니다. Telemetry 테이블 이름을 선택하거나 임의의 속성 모음을 보내거나 예외 메시지를 전달해서는 안 됩니다.

{
  "event_name": "mobile_sync_completed",
  "event_version": 1,
  "event_id": "0196f37e-6e83-7b75-9d04-6b8283b35f74",
  "occurred_at": "2026-07-30T18:42:11.150Z",
  "status": "success",
  "duration_ms": 842,
  "item_count": 12,
  "network_type": "wifi"
}

치수를 제한적으로 유지하세요. 원시 경로보다는 제어된 screen_name를, 예외 문자열보다는 error_type 열거형을, 네트워크 식별자보다는 대략적인 network_type를 선호합니다. 액세스 토큰, 장치 광고 식별자, 연락처 데이터, 메시지 내용, 파일 경로, 클립보드 데이터, 자유 형식 검색 텍스트 또는 원시 URL을 포함하지 마십시오.

event_id로 재시도를 식별할 수 있지만, 이 예제는 중복을 제거하거나 정확히 한 번 전달을 보장하지 않습니다. 기기 시계가 틀릴 수 있으므로 데이터 최신성은 서버 수신 시각으로 확인하세요.

애플리케이션 소유 엔드포인트 구현

이 팩토리는 서버에서만 실행되며 클라이언트가 보고한 동기화 관측을 Log API로 전달합니다. 반환된 핸들러를 앱에 등록하세요. authenticate는 검증된 앱 세션, allowRequest는 계정·세션별 및 전역 요청 제한, apiKey는 서버 설정의 키, environment는 고정값을 사용합니다. 인증, 동의, 요청 제한 저장소, 본문 읽기 기한은 구현하지 않습니다. 키를 모바일 코드에 전달하지 마세요.

type Actor = {
  accountId: string;
  deviceSessionId: string;
  platform: "ios" | "android";
  appVersion: string;
};
type Dependencies = {
  authenticate: (request: Request) => Promise<Actor | null>;
  allowRequest: (actor: Actor) => Promise<boolean>;
  apiKey: string;
  environment: "production" | "staging" | "development";
  diagnostic?: () => void;
};
const fields = new Set([
  "event_name", "event_version", "event_id", "occurred_at",
  "status", "duration_ms", "item_count", "network_type",
]);
const statuses = new Set(["success", "failed", "cancelled"]);
const networks = new Set(["wifi", "cellular", "ethernet", "offline", "unknown"]);

async function readBoundedJson(request: Request) {
  if (!request.body) return { ok: false as const, status: 400 };
  const reader = request.body.getReader();
  const chunks: Uint8Array[] = [];
  let bytes = 0;
  try {
    while (true) {
      const { done, value } = await reader.read();
      if (done) break;
      bytes += value.byteLength;
      if (bytes > 4096) {
        void reader.cancel().catch(() => {});
        return { ok: false as const, status: 413 };
      }
      chunks.push(value);
    }
    const joined = new Uint8Array(bytes);
    let offset = 0;
    for (const chunk of chunks) {
      joined.set(chunk, offset);
      offset += chunk.byteLength;
    }
    const text = new TextDecoder("utf-8", { fatal: true }).decode(joined);
    return { ok: true as const, value: JSON.parse(text) as unknown };
  } catch {
    return { ok: false as const, status: 400 };
  } finally {
    reader.releaseLock();
  }
}
function integerBetween(value: unknown, maximum: number): value is number {
  return typeof value === "number" && Number.isInteger(value) && value >= 0 && value <= maximum;
}
function boundedText(value: unknown, maximum: number): value is string {
  return typeof value === "string" && value.length > 0 && value.length <= maximum;
}
function clientTimestamp(value: unknown, receivedAt: number) {
  if (typeof value !== "string" || !/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/.test(value)) return false;
  const parsed = Date.parse(value);
  return Number.isFinite(parsed) && new Date(parsed).toISOString() === value &&
    parsed >= receivedAt - 86_400_000 && parsed <= receivedAt + 300_000;
}

export function createMobileTelemetryHandler(deps: Dependencies) {
  return async function postMobileTelemetry(request: Request) {
    const reportFailure = () => { try { deps.diagnostic?.(); } catch {} };
    try {
      if (request.method !== "POST") return new Response(null, { status: 405, headers: { Allow: "POST" } });
      const actor = await deps.authenticate(request);
      if (!actor) return new Response("unauthorized", { status: 401 });
      const type = request.headers.get("content-type")?.split(";")[0].trim().toLowerCase();
      if (type !== "application/json") return new Response("unsupported content type", { status: 415 });
      if (!(await deps.allowRequest(actor))) return new Response("rate limited", { status: 429 });
      const parsed = await readBoundedJson(request);
      if (!parsed.ok) return new Response("invalid body", { status: parsed.status });
      const input = parsed.value;
      if (input === null || typeof input !== "object" || Array.isArray(input)) {
        return new Response("invalid event", { status: 422 });
      }
      const body = input as Record<string, unknown>;
      const receivedAt = Date.now();
      if (
        Object.keys(body).some(key => !fields.has(key)) ||
        body.event_name !== "mobile_sync_completed" || body.event_version !== 1 ||
        typeof body.event_id !== "string" || !/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(body.event_id) ||
        !clientTimestamp(body.occurred_at, receivedAt) ||
        typeof body.status !== "string" || !statuses.has(body.status) ||
        !integerBetween(body.duration_ms, 300_000) || !integerBetween(body.item_count, 10_000) ||
        typeof body.network_type !== "string" || !networks.has(body.network_type)
      ) return new Response("invalid event", { status: 422 });
      if (
        !deps.apiKey || !boundedText(actor.accountId, 128) || !boundedText(actor.deviceSessionId, 128) ||
        !boundedText(actor.appVersion, 80) || !["ios", "android"].includes(actor.platform) ||
        !["production", "staging", "development"].includes(deps.environment)
      ) return new Response("collector unavailable", { status: 503 });
      const response = await fetch("https://api.telemetry.sh/log", {
        method: "POST",
        headers: { "Content-Type": "application/json", Authorization: `Bearer ${deps.apiKey}` },
        signal: AbortSignal.timeout(1000),
        body: JSON.stringify({ table: "mobile_sync_completed", data: {
          event_id: body.event_id, event_version: 1, occurred_at: body.occurred_at,
          received_at: new Date(receivedAt).toISOString(), status: body.status,
          duration_ms: body.duration_ms, item_count: body.item_count,
          network_type: body.network_type, account_id: actor.accountId,
          app_platform: actor.platform, app_version: actor.appVersion,
          environment: deps.environment,
        } }),
      });
      if (!response.ok) {
        void response.body?.cancel().catch(() => {});
        reportFailure();
        return new Response("collection unavailable", { status: 503 });
      }
      void response.body?.cancel().catch(() => {});
      return new Response(null, { status: 202 });
    } catch {
      reportFailure();
      return new Response("collection unavailable", { status: 503 });
    }
  };
}

대상 테이블을 고정하고 실제 바이트 수, JSON 구조, 알 수 없는 필드, 숫자 및 서버 컨텍스트를 검증합니다. occurred_at은 밀리초를 포함한 UTC이며 수신 시각보다 최대 24시간 이전 또는 5분 이후여야 합니다. 앱에 맞게 이 정책을 명시적으로 조정하세요. 클라이언트 상태는 백엔드 커밋의 증거가 아닙니다. 확정된 결과는 서버에서 전송하세요.

202는 업스트림 HTTP 응답 성공만 뜻하며 영구 저장, 중복 제거 또는 SQL 조회 확인을 증명하지 않습니다. 내보내기 실패는 503을 반환하고 진단 실패가 이 응답을 덮어쓰지 않습니다. 제품 작업이 완료된 후 별도의 요청으로 보내세요. 1초 AbortSignal은 전달을 협력적으로 제한하지만 인증, 요청 제한 및 본문 읽기를 포함한 전체 기한은 아닙니다. 영구 큐나 자동 재시도는 없습니다.

4개의 서버 측 컨트롤 적용

인증하다

나머지 API에서 사용하는 것과 동일한 서명된 애플리케이션 세션 또는 설치 자격 증명이 필요합니다. CORS는 모바일 보안 경계가 아니며 사용자 정의 헤더만으로는 누가 요청을 보냈는지 증명할 수 없습니다.

허용 목록

알 수 없는 이벤트 이름, 필드, 열거형 값, 너무 큰 문자열, 잘못된 숫자, 향후 타임스탬프 및 작은 크기 제한을 초과하는 본문을 거부합니다. 허용 목록은 개인정보 보호 제어이자 카디널리티 제어입니다.

비율 제한

인증된 계정과 적절한 설치 또는 세션 식별자로 제한됩니다. 또한 전역 상한선을 부과합니다. 클라이언트가 제한을 초과하면 무기한 재시도하지 않고 일반 애플리케이션 오류를 반환합니다.

신뢰할 수 있는 컨텍스트 추가

프록시는 해당 값을 사용할 수 있는 경우 계정 식별자, 서버 수신 시간, 환경 및 확인된 애플리케이션 버전을 추가해야 합니다. 계정 식별자가 귀하의 상황에서 민감한 경우 수집하기 전에 이를 일관성 있게 가명화하고 매핑을 되돌릴 수 있는 사람을 문서화하십시오.

모바일 전송 동작 선택

React Native 및 Flutter의 경우 이미 인증된 API 요청을 담당하는 플랫폼 HTTP 클라이언트를 사용하세요. 기본 iOS 및 Android의 경우 앱에서 사용하는 것과 동일한 URLSession 또는 HTTP 스택을 사용하세요. 엔드포인트와 이벤트 계약은 플랫폼 전반에서 동일하게 유지되어야 합니다.

오프라인 전달이 중요한 경우 소규모의 제한된 대기열을 유지하세요. 항목 수와 기간을 모두 제한하고, 문서화된 창을 초과한 이벤트를 삭제하고, 지터와 함께 지수 백오프를 사용합니다. 분석 재시도로 인해 제품 작업이 지연되지 않도록 하십시오. 서버가 중복을 제거할 수 있도록 시도 전반에 걸쳐 동일한 event_id를 보존합니다.

일부 결과는 서버에서 더 잘 방출됩니다. 구매, 구독 변경, 액세스 정책 결정 또는 완료된 가져오기는 백엔드가 커밋한 후에만 권한이 부여됩니다. 클라이언트 어설션을 신뢰하는 대신 서버가 해당 결과를 직접 내보내도록 합니다.

결과 쿼리 및 확인

먼저 전송을 캡처하는 오프라인 테스트로 성공, 실패, 취소, 잘못되거나 너무 큰 JSON, 미인증, 요청 제한, 업스트림 실패 및 중복 ID를 확인하세요. 가상의 성공을 mobile_sync_completed에 넣지 마세요. 별도 수집 테스트에는 telemetry_quickstart를 사용하세요.

그다음 자신의 앱에서 동의를 얻은 실제 동기화를 수행하고 쿼리의 ID를 관측한 event_id로 바꾸세요. 테스트 데이터나 클라이언트 상태만으로는 실제 통합 또는 영구적인 업무 결과를 증명할 수 없습니다:

SELECT
  received_at,
  event_id,
  app_platform,
  app_version,
  status,
  duration_ms,
  item_count
FROM mobile_sync_completed
WHERE event_id = 'your-real-observed-event-id'
ORDER BY received_at DESC
LIMIT 50;

출시 전에 다음을 확인하세요.

  1. 배송된 애플리케이션 바이너리와 JavaScript 번들에는 Telemetry API 키가 포함되어 있지 않습니다.
  2. 알 수 없는 이벤트 및 필드는 자동으로 전달되지 않고 거부됩니다.
  3. 원시 예외 텍스트, URL, 사용자 입력, 자격 증명 및 장치 식별자가 없습니다.
  4. 재시도에서는 같은 event_id를 유지합니다. 이 팩토리는 중복을 허용하므로 결과를 집계하기 전에 충돌하는 중복 페이로드를 검토하고 앱에서 중복을 제거하세요.
  5. 대시보드에는 요율 및 백분위수 옆에 샘플 수가 표시됩니다.
  6. 동의, 보유, 삭제, 계정 삭제 동작은 애플리케이션 정책과 일치합니다.

계획해야 할 실패 모드

이벤트가 별도로 설계된 내구성 있는 비즈니스 워크플로의 일부가 아닌 한 프록시를 최선의 옵저버빌리티으로 취급합니다. Telemetry 시간 초과는 일반적으로 모바일 제품 작업 실패 없이 기록되고 제한되어야 합니다. 프록시 거부율, 전달 실패, 대기열 수명 및 이벤트 최신성을 모니터링하여 자동 계측 중단이 제품 사용량 감소로 보이지 않도록 합니다.

"디버깅을 더 쉽게 만들기" 위해 임의의 클라이언트 이벤트를 자동으로 수락하지 마세요. 그러면 엔드포인트가 팀에서 검토하지 않은 필드까지 수락하게 됩니다. 목적, 유형, 카디널리티, 개인 정보 분류 및 삭제 요구 사항을 검토한 후에만 새 버전이 지정된 필드 또는 이벤트를 추가하세요.

주요 참고문헌

관련 기능

안정적인 이벤트 이름, 타입이 지정된 필드, 개인 정보 보호 검토 컨텍스트를 캡처합니다.

페이지 작성자 및 참고 자료

이 설명은 Telemetry 편집팀의 소유입니다. 제품 팀은 동작, 예시, 경계를 검토합니다.

문서 검토 방법