セキュアモバイル Telemetry プロキシ
モバイル アプリケーションは、ユーザーが制御していないデバイスに配布されます。 React Native バンドル、Swift アプリ、Kotlin アプリ、または Flutter バイナリにコンパイルされたものはすべて、最終的には検査できます。アプリ内の Telemetry API キー、アプリに配信されるリモート構成値、またはクライアントが読み取り可能な環境ファイルを配布しないでください。
代わりに、アプリケーションが所有する認証されたエンドポイントに小さなイベントを送信します。そのエンドポイントは、固定コントラクトを検証し、信頼できるサーバー コンテキストを追加し、サーバー側のキーを使用してイベントを転送します。
モバイルプロキシの操作フロー図。アプリが自社APIへイベントを送り、APIが認証、許可イベントの検証、レート制限を行ってからTelemetryへ転送します。 モバイルアプリにはTelemetryの取り込みキーを渡しません。
このパターンが当てはまる場合
製品マイルストーン、制限されたパフォーマンス測定、同期結果、管理されたエラー カテゴリ、およびリリース品質の信号にはモバイル プロキシを使用します。例としては次のものが挙げられます。
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 はモバイル セキュリティ境界ではないため、カスタム ヘッダーだけでは誰がリクエストを送信したかを証明できません。
ホワイトリスト
不明なイベント名、フィールド、列挙値、大きすぎる文字列、無効な数値、将来のタイムスタンプ、および小さなサイズ制限を超える本文を拒否します。ホワイトリストはプライバシー制御とカーディナリティ制御の両方です。
レート制限
認証されたアカウントと適切なインストールまたはセッション ID によって制限されます。また、グローバルな上限を課します。クライアントが制限を超えた場合、無期限に再試行せずに通常のアプリケーション エラーを返します。
信頼できるコンテキストを追加する
プロキシは、アカウント識別子、サーバー受信時間、環境、および検証済みのアプリケーションのバージョンが利用可能な場合は、それらの値を追加する必要があります。アカウント識別子がコンテキスト内で機密性の高いものである場合は、収集する前に一貫して仮名化し、マッピングを元に戻すことができる人を文書化します。
モバイル配信動作を選択する
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;
ロールアウトする前に、次のことを確認してください。
- 出荷されたアプリケーション バイナリと JavaScript バンドルには、Telemetry API キーは含まれません。
- 未知のイベントとフィールドは、サイレントに転送されるのではなく拒否されます。
- 生の例外テキスト、URL、ユーザー入力、資格情報、およびデバイス識別子がありません。
- 再試行では同じ
event_idを維持します。このファクトリーは重複を許すため、集計前に矛盾する重複ペイロードを確認し、アプリ側で重複排除してください。 - ダッシュボードには、レートとパーセンタイルのほかにサンプル数が表示されます。
- 同意、保持、削除、およびアカウント削除の動作は、アプリケーション ポリシーに一致します。
計画すべき障害モード
イベントが個別に設計された耐久性のあるビジネス ワークフローの一部でない限り、プロキシをベストエフォート型の可観測性として扱います。 Telemetry タイムアウトは通常、モバイル製品アクションを失敗させることなくログに記録され、制限される必要があります。プロキシの拒否率、転送失敗、キューの経過時間、およびイベントの鮮度を監視して、サイレント インストルメンテーションの中断が製品使用量の低下のように見えないようにします。
「デバッグを容易にする」ために、任意のクライアント イベントを自動的に受け入れないでください。その場合、チームが確認していないフィールドまでエンドポイントが受け入れてしまいます。新しいバージョン管理されたフィールドまたはイベントは、その目的、タイプ、基数、プライバシー分類、および削除要件を確認した後でのみ追加してください。