Mensajería · Atributos de envío (SendGrid + Bird → BigQuery → dbt)
Trae los atributos de envío de los dos canales salientes —email transaccional (SendGrid) y WhatsApp (Bird/MessageBird)— a BigQuery vía API, los aterriza en RAW, los modela en staging por fuente y los unifica en un mart sin datos personales (
messaging.messaging_daily) para responder cuánto se manda por día, de qué templates, y qué pasa con eso.
Alcance: atributos de envío, no conversaciones
El pedido original fue volumen, templates, entregas y engagement. No se ingesta el contenido de los mensajes ni las respuestas de los clientes: de WhatsApp solo se guardan mensajes de template (type=hsm) y nunca los parámetros del template, que es donde van nombres, montos y RFCs.
Las dos mitades no tienen la misma historia ni la misma precisión
WhatsApp tiene historia desde 2023-06-27 y su fecha de envío es exacta. Email empieza el 2026-08-27 —el día del despliegue— y su fecha de envío es estimada. Email Activity retiene 3 días, así que la historia anterior no es recuperable por ningún medio. Ver Limitaciones.
¿Llegaste hasta acá desde…?
| Si ves… | Eso sale de… | La lógica está en… |
|---|---|---|
| Cuántos mensajes se mandaron por día / template / canal | solvento-adv-analytics-prod.messaging.messaging_daily |
dbt-etls → models/advanced_analytics/messaging/messaging_daily.sql |
| Open rate o click rate de un email | Columnas open_rate / click_rate de messaging_daily |
…/messaging_daily.sql (numerador en stg_sendgrid_message_activity) |
| Read rate de un template de WhatsApp | Columna read_rate de messaging_daily |
…/messaging_daily.sql (was_read en stg_bird_messages) |
| Un email individual (destinatario, estado, aperturas) | solvento-adv-analytics-prod.sendgrid.stg_sendgrid_message_activity |
dbt-etls → models/advanced_analytics/sendgrid/stg_sendgrid_message_activity.sql |
| Un mensaje de WhatsApp individual (teléfono, estado) | solvento-adv-analytics-prod.bird.stg_bird_messages |
dbt-etls → models/advanced_analytics/bird/stg_bird_messages.sql |
El nombre de un template de email (viene como d-<hex>) |
…sendgrid.stg_sendgrid_templates |
Email Activity devuelve el id pero no el nombre; el join vive en messaging_daily.sql |
| "¿Estamos capturando todo el email?" | …messaging.email_coverage_daily |
dbt-etls → models/advanced_analytics/messaging/email_coverage_daily.sql |
| El dato crudo de un envío | solvento-data-prod.sendgrid.* / solvento-data-prod.bird.* |
Cloud Run (sin lógica de negocio) |
| Campo | Valor |
|---|---|
| Origen | SendGrid API v3 (/v3/messages, /v3/stats, /v3/templates, /v3/tracking_settings) · Bird Conversations API (/conversations, /conversations/{id}/messages) |
| Patrón | Cloud Run agendado (pull con ventana, RAW append, dedup en dbt) |
| Destino RAW | solvento-data-prod.sendgrid.* (4 tablas) · solvento-data-prod.bird.* (2 tablas) |
| Destino stg | solvento-adv-analytics-prod.sendgrid.* (4) · …bird.* (2) — con PII |
| Destino mart | solvento-adv-analytics-prod.messaging.* (2) — sin PII |
| Frecuencia | SendGrid diario 07:00 MX (+ sweep sábados 08:00) · Bird diario 07:30 MX |
| Criticidad | 🟡 Media (alimenta métricas de negocio; no bloquea producto ni cobranza) |
| Dueño | Martin (data eng) · negocio: Oscar, Diego |
| Repos | sendgrid-extractor · bird-extractor · dbt-etls · terraform-gcp-infrastructure |
Diagrama de linaje
graph LR
SCH1[["⏰ sendgrid-extractor-daily<br/>0 7 * * * (MX)"]]
SCH2[["⏰ bird-extractor-daily<br/>30 7 * * * (MX)"]]
SG[["📧 SendGrid API v3"]]
BD[["💬 Bird / MessageBird<br/>Conversations API"]]
CR1["Cloud Run<br/>sendgrid-extractor-prod"]
CR2["Cloud Run<br/>bird-extractor-prod"]
RAW1[("solvento-data-prod.sendgrid<br/>message_activity · stats_daily<br/>templates · tracking_settings")]
RAW2[("solvento-data-prod.bird<br/>messages · conversations")]
STG1[("adv-analytics.sendgrid<br/>stg_sendgrid_* (4) · PII")]
STG2[("adv-analytics.bird<br/>stg_bird_* (2) · PII")]
MART[("adv-analytics.messaging<br/>messaging_daily<br/>email_coverage_daily<br/>SIN PII")]
CONS["Oscar / Diego<br/>(métricas de envío)"]
SCH1 -->|dispara| CR1
SCH2 -->|dispara| CR2
SG -->|"Bearer · 6 req/min"| CR1 --> RAW1 -->|dbt-etls| STG1 --> MART
BD -->|"AccessKey · limit<20"| CR2 --> RAW2 -->|dbt-etls| STG2 --> MART --> CONS
classDef src fill:#fde68a,stroke:#b45309,color:#000;
classDef store fill:#bfdbfe,stroke:#1e40af,color:#000;
class SCH1,SCH2,SG,BD src;
class RAW1,RAW2,STG1,STG2,MART store;
Paso a paso
-
Cloud Scheduler dispara
POST /extractsobre cada servicio (sendgrid-extractor-daily07:00 MX,bird-extractor-daily07:30 MX).retry_count = 0a propósito: la ventana solapada hace que la corrida de mañana repare la de hoy, así que un reintento inmediato solo gasta rate limit. -
Cloud Run
sendgrid-extractor-prod(repo, API key desde Secret Managersendgrid_api_key). Pide en este orden:/v3/stats— agregados diarios de la cuenta, ventana de 30 días./v3/tracking_settings— si el open/click tracking está prendido./v3/templates— catálogo (nombres)./v3/messages— actividad por template, ventana de 3 días. Es la parte cara: 6 requests por minuto y tope de 1.000 resultados sin paginación, así que el extractor consulta template por template (solo los con actividad en 21 días) y parte la ventana en tramos cuando la página se llena. La corrida tarda ~13 min.
-
Cloud Run
bird-extractor-prod(repo, key desde Secret Managermessagebird_api_key):- lista conversaciones ordenadas por
lastReceivedDatetimey corta al salir de la ventana de 2 días (limitdebe ser < 20, no documentado). - por cada conversación pide sus mensajes en orden descendente y corta la paginación al salir de la ventana de 4 días. Sin ese corte se bajaba el historial completo de cada conversación (antigüedad media ~200 días).
- guarda solo los
type=hsm, sin contenido ni parámetros.
- lista conversaciones ordenadas por
-
RAW — las 6 tablas son
WRITE_APPENDcon duplicados permitidos, particionadas poringested_at. Sin lógica de negocio. El mismo mensaje aparece varias veces porque su estado es mutable (processed→delivereden email,sent→delivered→readen WhatsApp). -
dbt-etls (
models/advanced_analytics/{sendgrid,bird,messaging}/) deduplica quedándose con la última foto por clave, clasifica estados, y une los dos canales en el mart. Todo el dominio está tagueado:dbt build --select tag:messaging. -
Consumo — Oscar y Diego sobre
messaging_daily; cualquier métrica que el mart no cubra sale de lasstg, que están al grano del mensaje.
Tablas que alimenta
| Tabla | Contenido | Cómo se carga |
|---|---|---|
sendgrid.message_activity (RAW) |
Un evento-mensaje por fila (Email Activity) | Cloud Run (append) |
sendgrid.stats_daily (RAW) |
Agregados diarios de la cuenta | Cloud Run (append, 30 días por corrida) |
sendgrid.templates (RAW) |
Catálogo de templates | Cloud Run (foto por corrida) |
sendgrid.tracking_settings (RAW) |
Estado del open/click tracking | Cloud Run (foto por corrida) |
bird.messages (RAW) |
Un mensaje de WhatsApp por fila (solo hsm) | Cloud Run (append) |
bird.conversations (RAW) |
Conversaciones activas en la ventana | Cloud Run (append) |
sendgrid.stg_sendgrid_message_activity |
1 email por fila, deduplicado. Con PII | dbt-etls (table) |
sendgrid.stg_sendgrid_stats_daily |
1 día por fila (cuenta). Control del proceso | dbt-etls (table) |
sendgrid.stg_sendgrid_templates |
Catálogo deduplicado (nombres) | dbt-etls (table) |
sendgrid.stg_sendgrid_tracking_settings |
Flags de tracking de la cuenta | dbt-etls (table) |
bird.stg_bird_messages |
1 mensaje de WhatsApp por fila. Con PII | dbt-etls (table) |
bird.stg_bird_conversations |
1 conversación por fila | dbt-etls (table) |
messaging.messaging_daily |
MART. (send_date, channel, template_key) + tasas. Sin PII | dbt-etls (table) |
messaging.email_coverage_daily |
MART de control. Capturado vs. lo que reporta SendGrid | dbt-etls (table) |
Datos personales y frontera de acceso
La frontera es el dataset, no una columna enmascarada:
| Dataset | Contiene | Acceso |
|---|---|---|
solvento-data-prod.sendgrid / .bird (RAW) |
email, asunto, teléfono, contact_id en claro |
Restringido |
solvento-adv-analytics-prod.sendgrid / .bird (stg) |
lo mismo | Restringido |
solvento-adv-analytics-prod.messaging (marts) |
solo conteos y tasas | Se puede abrir |
RAW se mantiene fiel a la fuente a propósito: destruir el dato en la ingesta es irreversible y el tratamiento de PII se va a definir de forma transversal para todo el warehouse, no proceso por proceso. Hasta que esa definición exista, la protección es por permisos y el filtrado se hace en dbt.
Limitaciones que hay que conocer antes de usar la data
-
La fecha de envío de email es un estimado. La API de SendGrid no la devuelve: devuelve
last_event_time, el momento del último evento.send_datese deriva del primer evento observado, con sesgo hacia adelante acotado a ~24 h. En WhatsApp es exacta (createdDatetime). La columnasend_date_is_exactdemessaging_dailylo dice fila por fila. -
Todo el dominio usa el calendario
America/Mexico_City, no UTC.last_event_timeviene en UTC pero/v3/statsagrega en la zona de la cuenta (UTC−6), y como los blasts salen a la tarde-noche de México, tomar el día en UTC corría envíos enteros al día siguiente. Medido el 2026-09-07: la cobertura del sábado pasaba de 3,85 (UTC) a 1,13 (México). Ambos canales usan el mismo huso para que sumar envíos por día no mezcle calendarios. -
open_rateyread_rateson pisos, por motivos distintos. En email, Email Activity retiene 3 días: una apertura al cuarto día no existe para nosotros. En WhatsApp, solo se re-consultan las conversaciones activas: si el cliente lee y no responde, el cambio de estado puede no llegar a verse.runs_observed_minmide cuánto se llegó a mirar. Medido el 2026-09-07, el sesgo es fuerte en email (12,8% de apertura en mensajes vistos 1 vez vs. 30-35% en los vistos 4-5 veces) y despreciable en WhatsApp. -
Un 0% de apertura con
open_tracking_enabled = FALSEsignifica que no estábamos midiendo, no que nadie abrió. Un template con 0% de apertura Y 0% de clicks es indistinguible de uno sin tracking. La columnazero_engagementmarca el caso. -
Solo comparar entre sí días con
is_final = TRUE. Un día todavía abierto tiene los contadores a medio llenar y siempre se ve peor de lo que fue. -
El backfill de WhatsApp está incompleto y el sesgo crece hacia atrás. Tenemos 1.876 conversaciones; el extractor estima el total en ~9.600 (confirmar con
/preview?full=true). Bajamos el historial completo, pero solo de los contactos que estaban activos cuando corrió el backfill, así que los meses de 2024 solo tienen mensajes de clientes que siguen vivos hoy. Sirve para la tendencia de los que se quedaron, no para "cuánto mandábamos en 2024". -
stats_dailytiene ~39 días solo porque nunca se corriófull=true. SendGrid guarda 37 meses de agregados y el extractor ya tieneSTATS_FULL_START_DATE=2023-08-01: una corrida trae 3 años de volumen/aperturas/clicks a nivel cuenta (sin dimensión de template). Pendiente. -
La cobertura de email cae en los días de mayor volumen. Medido el 2026-09-07: 0,90–1,13 en días normales, pero 0,62 el viernes de 5.608 requests. Es el síntoma del tope de 1.000 por consulta cuando un blast grande satura la ventana. Se prueba subiendo
ACTIVITY_MAX_SPLIT_DEPTHen el extractor. Pendiente.
Alternativas descartadas
| Camino | Por qué no |
|---|---|
Export a CSV de Email Activity (/v3/messages/download) |
El download_uuid llega por correo a una casilla a la que no tenemos acceso. No es automatizable. |
| Airbyte + Meta / WhatsApp Business Platform | Requiere acceso a la cuenta de Meta que nunca se gestionó. Además conversation_analytics devuelve costo nulo en cuentas de Solution Partner. |
Endpoint /reporting de Bird |
Devuelve HTML con HTTP 200, no datos. |
| Webhooks (SendGrid Event Webhook / Bird) | Es el camino correcto a futuro para near-real-time y para no depender de ventanas de retención. No implementado. |
Consumo aguas abajo
- Oscar y Diego — volumen diario por template y canal, tasas de entrega, apertura y lectura, sobre
messaging_daily. - Analytics / Data — cualquier corte que el mart no cubra sale de las
stg, que están al grano del mensaje individual (destinatario, estado, timestamps). - Monitoreo — 5 log-based metrics en el dashboard "Data Processes":
messaging-extract-rows-total,messaging-extract-duration-seconds,messaging-extract-rate-limit-hits,messaging-extract-runs,messaging-bird-messages-without-template. Todas salen del log estructuradoevent="extract_run"que emite cada corrida (campos dejsonPayload, no regex sobre el texto).
Si falla 🛠️
Impacto: las métricas de envío quedan desactualizadas. No bloquea producto, cobranza ni facturación. En email el dato perdido no se recupera: si el extractor está caído más de 3 días, esos envíos no vuelven a estar disponibles nunca.
| Síntoma | Dónde mirar | Acción |
|---|---|---|
messaging_daily desactualizada |
Cloud Scheduler + logs de Cloud Run | Ver si corrió; usar GET /preview para dry-run sin gastar rate limit |
Métrica messaging-extract-runs sin puntos |
Dashboard "Data Processes" | Es la alerta que importa: la ausencia, no un valor alto. El servicio no corrió |
messaging-extract-rate-limit-hits alto |
Logs de sendgrid-extractor-prod |
429 de Email Activity (6 req/min). Revisar ACTIVITY_DELAY_SECS |
messaging-bird-messages-without-template alto |
Columnas template_path_found / content_keys en bird.messages |
Bird cambió la forma del bloque hsm; agregar la ruta nueva a RUTAS_NOMBRE_TEMPLATE |
Test accepted_values de status o direction en warn |
dbt test --select tag:messaging |
Bird agregó un estado que no clasificamos; se cae en is_pending y desaparecen entregas del embudo. Agregarlo al modelo |
Test test_sendgrid_coverage_vs_stats en warn |
messaging.email_coverage_daily |
Dejamos de cubrir algo. Mirar primero activity_templates: si bajó, el extractor no está consultando templates que sí enviaron |
Test test_bird_messages_vs_conversation_total en warn |
— | Nuestro conteo superó al de Bird: la dedup por message_id está rota. Verificar si unique sobre message_id también falló |
401/403 contra las APIs |
Logs de Cloud Run | Secrets sendgrid_api_key / messagebird_api_key en Secret Manager (creados a mano, no por Terraform) |
| Todo en cero para un template de email | stg_sendgrid_tracking_settings |
Si open/click están apagados, el número no significa "nadie abrió" |
Escalación: extractores / modelos dbt / infra → Martin · métricas y definición de negocio → Oscar y Diego.
⚠️ Antes de tocar el rate limit: las API keys son las mismas que puede estar usando el backend para enviar. Saturar el rate limit de lectura puede afectar envíos reales. No subir la frecuencia sin verificar con backend.
Enlaces
- 📊 Estado en vivo: dashboard "Data Processes" (Cloud Monitoring), sección "Messaging · Envíos"
- 📦 Repos:
sendgrid-extractor·bird-extractor· lógica dbt endbt-etls(models/advanced_analytics/{sendgrid,bird,messaging}/) · deploy enterraform-gcp-infrastructure - 🔗 Relacionado: Capa dbt · Cloud Schedulers · Dashboard Data Processes · ElevenLabs · Cobranza (mismo patrón
cloud-run-programado)
Última actualización: 2026-09-09 · Pendientes: backfill de stats_daily con full=true (3 años, 1 request); backfill completo de conversaciones de Bird; cobertura de email en días de alto volumen; webhooks. Si cambia el flujo, actualizar esta ficha en el mismo MR.