v1.0 gRPC + Proto3 Node.js server

CampaignMetricsService β€” gRPC

Servicio de mΓ©tricas de campaΓ±as en tiempo real para DataFlow AI β€” arquitectura de microservicios

Node.js server Python client Protocol Buffers v3 HTTP/2 transport streaming bidireccional 5 RPCs
SerializaciΓ³n
10Γ—
mΓ‘s pequeΓ±o que JSON
Parsing speed
5-10Γ—
mΓ‘s rΓ‘pido que REST+JSON
Lenguajes
12+
generaciΓ³n de stubs
RPCs definidos
5
unary + streaming
ℹ️

Skill aplicada: gRPC: Servicios RPC de Alto Rendimiento β€” Define el contrato en .proto, genera stubs con protoc, implementa handlers con patrones de errores, deadlines e interceptores. Adecuado para el pipeline de eventos de DataFlow AI donde la latencia baja y el streaming son crΓ­ticos.

Inventario de RPCs

Contrato de API
RPC Tipo Input β†’ Output DescripciΓ³n Estado
RecordMetric UNARY CampaignMetric β†’ RecordResponse Registra una mΓ©trica puntual desde un ad network βœ“ Listo
GetCampaignSummary UNARY SummaryRequest β†’ CampaignSummary Agrega KPIs consolidados de una campaΓ±a (ROAS, CTR) βœ“ Listo
BatchIngestMetrics CLIENT STREAM stream CampaignMetric β†’ IngestSummary Ingesta masiva desde conectores de Meta/Google/TikTok βœ“ Listo
StreamAlerts SERVER STREAM AlertSubscription β†’ stream Alert Push de alertas de anomalΓ­as al dashboard en tiempo real βœ“ Listo
LiveMetricsChat BIDI STREAM stream MetricQuery ↔ stream MetricResult Canal interactivo para drill-down y filtros en vivo ⚑ Beta

DefiniciΓ³n Protocol Buffers

proto/campaign_metrics.proto
proto/campaign_metrics.proto β€” protobuf
// DataFlow AI β€” CampaignMetricsService
// Contrato gRPC para el pipeline de mΓ©tricas de campaΓ±as
syntax = "proto3";
package dataflowai.metrics.v1;

import "google/protobuf/timestamp.proto";
import "google/protobuf/empty.proto";

// ─── Servicio principal ───────────────────────────────────────────────────
service CampaignMetricsService {
  // Unary: registra una mΓ©trica puntual
  rpc RecordMetric(CampaignMetric) returns (RecordResponse);

  // Unary: devuelve el resumen consolidado de una campaΓ±a
  rpc GetCampaignSummary(SummaryRequest) returns (CampaignSummary);

  // Client streaming: ingesta masiva desde ad networks
  rpc BatchIngestMetrics(stream CampaignMetric) returns (IngestSummary);

  // Server streaming: push de alertas de anomalΓ­as al dashboard
  rpc StreamAlerts(AlertSubscription) returns (stream Alert);

  // Bidireccional: drill-down interactivo en vivo
  rpc LiveMetricsChat(stream MetricQuery) returns (stream MetricResult);
}

// ─── Mensajes principales ─────────────────────────────────────────────────
message CampaignMetric {
  string   campaign_id  = 1;
  Channel  channel      = 2;  // META | GOOGLE | TIKTOK
  int64    impressions  = 3;
  int64    clicks       = 4;
  double   spend_eur   = 5;
  int32    conversions = 6;
  google.protobuf.Timestamp timestamp = 7;
}

message CampaignSummary {
  string         campaign_id   = 1;
  string         name          = 2;
  double         total_spend_eur = 3;
  double         roas          = 4;  // return on ad spend
  double         ctr_pct      = 5;
  int32          conversions  = 6;
  CampaignStatus status       = 7;
}

