Diseña un SDK de analítica/telemetría que las apps móviles integran
Un plano senior para un SDK de telemetría con cola durable y por lotes — API pública, modelo de hilos, lógica de reintentos y los controles de privacidad que lo mantienen fuera de problemas de consentimiento.
APRENDE HACIENDOPon a prueba tu intuición3 pasos
Diseña un SDK de analítica/telemetría que las apps móviles integran
Usa los ejemplos y la explicación de la lección para comprobar tu idea.
Reflexión personal. No se envía a ningún servicio de IA.#Aclarar los requisitos
Antes de escribir un solo método público, separaría para qué es realmente este SDK. ¿Es analítica de producto (embudos, uso de funciones), telemetría de crashes y rendimiento, o ambas compartiendo un mismo pipeline? ¿Necesita entrega casi en tiempo real para dashboards, o es aceptable un retraso de unos minutos? — y casi todos los casos de uso de analítica toleran mucho más retraso del que los ingenieros suponen, lo cual importa mucho para el diseño. ¿Necesita soportar múltiples destinos — un data warehouse interno más una herramienta de terceros como Firebase o Amplitude — o solo uno? ¿Cuál es el tamaño binario y la huella de dependencias aceptables?, ya que este código se distribuye dentro de cada app que lo integra, y un SDK inflado es un costo real para el tamaño de instalación y el tiempo de arranque de la app anfitriona. ¿Y qué modelo de consentimiento tiene que respetar? — opt-out de GDPR/CCPA, App Tracking Transparency de Apple para cualquier cosa ligada a identificadores publicitarios. Esas respuestas definen casi todo sobre el pipeline, especialmente la tensión entre entregar todo rápido y ser invisible en el consumo de batería y red de la app anfitriona.
#Una API pública pequeña y con criterio
La superficie pública debería ser deliberadamente aburrida — un puñado de llamadas que los ingenieros de la app anfitriona puedan usar sin leer documentación, porque el trabajo del SDK es desaparecer dentro de la app, no convertirse en algo más que aprender. Expondría inicialización, identidad, registro de eventos, registro de pantallas, un flush explícito y un interruptor de consentimiento, y nada más en el nivel superior:
object Analytics {fun initialize(config: AnalyticsConfig)fun identify(userId: String, traits: Map<String, Any> = emptyMap())fun track(event: String, properties: Map<String, Any> = emptyMap())fun screen(name: String, properties: Map<String, Any> = emptyMap())/** Fuerza un intento de flush inmediato; el SDK igual agrupa por su cuenta. */fun flush()/** Detiene la recolección; los eventos ya encolados se envían una vez y luego se descartan. */fun setOptedOut(optedOut: Boolean)}data class AnalyticsConfig(val writeKey: String,val endpoint: String,val flushAtCount: Int = 20,val flushIntervalMs: Long = 30_000,val maxQueueSize: Int = 1_000,val maxRetries: Int = 5)
Cada una de esas llamadas tiene que regresar de inmediato y no hacer prácticamente ningún trabajo en el hilo del que la invoca, porque las apps anfitrionas van a llamar a track desde manejadores de clics y listeners de scroll sin pensarlo dos veces — si el SDK bloquea, se convierte en el tironeo (jank) de la app anfitriona, y esa es la forma más rápida de que lo saquen de una base de código.
#Arquitectura: encolar, persistir, agrupar, enviar
Por debajo de esa API delgada, el pipeline tiene cuatro etapas. Primero, cada llamada a track o screen construye un evento con un UUID generado del lado del cliente (para deduplicación más adelante) y lo agrega a un write-ahead log durable — una pequeña tabla SQLite o un archivo de solo append, no una lista en memoria — de modo que la muerte del proceso entre la llamada y el siguiente flush no descarte datos en silencio. Segundo, un agrupador (batcher) vigila ese log y decide cuándo enviar: al alcanzar una cantidad configurada de eventos, por un temporizador, o de forma oportunista en el momento en que la app pasa a segundo plano, ya que ese es el último momento confiable para poner bytes en la red antes de que el sistema pueda suspender el proceso. Tercero, el paso de flush lee un lote acotado, lo comprime y lo envía al endpoint de ingesta. Cuarto, solo un éxito confirmado elimina esos eventos del log durable — cualquier otra cosa los deja ahí para el siguiente reintento, así que un crash a mitad del flush solo significa que el mismo lote se vuelve a enviar, no que desaparece.
class EventQueue(private val config: AnalyticsConfig,private val storage: EventStorage, // WAL durable, respaldado por SQLiteprivate val transport: EventTransport, // abstracción delgada de cliente HTTPprivate val scope: CoroutineScope) {private val dispatcher = Dispatchers.IO.limitedParallelism(1) // serial, fuera del hilo que lo invocaprivate var timedFlush: Job? = nullfun enqueue(event: AnalyticsEvent) {scope.launch(dispatcher) {storage.append(event)scheduleTimedFlushIfNeeded()if (storage.pendingCount() >= config.flushAtCount) flushNow()}}private fun scheduleTimedFlushIfNeeded() {if (timedFlush?.isActive == true) returntimedFlush = scope.launch(dispatcher) {delay(config.flushIntervalMs)flushNow()}}suspend fun flushNow() {val batch = storage.peekBatch(limit = 100) ?: returnwhen (val result = transport.send(batch)) {is TransportResult.Success -> storage.remove(batch.ids)is TransportResult.Retry -> scheduleBackoff(batch.attempt)is TransportResult.RejectedPayload -> storage.moveToDeadLetter(batch.ids)}}private suspend fun scheduleBackoff(attempt: Int) {val backoffMs = min(30_000L, (1_000L * 2.0.pow(attempt)).toLong())delay(backoffMs + Random.nextLong(0, 500)) // el jitter evita una estampida sobre el endpoint de ingesta}}
#Modelo de hilos y huella de rendimiento
Todo lo que ocurre después del encolado inicial — la escritura al WAL, la decisión de agrupar, la compresión y la llamada de red — corre en un único dispatcher serial en segundo plano, nunca en el hilo de quien lo invoca ni en paralelo consigo mismo, lo cual mantiene simple la implementación (no se necesita locking alrededor de la cola) a la vez que garantiza que la API pública nunca introduce jank. Además de eso, mantendría al SDK dentro de un presupuesto estricto de tamaño y dependencias — reutilizar la propia pila de red de la app anfitriona en lugar de empaquetar un segundo cliente HTTP suele valer la pequeña incomodidad de integración, porque el sobrepeso de un SDK es una de las razones más comunes por las que a un equipo de growth o de plataforma le piden que lo saque de nuevo.
#Manejo de fallos y garantías de entrega
Sería explícito en el documento de diseño en que este SDK apunta a entrega al menos una vez (at-least-once), no exactamente una vez, porque eso es lo que un sistema de cliente más reintentos puede prometer realmente — el UUID generado del lado del cliente en cada evento existe para que el backend pueda deduplicar en la ingesta, en lugar de que el SDK pretenda garantizar que nunca llegan duplicados a la red. Los reintentos usan backoff exponencial con jitter para que un incidente de servicio no se convierta en una tormenta de reintentos en el momento en que se recupera, y un tope duro de reintentos mueve un lote a un almacén dead-letter local en lugar de reintentar para siempre — un lote que falla cinco veces seguidas es mucho más probable que esté malformado que simplemente tener mala suerte transitoria. También limitaría el tamaño total de la cola durable y descartaría primero los eventos de baja prioridad más antiguos una vez que se llena, dándoles a los tipos de evento de alto valor (crashes, compras) un nivel de prioridad para que un flujo parlanchín de vistas de pantalla no pueda expulsar de la cola a un evento de compra durante un período prolongado sin conexión.
#Privacidad y consentimiento
Un SDK de telemetría también es una superficie de cumplimiento normativo, así que setOptedOut tiene que ser más que una sugerencia: debería enviar una vez lo que ya esté encolado, y luego dejar de aceptar eventos nuevos por completo y borrar cualquier cosa que quede en disco, en lugar de seguir acumulando en silencio para después. Validaría las propiedades salientes de cada evento contra un esquema explícito de lista blanca en lugar de aceptar claves arbitrarias, para que un ingeniero bien intencionado dentro de tres años no pueda pasar accidentalmente el correo de un usuario como propiedad de un evento y convertir un evento de analítica en una fuga de datos personales. Cualquier identificador usado para atribución publicitaria necesita mantenerse separado del ID interno de analítica y quedar condicionado al propio primitivo de consentimiento de la plataforma — App Tracking Transparency en iOS, el reseteo del ID de publicidad en Android — porque mezclar el ID de analítica de producto propio con el identificador publicitario es exactamente el tipo de cosa que convierte una actualización de rutina en una violación de política de Play Store o App Store.
#Tradeoffs que nombraría
Agrupar por lotes sobre HTTP es la opción correcta por defecto frente a un WebSocket persistente para casi cualquier caso de uso de analítica, porque la entrega casi en tiempo real rara vez es el requisito real, y una conexión viva cuesta bastante más batería y datos por un beneficio que la mayoría de los dashboards no necesita. Escribir cada evento individual a disco de forma síncrona es la opción más segura pero la más lenta; agruparía las escrituras al WAL en pequeños grupos en lugar de hacer fsync por evento, aceptando una pequeña ventana de posible pérdida de datos ante un crash duro a cambio de un throughput considerablemente mejor. Y trataría al propio esquema de eventos como un contrato versionado — las versiones antiguas de la app siguen activas en el campo durante meses después de que se publica un cambio de esquema, así que el endpoint de ingesta tiene que seguir aceptando la forma anterior en lugar de asumir que todos los clientes se actualizaron, una restricción que no tiene nada que ver con lo interno del SDK pero que define su API pública desde el primer día.