Proxy seguro de telemetría para aplicaciones móviles
Las aplicaciones móviles se distribuyen a dispositivos que usted no controla. Todo lo compilado en un paquete React Native, una aplicación Swift, una aplicación Kotlin o un binario Flutter puede eventualmente inspeccionarse. No envíe una clave Telemetry API en la aplicación, un valor de configuración remota entregado a la aplicación ni un archivo de entorno legible por el cliente.
En su lugar, envíe un pequeño evento a un punto final autenticado propiedad de su aplicación. Ese punto final valida un contrato fijo, agrega un contexto de servidor confiable y reenvía el evento con una clave del lado del servidor.
Diagrama de un proxy móvil: la aplicación envía eventos a su API, que autentica, valida los eventos permitidos y limita la frecuencia antes de enviarlos a Telemetry. La aplicación móvil no recibe la clave de ingesta de Telemetry.
Cuando este patrón encaja
Utilice un proxy móvil para hitos de productos, mediciones de rendimiento limitadas, resultados de sincronización, categorías de errores controladas y señales de calidad de lanzamiento. Los ejemplos incluyen:
mobile_sync_completeddespués de que una sincronización local con el servidor alcanza un resultado de terminal.mobile_screen_readypara un pequeño conjunto de pantallas con nombre y una duración medida.mobile_purchase_flow_completeddespués de que el servidor confirme el resultado duradero.mobile_api_request_completedpara obtener resultados de dependencia categorizados y muestreados.
Esto no reemplaza la simbolización de fallos, los registros de dispositivos, los seguimientos distribuidos ni un libro de auditoría exacto. La entrega móvil se ve afectada por la conectividad, los límites de ejecución en segundo plano, la terminación de la aplicación, el consentimiento y la calidad del reloj del cliente. Mantenga diagnósticos especializados en sus sistemas especializados y envíe solo el resultado necesario para el análisis SQL.
Definir el contrato del cliente.
El cliente debe elegir de una lista versionada de nombres y campos de eventos. No debe elegir un nombre de tabla Telemetry, enviar bolsas de propiedades arbitrarias ni reenviar mensajes de excepción.
{
"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"
}
Mantenga las dimensiones acotadas. Prefiera un screen_name controlado a una ruta sin formato, una enumeración error_type a una cadena de excepción y un network_type aproximado a identificadores de red. No incluya tokens de acceso, identificadores de publicidad de dispositivos, datos de contacto, contenidos de mensajes, rutas de archivos, datos del portapapeles, texto de búsqueda de formato libre ni URL sin formato.
event_id permite reconocer reintentos, pero este ejemplo no elimina duplicados ni garantiza una entrega exactamente una vez. Use la hora de recepción del servidor para evaluar la frescura; el reloj del dispositivo puede ser incorrecto.
Implementar el punto final propiedad de la aplicación
Esta fábrica se ejecuta solo en el servidor y reenvía una observación de sincronización mediante la API Log. Registre el handler devuelto en su aplicación. Proporcione authenticate desde la sesión verificada, allowRequest desde un limitador por cuenta/sesión con un límite global, apiKey desde configuración del servidor y un environment fijo. La fábrica no implementa autenticación, consentimiento, almacenamiento del limitador ni un plazo para leer el cuerpo. Nunca envíe la clave al móvil.
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 });
}
};
}
El endpoint fija la tabla y valida bytes reales, estructura JSON, campos, tipos y contexto del servidor. occurred_at debe ser UTC con milisegundos, no más de 24 horas anterior ni cinco minutos posterior a la recepción. Ajuste esa política deliberadamente. El estado declarado por el cliente no prueba un commit del servidor. Emita resultados autoritativos desde el backend.
Un 202 indica una respuesta HTTP upstream satisfactoria, no almacenamiento duradero, deduplicación ni verificación SQL. Un fallo de exportación devuelve 503; un fallo del diagnóstico no reemplaza esa respuesta. Envíe la telemetría después y por separado de la acción del producto. AbortSignal limita cooperativamente el reenvío a un segundo, no el tiempo total de autenticación, límites o lectura. No hay cola duradera ni reintento automático.
Aplicar cuatro controles del lado del servidor
autenticar
Requiere la misma sesión de aplicación firmada o credencial de instalación utilizada por el resto de su API. CORS no es un límite de seguridad móvil y un encabezado personalizado por sí solo no prueba quién envió una solicitud.
Lista de permitidos
Rechace nombres de eventos, campos, valores de enumeración, cadenas de gran tamaño, números no válidos, marcas de tiempo futuras y cuerpos que superen un límite de tamaño pequeño desconocidos. La lista de permitidos es tanto un control de privacidad como un control de cardinalidad.
Límite de tarifa
Límite por cuenta autenticada y un identificador de sesión o instalación adecuado. También imponer un techo global. Devuelve un error de aplicación normal sin reintentar indefinidamente cuando un cliente excede el límite.
Agregar contexto confiable
El proxy debe agregar el identificador de la cuenta, la hora de recepción del servidor, el entorno y la versión verificada de la aplicación cuando esos valores estén disponibles. Si un identificador de cuenta es confidencial en su contexto, pseudónimo consistentemente antes de recopilarlo y documente quién puede revertir la asignación.
Elija el comportamiento de entrega móvil
Para React Native y Flutter, utilice el cliente de plataforma HTTP que ya es responsable de las solicitudes API autenticadas. Para iOS y Android nativos, use la misma pila URLSession o HTTP que usa la aplicación. El contrato de punto final y de evento debe permanecer idéntico en todas las plataformas.
Mantenga una pequeña cola limitada cuando la entrega fuera de línea sea importante. Limite tanto el recuento como la antigüedad de los elementos, descarte los eventos que hayan excedido la ventana documentada y utilice un retroceso exponencial con fluctuación. No permita que los reintentos de análisis retrasen la acción del producto. Conserve el mismo event_id en todos los intentos para que el servidor pueda deduplicar.
Algunos resultados son mejor emitidos por el servidor. Una compra, un cambio de suscripción, una decisión de política de acceso o una importación completa adquieren autoridad solo después de que el backend la confirma. Deje que el servidor emita esos resultados directamente en lugar de confiar en la afirmación del cliente.
Consulta y verifica el resultado.
Pruebe los casos sintéticos offline con un transporte capturado: éxito, fallo, cancelación, JSON inválido o demasiado grande, autenticación, límites, fallo upstream y duplicados. No inserte éxitos inventados en mobile_sync_completed; use telemetry_quickstart para fixtures de ingestión aislados.
Después realice una sincronización consentida en su propia aplicación y sustituya el ID de la consulta por su event_id observado. Un fixture o un estado declarado por el cliente no prueba una integración ni un resultado empresarial duradero:
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;
Antes del lanzamiento, verifique:
- El binario de la aplicación enviada y el paquete JavaScript no contienen ninguna clave Telemetry API.
- Los eventos y campos desconocidos se rechazan en lugar de reenviarse silenciosamente.
- Faltan texto de excepción sin formato, URL, entradas de usuario, credenciales e identificadores de dispositivos.
- Los reintentos conservan un
event_id. La fábrica admite duplicados: revise los payloads contradictorios y deduplique en la aplicación antes de calcular resultados. - Los paneles muestran el recuento de muestras junto a tasas y percentiles.
- El comportamiento de consentimiento, retención, eliminación y eliminación de cuentas coincide con la política de la aplicación.
Modos de falla para planificar
Trate el proxy como observabilidad del mejor esfuerzo, a menos que el evento sea parte de un flujo de trabajo empresarial duradero diseñado por separado. Normalmente se debe registrar y limitar un tiempo de espera Telemetry sin que falle la acción del producto móvil. Supervise la tasa de rechazo de proxy, los errores de reenvío, la antigüedad de la cola y la actualidad de los eventos para que una interrupción silenciosa de la instrumentación no parezca una caída en el uso del producto.
No acepte automáticamente eventos de cliente arbitrarios para "facilitar la depuración". El punto final aceptaría entonces campos que su equipo no ha revisado. Agregue un nuevo campo o evento versionado solo después de revisar su propósito, tipo, cardinalidad, clasificación de privacidad y requisitos de eliminación.
Referencias primarias
- Guía móvil de OWASP sobre claves API codificadas y patrones de proxy del lado del servidor
- Flutter networking: envía datos a internet
- Entrega de eventos Telemetry, idempotencia y límites de reintento
- Guía de eliminación de datos sensibles confidenciales Telemetry
- Guía de proxy del navegador para el modelo de entrega web relacionado