message Alert {
  string        campaign_id  = 1;
  AlertSeverity severity    = 2;
  string        message     = 3;
  string        metric_name = 4;
  double        value       = 5;
  double        threshold   = 6;
  google.protobuf.Timestamp fired_at = 7;
}

message MetricQuery  { string campaign_id = 1; Channel channel = 2; string metric = 3; }
message MetricResult { string campaign_id = 1; string metric = 2; double value = 3; string unit = 4; }
message SummaryRequest  { string campaign_id = 1; }
message RecordResponse  { string metric_id = 1; bool accepted = 2; }
message IngestSummary   { int32 ingested_count = 1; int32 failed_count = 2; repeated string errors = 3; }
message AlertSubscription { string workspace_id = 1; AlertSeverity min_severity = 2; }

// ─── Enums ────────────────────────────────────────────────────────────────
enum Channel {
  CHANNEL_UNSPECIFIED = 0;
  CHANNEL_META        = 1;
  CHANNEL_GOOGLE      = 2;
  CHANNEL_TIKTOK      = 3;
}
enum AlertSeverity {
  SEVERITY_UNSPECIFIED = 0;
  SEVERITY_LOW         = 1;
  SEVERITY_MEDIUM      = 2;
  SEVERITY_HIGH        = 3;
  SEVERITY_CRITICAL    = 4;
}
enum CampaignStatus {
  STATUS_UNSPECIFIED = 0;
  STATUS_ACTIVE      = 1;
  STATUS_PAUSED      = 2;
  STATUS_COMPLETED   = 3;
}

ImplementaciΓ³n del servidor

server.js β€” Node.js
server.js β€” JavaScript (Node.js Β· @grpc/grpc-js)
const grpc        = require('@grpc/grpc-js');
const protoLoader = require('@grpc/proto-loader');
const crypto      = require('crypto');
const reflection  = require('@grpc/reflection');

// ── Carga dinΓ‘mica del proto (sin compilar) ───────────────────────────────
const pkgDef = protoLoader.loadSync('proto/campaign_metrics.proto', {
  keepCase: true, longs: String, enums: String,
  defaults: true, oneofs: true,
});
const proto = grpc.loadPackageDefinition(pkgDef).dataflowai.metrics.v1;

// ── In-memory store (producciΓ³n β†’ PostgreSQL + Redis) ─────────────────────
const metricsStore  = new Map(); // campaign_id β†’ [CampaignMetric]
const alertListeners = new Map(); // workspace_id β†’ Set(call)

// ── Interceptor de logging ────────────────────────────────────────────────
function loggingInterceptor(methodDef, next) {
  return function(call, callback) {
    const start = Date.now();
    console.log(`β†’ [${methodDef.path}] llamada iniciada`);
    next(call, (...args) => {
      console.log(`← [${methodDef.path}] ${Date.now() - start}ms`);
      callback(...args);
    });
  };
}

// ── Helpers ───────────────────────────────────────────────────────────────
function computeSummary(campaignId) {
  const metrics = metricsStore.get(campaignId) || [];
  const totalSpend = metrics.reduce((s, m) => s + m.spend_eur, 0);
  const totalClicks = metrics.reduce((s, m) => s + m.clicks, 0);
  const totalImpr  = metrics.reduce((s, m) => s + m.impressions, 0);
  const totalConv  = metrics.reduce((s, m) => s + m.conversions, 0);
  const revenue    = totalConv * 47.5; // AOV estimado
  return {
    campaign_id:    campaignId,
    name:          `CampaΓ±a ${campaignId.slice(0,8)}`,
    total_spend_eur: totalSpend,
    roas:           totalSpend > 0 ? revenue / totalSpend : 0,
    ctr_pct:        totalImpr  > 0 ? (totalClicks / totalImpr) * 100 : 0,
    conversions:    totalConv,
    status:        'STATUS_ACTIVE',
  };
}

// ── Handlers ──────────────────────────────────────────────────────────────
const service = {

  // UNARY β€” guardar una mΓ©trica
  RecordMetric(call, callback) {
    const { campaign_id } = call.request;
    if (!campaign_id)
      return callback({ code: grpc.status.INVALID_ARGUMENT, message: 'campaign_id es obligatorio' });
    if (!metricsStore.has(campaign_id)) metricsStore.set(campaign_id, []);
    metricsStore.get(campaign_id).push(call.request);
    callback(null, { metric_id: crypto.randomUUID(), accepted: true });
  },

  // UNARY β€” resumen de campaΓ±a
  GetCampaignSummary(call, callback) {
    const { campaign_id } = call.request;
    if (!metricsStore.has(campaign_id))
      return callback({ code: grpc.status.NOT_FOUND, message: `CampaΓ±a ${campaign_id} no encontrada` });
    callback(null, computeSummary(campaign_id));
  },

  // CLIENT STREAMING β€” ingesta masiva
  BatchIngestMetrics(call, callback) {
    let ingested = 0; const errors = [];
    call.on('data', (metric) => {
      if (!metric.campaign_id) { errors.push('metric sin campaign_id omitida'); return; }
      if (!metricsStore.has(metric.campaign_id)) metricsStore.set(metric.campaign_id, []);
      metricsStore.get(metric.campaign_id).push(metric);
      ingested++;
    });
    call.on('end', () => callback(null, { ingested_count: ingested, failed_count: errors.length, errors }));
    call.on('error', (e) => console.error('BatchIngestMetrics error:', e));
  },

  // SERVER STREAMING β€” push alertas al dashboard
  StreamAlerts(call) {
    const { workspace_id, min_severity } = call.request;
    if (!alertListeners.has(workspace_id)) alertListeners.set(workspace_id, new Set());
    const listeners = alertListeners.get(workspace_id);
    listeners.add(call);
    // SimulaciΓ³n de detecciΓ³n de anomalΓ­a (producciΓ³n β†’ ML anomaly detector)
    const timer = setInterval(() => {
      call.write({
        campaign_id: 'camp_summer_2025', severity: 'SEVERITY_HIGH',
        message: 'CTR caΓ­do 40% en TikTok β€” posible fatiga creativa',
        metric_name: 'ctr_pct', value: 0.8, threshold: 1.5,
        fired_at: { seconds: Math.floor(Date.now() / 1000) },
      });
    }, 10000);
    call.on('cancelled', () => { clearInterval(timer); listeners.delete(call); });
  },

  // BIDIRECCIONAL β€” drill-down interactivo
  LiveMetricsChat(call) {
    call.on('data', ({ campaign_id, metric }) => {
      const summary = computeSummary(campaign_id);
      const value   = summary[metric] ?? 0;
      call.write({ campaign_id, metric, value, unit: unitOf(metric) });
    });
    call.on('end', () => call.end());
  },
};

// ── Arranque del servidor ─────────────────────────────────────────────────
const server = new grpc.Server({ interceptors: [loggingInterceptor] });
server.addService(proto.CampaignMetricsService.service, service);
reflection.addToServer(server); // habilita grpcurl en desarrollo
server.bindAsync('0.0.0.0:50055', grpc.ServerCredentials.createInsecure(), (err, port) => {
  if (err) { console.error('Error arrancando servidor:', err); return; }
  console.log(`βœ“ CampaignMetricsService escuchando en 0.0.0.0:${port}`);
});

Cliente Python β€” Pipeline de ingesta

ingest_pipeline.py β€” Python
ingest_pipeline.py β€” Python 3 (grpcio)
"""DataFlow AI β€” Pipeline de ingesta masiva de mΓ©tricas via gRPC client streaming."""
import grpc, csv, datetime
from google.protobuf import timestamp_pb2
import campaign_metrics_pb2 as pb
import campaign_metrics_pb2_grpc as stub

CHANNEL_MAP = { 'META': pb.CHANNEL_META, 'GOOGLE': pb.CHANNEL_GOOGLE, 'TIKTOK': pb.CHANNEL_TIKTOK }

def parse_csv_row(row):
    ts = timestamp_pb2.Timestamp()
    ts.FromDatetime(datetime.datetime.fromisoformat(row['timestamp']))
    return pb.CampaignMetric(
        campaign_id  = row['campaign_id'],
        channel      = CHANNEL_MAP.get(row['channel'], pb.CHANNEL_UNSPECIFIED),
        impressions  = int(row['impressions']),
        clicks       = int(row['clicks']),
        spend_eur   = float(row['spend_eur']),
        conversions = int(row['conversions']),
        timestamp    = ts,
    )

def metric_generator(csv_path):
    with open(csv_path) as f:
        for row in csv.DictReader(f):
            yield parse_csv_row(row)

def run_batch_ingest(csv_path: str):
    # Deadline de 60s para batch grandes
    with grpc.insecure_channel('localhost:50055') as channel:
        client   = stub.CampaignMetricsServiceStub(channel)
        response = client.BatchIngestMetrics(
            metric_generator(csv_path),
            timeout=60,
        )
        print(f"βœ“ Ingestadas: {response.ingested_count} | Fallidas: {response.failed_count}")
        if response.errors:
            print("Errores:", response.errors)

if __name__ == '__main__':
    run_batch_ingest('data/meta_export_2025-06-14.csv')
    # Ejemplo CSV esperado:
    # campaign_id,channel,impressions,clicks,spend_eur,conversions,timestamp
    # camp_summer_2025,META,150000,2100,840.50,38,2025-06-14T08:00:00
    # camp_summer_2025,TIKTOK,95000,760,310.00,12,2025-06-14T08:00:00
    # camp_brandawareness,GOOGLE,200000,4500,1200.00,55,2025-06-14T09:00:00

Testing con grpcurl

CLI testing β€” sin cΓ³digo
test_commands.sh β€” shell
# Listar servicios disponibles (reflection activa)
grpcurl -plaintext localhost:50055 list
# β†’ dataflowai.metrics.v1.CampaignMetricsService

# Inspeccionar RPC
grpcurl -plaintext localhost:50055 describe dataflowai.metrics.v1.CampaignMetricsService

# Registrar una mΓ©trica (unary)
grpcurl -plaintext -d '{
  "campaign_id": "camp_summer_2025",
  "channel": "CHANNEL_META",
  "impressions": 150000,
  "clicks": 2100,
  "spend_eur": 840.50,
  "conversions": 38
}' localhost:50055 dataflowai.metrics.v1.CampaignMetricsService/RecordMetric
# β†’ { "metric_id": "3f7a1c2e-...", "accepted": true }

# Consultar resumen de campaΓ±a
grpcurl -plaintext -d '{"campaign_id": "camp_summer_2025"}' \
  localhost:50055 dataflowai.metrics.v1.CampaignMetricsService/GetCampaignSummary
# β†’ { "campaign_id": "camp_summer_2025", "total_spend_eur": 840.5,
#     "roas": 2.15, "ctr_pct": 1.40, "conversions": 38, "status": "STATUS_ACTIVE" }

# Suscribirse a alertas en tiempo real (server streaming)
grpcurl -plaintext -d '{
  "workspace_id": "ws_dataflowai_001",
  "min_severity": "SEVERITY_MEDIUM"
}' localhost:50055 dataflowai.metrics.v1.CampaignMetricsService/StreamAlerts

Rendimiento estimado vs REST/JSON

Payload size (batch 1k mΓ©tricas)
~18 KB
REST/JSON equivalente
~135 KB
Parse time gRPC/protobuf
0.4 ms
Parse time REST/JSON
2.8 ms
Latencia p99 unary RPC
1.2 ms
Latencia p99 REST
8.5 ms
DataFlow AI Β· CampaignMetricsService v1.0 Β· gRPC skill demo β€” CULTIVA IA
@grpc/grpc-js ^1.10 grpcio ^1.63 proto3