Observable
Se sabe si corrió, cuánto tardó y si falló. Todo job genera un job_run con métricas básicas y traza estructurada.
Cómo FARO Connect se mantiene funcionando de forma controlada, monitoreada y recuperable. 13 jobs canónicos, 8 tablas del schema ops, worker idempotente, error taxonomy, health checks, 5 runbooks operativos, backups verificables, incident management y feature flags por release.
FARO-OPS-001 define cómo FARO Connect se mantiene funcionando de forma controlada, monitoreada y recuperable. Cubre jobs programados, colas, workers, reintentos, idempotencia, errores, dead letter queue, logs estructurados, métricas, traces, health checks, backups, migraciones, alertas técnicas, runbooks e incidentes.
FARO Connect tiene muchos procesos asincrónicos corriendo en paralelo por empresa: importar datos, normalizar, validar calidad, calcular KPIs, detectar tensiones, crear acciones, evaluar workflow, recalcular Score, enviar notificaciones, generar reportes, limpiar cache IA, aplicar retención y monitorear integraciones. Si esos procesos no están gobernados, el sistema se vuelve inestable.
El problema no es que un job falle. El problema es que falle y nadie se entere. O peor: falla, se reintenta mal, duplica acciones, manda alertas repetidas, cambia Score dos veces y después todos miran al programador como si fuera un chamán. FARO-OPS-001 evita ese circo con un set acotado de primitivas operativas que se aplican a todos los jobs por igual.
El alcance MVP incluye: 13 jobs canónicos (códigos JOB-XXX-NNN), schema ops con 8 tablas (jobs, job_runs, job_locks, job_schedules, error_events, health_snapshots, dead_letter_queue, alerts), worker idempotente en TypeScript, scheduler básico con cron por job, error taxonomy de 13 categorías con política de backoff exponencial, health checks (simple + deep), observabilidad mínima (logs estructurados + métricas Prometheus + traces con request_id), 5 runbooks operativos expandibles, backups verificables con restore drills, incident management SEV-1 a SEV-4 con postmortems y feature flags por release.
Lo que queda fuera del MVP: Kubernetes complejo, auto-scaling avanzado, multi-región, DRP completo con RTO/RPO contractual, SIEM corporativo, SRE dedicado, chaos engineering, blue/green avanzado y service mesh. Esto se aborda en releases posteriores cuando el volumen lo justifique.
Tenant ejemplo en todas las queries del documento: Empresa Demo Cuyo S.A.. Timezone por defecto: America/Argentina/Mendoza. Toda timestamp en la base se guarda en timestamptz y se presenta en la zona de la empresa.
Todo proceso automático debe ser observable, reintentable, idempotente y auditable. Las 7 reglas que siguen no son adornos: son lo que separa un sistema operable de un sistema que solo funciona en el demo.
Se sabe si corrió, cuánto tardó y si falló. Todo job genera un job_run con métricas básicas y traza estructurada.
Puede volver a ejecutarse sin intervención manual destructiva. Backoff exponencial con tope. Si agota, va a DLQ.
Ejecutarlo dos veces no duplica efectos. Todo job que escriba datos tiene dedupe_key o constraint equivalente.
Cada falla esperada tiene un procedimiento documentado. La improvisación también es una metodología, solo que mala.
Migraciones reversibles cuando es posible. Feature flags para apagar funcionalidad rota sin redeploy.
Deja registro técnico y funcional. Error events, audit log y health snapshots quedan en la base 365 días.
Respeta company_id, permisos y RLS. El worker setea contexto antes de cada job. Sin contexto, no corre.
11 componentes que componen la operación. Cada uno con responsabilidad acotada y observabilidad propia.
| Componente | Responsabilidad | Tecnología sugerida |
|---|---|---|
| Web App | UI, APIs, SSR para tableros operativos | Next.js / React |
| API Backend | Endpoints, validaciones, comandos, withFaroSecurity | Node.js / TypeScript |
| PostgreSQL | Datos, RLS, snapshots, auditoría, schema ops | PostgreSQL 15+ |
| Redis | Queue, locks temporales, cache de sesión | Redis 7 |
| Workers | Procesan jobs asincrónicos con runJob | BullMQ o Celery/RQ |
| Scheduler | Dispara jobs recurrentes según cron por job | node-cron o equivalente |
| Storage privado | Evidencias, PDFs, archivos, backups | S3-compatible |
| AI Gateway | Explicaciones IA controladas con budget | Anthropic / OpenAI compat |
| Observability stack | Logs, métricas, traces, alertas técnicas | OpenTelemetry + Grafana |
| Email provider | Notificaciones y reportes ejecutivos | Resend / SES |
| Error tracker | Errores frontend/backend con stack | Sentry |
Flujo operativo canónico. Evento o schedule → crea job_run → entra en queue → worker toma job → setea company_id y contexto → ejecuta proceso → guarda resultado → genera logs y métricas → si falla, reintenta con backoff → si agota reintentos, va a dead_letter → si es crítico, dispara alerta técnica. Sin pasos optativos, sin saltos.
13 jobs cubren el pipeline completo: ingesta, validación, normalización, KPIs, tensiones, workflow, score, notificaciones, reportes, IA, retención, backups y health snapshot. Cada uno con código JOB-XXX-NNN, frecuencia, timeout, max_attempts y dedupe_key. Las marcadas EVENT corren además on-demand al ocurrir su evento disparador.
Procesa archivos subidos a raw_imports según plantilla FARO-PL y los baja a staging.
file_hash + source_idAplica las 120 reglas de calidad-datos.html sobre staging. Marca registros con quality_issue.
company_id + periodTransforma staging en tablas normalizadas por área. Resuelve dimensiones (cliente, vendedor, sucursal, producto).
company_id + period + tableCalcula los KPIs del MVP por período definido (day/week) según biblioteca de 400 KPIs.
company_id + kpi_code + periodCorre el motor evaluador sobre las reglas YAML activas. Crea tensiones nuevas o actualiza existentes.
company_id + rule_set_version + periodAvanza acciones según workflow-escalamiento-mvp.html. Marca vencimientos, escala niveles.
company_id + tick_atRecalcula Score por empresa y dimensión. Snapshot en faro.score_snapshots.
company_id + dimension + periodProcesa la cola de notificaciones pendientes. Llama al email provider, registra provider_response.
notification_idGenera reporte ejecutivo semanal por empresa (HTML + PDF). Lo guarda en storage privado.
company_id + report_code + periodVence cache de respuestas IA según TTL. Aplica retención sobre ai.requests según política.
dayAplica política de retención: 90-180d job_runs ok, 365d failed, 5 años audit. Archiva al storage frío.
retention_policy + dayVerifica último backup exitoso, su edad y el último restore test. Registra en ops.backup_checks.
resource_type + dayToma snapshot del estado del sistema. Calcula pending_jobs, failed_jobs_24h, dead_letter_jobs, error_rate_5m.
floor(now / 5min)opsLas 8 tablas que sostienen toda la operación técnica: catálogo de jobs, runs ejecutados, locks, schedules, errores, snapshots de salud, dead letter queue y alertas. Toda DDL es idempotente (CREATE TABLE IF NOT EXISTS), versionada y respeta company_id donde corresponde.
CREATE SCHEMA IF NOT EXISTS ops; COMMENT ON SCHEMA ops IS 'FARO Connect · operación técnica · jobs, errores, health, backups';
ops.jobs — catálogo de jobsDefine cada job MVP con su política de retry, timeout, cron y queue. Es la fuente única que consulta el scheduler.
CREATE TABLE IF NOT EXISTS ops.jobs ( job_id uuid PRIMARY KEY DEFAULT gen_random_uuid(), job_code text NOT NULL UNIQUE, name text NOT NULL, description text NOT NULL, job_type text NOT NULL CHECK ( job_type IN ('scheduled', 'event_driven', 'manual', 'batch', 'system') ), queue_name text NOT NULL DEFAULT 'default', default_priority integer NOT NULL DEFAULT 5, max_attempts integer NOT NULL DEFAULT 3, timeout_seconds integer NOT NULL DEFAULT 300, retry_strategy text NOT NULL DEFAULT 'exponential' CHECK ( retry_strategy IN ('none', 'fixed', 'exponential') ), retry_delay_seconds integer NOT NULL DEFAULT 60, is_company_scoped boolean NOT NULL DEFAULT true, is_active boolean NOT NULL DEFAULT true, schedule_cron text NULL, timezone text NOT NULL DEFAULT 'America/Argentina/Mendoza', created_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now() ); CREATE INDEX IF NOT EXISTS idx_ops_jobs_active ON ops.jobs(is_active, job_type);
ops.job_runs — ejecucionesCada ejecución de un job queda registrada acá con su payload, resultado, attempt, status y métricas. Es la tabla más activa del schema.
CREATE TABLE IF NOT EXISTS ops.job_runs ( job_run_id uuid PRIMARY KEY DEFAULT gen_random_uuid(), job_id uuid NULL, job_code text NOT NULL, company_id uuid NULL, queue_name text NOT NULL DEFAULT 'default', status text NOT NULL DEFAULT 'queued' CHECK ( status IN ('queued', 'running', 'success', 'failed', 'retrying', 'cancelled', 'dead_letter') ), priority integer NOT NULL DEFAULT 5, attempt integer NOT NULL DEFAULT 0, max_attempts integer NOT NULL DEFAULT 3, dedupe_key text NULL, payload jsonb NOT NULL DEFAULT '{}'::jsonb, result jsonb NOT NULL DEFAULT '{}'::jsonb, error_code text NULL, error_message text NULL, error_stack text NULL, started_at timestamptz NULL, finished_at timestamptz NULL, duration_ms integer NULL, scheduled_at timestamptz NOT NULL DEFAULT now(), next_retry_at timestamptz NULL, locked_by text NULL, locked_at timestamptz NULL, request_id text NULL, created_by uuid NULL, created_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now() ); CREATE INDEX IF NOT EXISTS idx_job_runs_status_schedule ON ops.job_runs(status, scheduled_at); CREATE INDEX IF NOT EXISTS idx_job_runs_company_time ON ops.job_runs(company_id, created_at DESC); CREATE INDEX IF NOT EXISTS idx_job_runs_code_time ON ops.job_runs(job_code, created_at DESC); CREATE UNIQUE INDEX IF NOT EXISTS uq_job_runs_active_dedupe ON ops.job_runs(company_id, job_code, dedupe_key) WHERE dedupe_key IS NOT NULL AND status IN ('queued', 'running', 'retrying');
ops.job_locks — locks distribuidosEvita que dos workers ejecuten el mismo job crítico al mismo tiempo. Por ejemplo, dos recálculos de Score de Empresa Demo Cuyo S.A. en paralelo.
CREATE TABLE IF NOT EXISTS ops.job_locks ( job_lock_id uuid PRIMARY KEY DEFAULT gen_random_uuid(), lock_key text NOT NULL UNIQUE, company_id uuid NULL, locked_by text NOT NULL, locked_at timestamptz NOT NULL DEFAULT now(), expires_at timestamptz NOT NULL, metadata jsonb NOT NULL DEFAULT '{}'::jsonb ); CREATE INDEX IF NOT EXISTS idx_job_locks_expires ON ops.job_locks(expires_at);
ops.job_schedules — cron por jobPermite definir múltiples schedules por job (por ejemplo, JOB-KPI-001 cada 30 min de 7 a 22 y cada 60 min de 22 a 7). Respeta timezone por empresa.
CREATE TABLE IF NOT EXISTS ops.job_schedules ( job_schedule_id uuid PRIMARY KEY DEFAULT gen_random_uuid(), job_id uuid NOT NULL REFERENCES ops.jobs(job_id) ON DELETE CASCADE, company_id uuid NULL, cron_expression text NOT NULL, timezone text NOT NULL DEFAULT 'America/Argentina/Mendoza', is_active boolean NOT NULL DEFAULT true, last_triggered_at timestamptz NULL, next_trigger_at timestamptz NULL, payload_template jsonb NOT NULL DEFAULT '{}'::jsonb, created_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now() ); CREATE INDEX IF NOT EXISTS idx_job_schedules_next ON ops.job_schedules(is_active, next_trigger_at);
ops.error_events — taxonomía de erroresToda excepción técnica genera un error event canónico. Se usa para alertas, dashboards y postmortems.
CREATE TABLE IF NOT EXISTS ops.error_events ( error_event_id uuid PRIMARY KEY DEFAULT gen_random_uuid(), company_id uuid NULL, user_id uuid NULL, source text NOT NULL CHECK ( source IN ('frontend', 'api', 'worker', 'scheduler', 'database', 'integration', 'ai_gateway', 'storage', 'email_provider') ), severity text NOT NULL CHECK ( severity IN ('low', 'medium', 'high', 'critical') ), error_code text NOT NULL, error_message text NOT NULL, job_run_id uuid NULL, request_id text NULL, entity_type text NULL, entity_id text NULL, stack_trace text NULL, context jsonb NOT NULL DEFAULT '{}'::jsonb, is_resolved boolean NOT NULL DEFAULT false, resolved_by uuid NULL, resolved_at timestamptz NULL, resolution_note text NULL, created_at timestamptz NOT NULL DEFAULT now() ); CREATE INDEX IF NOT EXISTS idx_error_events_company_time ON ops.error_events(company_id, created_at DESC); CREATE INDEX IF NOT EXISTS idx_error_events_source_severity ON ops.error_events(source, severity, created_at DESC); CREATE INDEX IF NOT EXISTS idx_error_events_unresolved ON ops.error_events(severity, created_at DESC) WHERE is_resolved = false;
ops.health_snapshots — estado del sistemaCada 5 min JOB-HEALTH-001 deja un snapshot. Sirve para graficar tendencias y disparar alertas técnicas.
CREATE TABLE IF NOT EXISTS ops.health_snapshots ( health_snapshot_id uuid PRIMARY KEY DEFAULT gen_random_uuid(), status text NOT NULL CHECK ( status IN ('healthy', 'degraded', 'unhealthy') ), db_status text NOT NULL, redis_status text NULL, storage_status text NULL, email_status text NULL, ai_status text NULL, pending_jobs integer NOT NULL DEFAULT 0, failed_jobs_24h integer NOT NULL DEFAULT 0, dead_letter_jobs integer NOT NULL DEFAULT 0, avg_job_duration_ms numeric(12,2) NULL, p95_api_latency_ms numeric(12,2) NULL, error_rate_5m numeric(12,4) NULL, details jsonb NOT NULL DEFAULT '{}'::jsonb, created_at timestamptz NOT NULL DEFAULT now() ); CREATE INDEX IF NOT EXISTS idx_health_snapshots_time ON ops.health_snapshots(created_at DESC); CREATE INDEX IF NOT EXISTS idx_health_snapshots_status ON ops.health_snapshots(status, created_at DESC);
ops.dead_letter_queue — DLQ explícitaMaterializa los job_runs que agotaron reintentos. Permite triage, retry manual y resolución sin tocar la tabla caliente.
CREATE TABLE IF NOT EXISTS ops.dead_letter_queue ( dlq_id uuid PRIMARY KEY DEFAULT gen_random_uuid(), job_run_id uuid NOT NULL REFERENCES ops.job_runs(job_run_id) ON DELETE CASCADE, job_code text NOT NULL, company_id uuid NULL, payload jsonb NOT NULL DEFAULT '{}'::jsonb, error_code text NOT NULL, error_message text NOT NULL, last_stack text NULL, attempts integer NOT NULL, first_failed_at timestamptz NOT NULL, last_failed_at timestamptz NOT NULL DEFAULT now(), status text NOT NULL DEFAULT 'pending' CHECK ( status IN ('pending', 'investigating', 'requeued', 'resolved', 'cancelled') ), resolution_note text NULL, resolved_by uuid NULL, resolved_at timestamptz NULL, created_at timestamptz NOT NULL DEFAULT now() ); CREATE INDEX IF NOT EXISTS idx_dlq_status_time ON ops.dead_letter_queue(status, last_failed_at DESC); CREATE INDEX IF NOT EXISTS idx_dlq_company ON ops.dead_letter_queue(company_id, status);
ops.alerts — alertas técnicas disparadasToda alerta técnica enviada queda persistida. Permite reconciliar canales (email, Slack), evitar duplicados y armar postmortems.
CREATE TABLE IF NOT EXISTS ops.alerts ( alert_id uuid PRIMARY KEY DEFAULT gen_random_uuid(), alert_code text NOT NULL, company_id uuid NULL, severity text NOT NULL CHECK ( severity IN ('low', 'medium', 'high', 'critical') ), title text NOT NULL, description text NOT NULL, source_table text NULL, source_id uuid NULL, dedupe_key text NULL, fingerprint text NULL, channels text[] NOT NULL DEFAULT ARRAY[]::text[], recipients text[] NOT NULL DEFAULT ARRAY[]::text[], status text NOT NULL DEFAULT 'open' CHECK ( status IN ('open', 'acknowledged', 'silenced', 'resolved') ), triggered_at timestamptz NOT NULL DEFAULT now(), acknowledged_at timestamptz NULL, resolved_at timestamptz NULL, context jsonb NOT NULL DEFAULT '{}'::jsonb ); CREATE INDEX IF NOT EXISTS idx_alerts_status_time ON ops.alerts(status, triggered_at DESC); CREATE UNIQUE INDEX IF NOT EXISTS uq_alerts_open_dedupe ON ops.alerts(alert_code, dedupe_key) WHERE status IN ('open', 'acknowledged') AND dedupe_key IS NOT NULL;
El schema cierra el contrato técnico. Ver modelo-sql.html para el DDL consolidado del MVP completo (incluye faro.*, ai.*, audit.* y este ops.*). Toda migración es idempotente y versionada.
Cuatro piezas en TypeScript que sostienen toda la operación. Cada handler es independiente, idempotente y testeable. El runner setea contexto de empresa antes de invocar el handler. El failure handler decide si reintenta o promueve a dead letter.
El registry mapea cada job_code a su handler. Agregar un job nuevo es agregar una línea acá más una migración seed.
export type JobPayload = { companyId?: string; triggeredBy?: string | null; requestId?: string; payload: Record<string, unknown>; }; export type JobHandlerContext = { jobRunId: string; jobCode: string; companyId?: string | null; requestId: string; attempt: number; }; export type JobHandler = ( payload: JobPayload, ctx: JobHandlerContext ) => Promise<Record<string, unknown> | void>;
import { ingestPendingFiles } from "@/src/ingest/ingestPendingFiles"; import { validateDataQuality } from "@/src/quality/validateDataQuality"; import { normalizeStaging } from "@/src/etl/normalizeStaging"; import { computeKpis } from "@/src/kpi/computeKpis"; import { evaluateRules } from "@/src/engine/evaluateRules"; import { runWorkflowChecker } from "@/src/workflow/workflowChecker"; import { recalculateScoreJob } from "@/src/score/recalculateScoreJob"; import { dispatchPendingNotifications } from "@/src/notifications/dispatchPendingNotifications"; import { generateWeeklyReportsJob } from "@/src/reports/generateWeeklyReportsJob"; import { cleanAiCache } from "@/src/ai/cleanAiCache"; import { applyRetention } from "@/src/ops/applyRetention"; import { checkBackups } from "@/src/ops/checkBackups"; import { createHealthSnapshot } from "@/src/ops/createHealthSnapshot"; import type { JobHandler } from "./ops.types"; export const jobRegistry: Record<string, JobHandler> = { "JOB-ING-001": ingestPendingFiles, "JOB-DQ-001": validateDataQuality, "JOB-NORM-001": normalizeStaging, "JOB-KPI-001": computeKpis, "JOB-RULE-001": evaluateRules, "JOB-WF-001": runWorkflowChecker, "JOB-SCORE-001": recalculateScoreJob, "JOB-NOTIF-001": dispatchPendingNotifications, "JOB-REPORT-001": generateWeeklyReportsJob, "JOB-AI-001": cleanAiCache, "JOB-RET-001": applyRetention, "JOB-BACKUP-001": checkBackups, "JOB-HEALTH-001": createHealthSnapshot };
runJobEl worker toma un job_run en queued o retrying, lo marca como running con lock, ejecuta el handler y registra el resultado. Si falla, delega a handleJobFailure.
import { jobRegistry } from "./jobRegistry"; import { handleJobFailure } from "./handleJobFailure"; export async function runJob(params: { client: any; jobRunId: string; workerId: string; }) { const started = Date.now(); const jobResult = await params.client.query( ` UPDATE ops.job_runs SET status = 'running', locked_by = $2, locked_at = now(), started_at = now(), attempt = attempt + 1, updated_at = now() WHERE job_run_id = $1 AND status IN ('queued','retrying') RETURNING * `, [params.jobRunId, params.workerId] ); const job = jobResult.rows[0]; if (!job) return null; const handler = jobRegistry[job.job_code]; if (!handler) { throw new Error(`JOB_HANDLER_NOT_FOUND: ${job.job_code}`); } try { // Setea contexto de empresa para RLS if (job.company_id) { await params.client.query( `SELECT set_config('app.company_id', $1, true)`, [job.company_id] ); } const result = await handler( { companyId: job.company_id, requestId: job.request_id, payload: job.payload }, { jobRunId: job.job_run_id, jobCode: job.job_code, companyId: job.company_id, requestId: job.request_id, attempt: job.attempt } ); await params.client.query( ` UPDATE ops.job_runs SET status = 'success', result = $2::jsonb, finished_at = now(), duration_ms = $3, updated_at = now() WHERE job_run_id = $1 `, [job.job_run_id, JSON.stringify(result ?? {}), Date.now() - started] ); return result; } catch (error: any) { await handleJobFailure({ client: params.client, job, error, durationMs: Date.now() - started }); return null; } }
Decide entre retrying y dead_letter según attempt < max_attempts. Si pasa a DLQ, registra error event de severidad alta y dispara alerta si el job era P1.
export async function handleJobFailure(params: { client: any; job: any; error: any; durationMs: number; }) { const nextAttempt = Number(params.job.attempt ?? 0); const maxAttempts = Number(params.job.max_attempts ?? 3); const errorCode = params.error?.code ?? "JOB_FAILED"; const errorMessage = params.error?.message ?? "Unknown job error"; const stack = params.error?.stack ?? null; const shouldRetry = nextAttempt < maxAttempts; await params.client.query( ` UPDATE ops.job_runs SET status = $2, error_code = $3, error_message = $4, error_stack = $5, finished_at = CASE WHEN $2 = 'dead_letter' THEN now() ELSE finished_at END, next_retry_at = CASE WHEN $2 = 'retrying' THEN now() + ($6::int || ' seconds')::interval ELSE NULL END, duration_ms = $7, updated_at = now() WHERE job_run_id = $1 `, [ params.job.job_run_id, shouldRetry ? "retrying" : "dead_letter", errorCode, errorMessage, stack, calculateRetryDelaySeconds(nextAttempt), params.durationMs ] ); await params.client.query( ` INSERT INTO ops.error_events ( company_id, source, severity, error_code, error_message, job_run_id, stack_trace, context ) VALUES ($1, 'worker', $2, $3, $4, $5, $6, $7::jsonb) `, [ params.job.company_id, shouldRetry ? "medium" : "high", errorCode, errorMessage, params.job.job_run_id, stack, JSON.stringify({ job_code: params.job.job_code, attempt: nextAttempt, max_attempts: maxAttempts }) ] ); // Si fue DLQ, materializa fila explícita if (!shouldRetry) { await params.client.query( ` INSERT INTO ops.dead_letter_queue ( job_run_id, job_code, company_id, payload, error_code, error_message, last_stack, attempts, first_failed_at ) VALUES ($1, $2, $3, $4::jsonb, $5, $6, $7, $8, now()) `, [ params.job.job_run_id, params.job.job_code, params.job.company_id, JSON.stringify(params.job.payload ?? {}), errorCode, errorMessage, stack, nextAttempt ] ); } } function calculateRetryDelaySeconds(attempt: number) { // Backoff: 60s, 5m, 15m, 1h const delays = [60, 300, 900, 3600]; return delays[Math.min(attempt - 1, delays.length - 1)]; }
El scheduler lee ops.job_schedules activos, evalúa el cron, crea un job_run idempotente con dedupe_key y actualiza next_trigger_at. Respeta timezone por empresa.
export async function scheduleJobRun(params: { client: any; jobCode: string; companyId?: string | null; payload?: Record<string, unknown>; dedupeKey?: string | null; scheduledAt?: string | null; }) { const job = await params.client.query( ` SELECT * FROM ops.jobs WHERE job_code = $1 AND is_active = true LIMIT 1 `, [params.jobCode] ); if (!job.rows[0]) { throw new Error(`JOB_NOT_FOUND: ${params.jobCode}`); } const j = job.rows[0]; const result = await params.client.query( ` INSERT INTO ops.job_runs ( job_id, job_code, company_id, queue_name, status, priority, max_attempts, dedupe_key, payload, scheduled_at ) VALUES ( $1, $2, $3, $4, 'queued', $5, $6, $7, $8::jsonb, COALESCE($9::timestamptz, now()) ) ON CONFLICT DO NOTHING RETURNING job_run_id `, [ j.job_id, j.job_code, params.companyId ?? null, j.queue_name, j.default_priority, j.max_attempts, params.dedupeKey ?? null, JSON.stringify(params.payload ?? {}), params.scheduledAt ?? null ] ); return result.rows[0]?.job_run_id ?? null; }
import { describe, expect, it } from "vitest"; import { handleJobFailure } from "../src/ops/handleJobFailure"; import { withTestDbContext } from "../src/helpers/dbTestContext"; describe("OPS job failure", () => { it("moves job to retrying when attempts remain", async () => { await withTestDbContext({}, async (client) => { const insert = await client.query(` INSERT INTO ops.job_runs (job_code, status, attempt, max_attempts, payload) VALUES ('JOB-TEST-001', 'running', 1, 3, '{}'::jsonb) RETURNING * `); await handleJobFailure({ client, job: insert.rows[0], error: new Error("Test failure"), durationMs: 100 }); const result = await client.query( `SELECT status FROM ops.job_runs WHERE job_run_id = $1`, [insert.rows[0].job_run_id] ); expect(result.rows[0].status).toBe("retrying"); }); }); });
FARO usa una taxonomía cerrada de errores. Cada categoría tiene política de retry conocida. Cuando un job agota intentos, va a la dead letter queue, donde puede ser triageado, requeueado o cerrado manualmente desde /faro/ops/dead-letter.
No autenticado. Usuario sin sesión o token vencido. Retornar 401 y limpiar contexto.
No retrySin permiso. Usuario autenticado sin el scope necesario. Retornar 403.
No retryPayload inválido. Esquema, tipos o reglas de negocio no cumplidas.
No retryDatos incompletos o inconsistentes. Marca quality_issue, requiere intervención.
API externa falló (Tango, Calipso, banco). Suele ser transitorio.
Exponencial · 5 maxNo subió/bajó archivo del object storage. Suele ser transitorio.
Exponencial · 3 maxProvider IA falló (timeout, rate limit, content policy).
1 retry + fallbackQuery inválida o violación de constraint. Suele requerir fix de código.
No retry · investigarWorker falló sin clasificar. Categoría por defecto cuando no se conoce el tipo.
Exponencial · 3 maxOperación excedió timeout_seconds del job.
Límite del provider excedido. Esperar y reintentar según ventana.
Delay del providerConfiguración faltante (secret, env var, feature flag). Requiere intervención de plataforma.
No retry · alertarExcepción no clasificada. Va al tracker con stack completo para clasificación posterior.
1 retry · luego DLQEl usuario recibe claridad. El sistema guarda detalle. Nadie recibe stack trace en pantalla.
{
"error": {
"code": "PERMISSION_DENIED",
"category": "PERMISSION_ERROR",
"message": "No tenés permiso para realizar esta acción.",
"request_id": "req_a8f3c1",
"severity": "medium"
}
}
| Intento | Delay | Aplica a | Resultado si falla |
|---|---|---|---|
1 | 60 s | Default todos los jobs retryable | retrying |
2 | 5 min | Default todos los jobs retryable | retrying |
3 | 15 min | Default · DLQ si no es JOB-NOTIF | dead_letter |
4 | 1 h | Solo JOB-NOTIF y JOB-ING (max_attempts = 5) | retrying |
5 | DLQ | Solo JOB-NOTIF y JOB-ING | dead_letter |
ops.dead_letter_queue con payload, error_code, último stack y attempts.alert_code = ops.dlq.critical) deduped por job_code + company_id + day.queued, attempt 0), cancelar (marca cancelled) o resolver con nota.action = ops.dlq.{retry|cancel|resolve} y risk_level = high./health/live y /health/ready + status snapshotsTres niveles de health check: live (¿el proceso responde?), ready (¿puede atender requests?) y deep (¿están sanas todas las dependencias?). Cada 5 min JOB-HEALTH-001 deja snapshot en ops.health_snapshots para histórico y alertas.
DB, Redis, storage, email, IA responden. Sin DLQ acumulada, sin error rate elevado. Sistema funcionando normal.
Una dependencia no crítica caída (ej. email provider, IA). El sistema sigue procesando jobs core, con funcionalidad reducida.
DB caída, error rate > 10% o múltiples DLQ críticos. Dispara alerta SEV-1, abre incidente automático.
GET /health/liveLiveness probe para Kubernetes/Fly/Render. Solo responde 200 si el proceso está vivo. No verifica dependencias. Pensado para timeouts cortos (1-2 s).
HTTP/1.1 200 OK Content-Type: application/json { "status": "alive", "service": "faro-api", "version": "1.0.0", "timestamp": "2026-05-31T12:00:00-03:00" }
GET /health/readyReadiness probe. Verifica conexión a DB y Redis. Si falla, el orquestador deja de mandar tráfico hasta que vuelva.
HTTP/1.1 200 OK Content-Type: application/json { "status": "ready", "checks": { "database": "healthy", "redis": "healthy" }, "timestamp": "2026-05-31T12:00:00-03:00" }
GET /api/health/deepHealth check profundo. Verifica DB, Redis, storage, email provider, AI provider y devuelve métricas operativas. Más caro, protegido con permiso ops:health:read.
HTTP/1.1 200 OK Content-Type: application/json { "status": "degraded", "checks": { "database": "healthy", "redis": "healthy", "storage": "healthy", "email_provider": "degraded", "ai_provider": "healthy" }, "jobs": { "pending": 12, "failed_24h": 2, "dead_letter": 1, "avg_duration_ms": 842, "queue_depth_max": 18 }, "metrics": { "p95_api_latency_ms": 245, "error_rate_5m": 0.012 }, "timestamp": "2026-05-31T12:00:00-03:00" }
Función pura que recibe los inputs y devuelve healthy | degraded | unhealthy. Tiene tests propios. Usada por /health/deep y JOB-HEALTH-001.
export type HealthInputs = { db: "healthy" | "degraded" | "unhealthy"; redis: "healthy" | "degraded" | "unhealthy"; storage: "healthy" | "degraded" | "unhealthy"; failedJobs24h: number; deadLetterJobs: number; errorRate5m: number; }; export function classifyHealthStatus( i: HealthInputs ): "healthy" | "degraded" | "unhealthy" { if (i.db === "unhealthy") return "unhealthy"; if (i.errorRate5m > 0.10) return "unhealthy"; if (i.deadLetterJobs >= 5) return "unhealthy"; if (i.redis !== "healthy") return "degraded"; if (i.storage !== "healthy") return "degraded"; if (i.failedJobs24h > 20) return "degraded"; if (i.deadLetterJobs >= 1) return "degraded"; if (i.errorRate5m > 0.05) return "degraded"; return "healthy"; }
Cuatro dimensiones a observar: logs (qué pasó), metrics (cuánto pasó), traces (por dónde pasó), events (qué significó para negocio). Toda request propaga request_id end-to-end. Toda métrica clave queda en Prometheus + visualizada en Grafana.
JSON por línea, con campos canónicos. El logger de aplicación inyecta request_id, company_id, user_id y module automáticamente desde el contexto.
{
"timestamp": "2026-05-31T12:00:00-03:00",
"level": "info",
"service": "faro-api",
"environment": "production",
"request_id": "req_a8f3c1",
"company_id": "d4e1...empresa-demo-cuyo",
"user_id": "7b2a...",
"module": "score",
"event": "score_recalculated",
"message": "FARO Score recalculated for Empresa Demo Cuyo S.A.",
"duration_ms": 842,
"metadata": { "score": 66, "delta": -8 }
}
5 niveles canónicos.
| Nivel | Uso | Ejemplo |
|---|---|---|
debug | Solo desarrollo / staging | Trace de query, dump de payload pesado |
info | Evento normal del negocio | score_recalculated, action_created |
warn | Situación anómala controlada | Reintento, fallback IA, rate limit cerca |
error | Fallo recuperable | Job que se manda a retry, integración caída |
fatal | Sistema comprometido | DB caída, config faltante crítica |
Endpoint /metrics protegido. 4 grupos de métricas: API, jobs, negocio-operativas, IA.
# HELP faro_api_request_count Total API requests # TYPE faro_api_request_count counter faro_api_request_count{method="GET",route="/api/v1/tensions",status="200"} 12483 # HELP faro_api_latency_ms API latency histogram # TYPE faro_api_latency_ms histogram faro_api_latency_ms_bucket{le="50"} 8421 faro_api_latency_ms_bucket{le="100"} 10987 faro_api_latency_ms_bucket{le="250"} 12104 # HELP faro_job_runs_total Jobs ejecutados # TYPE faro_job_runs_total counter faro_job_runs_total{job_code="JOB-SCORE-001",status="success"} 1284 faro_job_runs_total{job_code="JOB-SCORE-001",status="dead_letter"} 2 # HELP faro_queue_depth Jobs pendientes en queue # TYPE faro_queue_depth gauge faro_queue_depth{queue_name="notifications"} 12 faro_queue_depth{queue_name="score"} 0 # HELP faro_ai_cost_usd_daily Costo IA diario por empresa # TYPE faro_ai_cost_usd_daily gauge faro_ai_cost_usd_daily{company="empresa-demo-cuyo"} 1.84
| Métrica | Tipo | Grupo | Descripción |
|---|---|---|---|
faro_api_request_count | counter | API | Requests totales por método/ruta/status |
faro_api_error_count | counter | API | Errores 4xx/5xx por endpoint |
faro_api_latency_ms | histogram | API | Latencia con buckets p50/p95/p99 |
faro_permission_denied_count | counter | API | 403 por permiso |
faro_auth_error_count | counter | API | 401 por sesión |
faro_job_runs_total | counter | Jobs | Jobs ejecutados por code/status |
faro_job_failures_total | counter | Jobs | Jobs fallidos por code/error_code |
faro_job_dead_letter_total | counter | Jobs | Jobs promovidos a DLQ |
faro_job_duration_ms | histogram | Jobs | Duración por job_code |
faro_queue_depth | gauge | Jobs | Profundidad de cola por queue_name |
faro_retry_count | counter | Jobs | Reintentos por job_code |
faro_stuck_jobs_count | gauge | Jobs | Jobs trabados (running > timeout) |
faro_tensions_detected_count | counter | Negocio | Tensiones detectadas por empresa |
faro_actions_created_count | counter | Negocio | Acciones creadas por empresa |
faro_actions_expired_count | counter | Negocio | Acciones vencidas |
faro_evidence_rejected_count | counter | Negocio | Evidencias rechazadas |
faro_score_recalculated_count | counter | Negocio | Recalculos de Score por empresa |
faro_reports_generated_count | counter | Negocio | Reportes generados |
faro_notifications_sent_count | counter | Negocio | Notificaciones enviadas |
faro_ai_requests_count | counter | IA | Requests al gateway IA |
faro_ai_failures_count | counter | IA | Fallos del provider IA |
faro_ai_fallback_used_count | counter | IA | Veces que se usó fallback |
faro_ai_policy_violation_count | counter | IA | Violaciones de policy IA |
faro_ai_cost_usd_daily | gauge | IA | Costo diario por empresa |
faro_ai_latency_ms | histogram | IA | Latencia del gateway IA |
request_idToda request entra con X-Request-Id (o se genera uno). Ese ID se propaga al worker cuando el handler encola un job. Permite reconstruir el viaje completo end-to-end.
export function withRequestId(handler: RequestHandler) { return async (req: Request, res: Response, next: NextFunction) => { const requestId = req.headers["x-request-id"] ?? `req_${crypto.randomUUID().slice(0, 8)}`; res.setHeader("x-request-id", requestId); await asyncLocalStorage.run({ requestId }, async () => { await handler(req, res, next); }); }; }
Cinco dashboards canónicos. Sin ellos, la operación es a ciegas.
Cada runbook es un procedimiento numerado, repetible y revisable para una falla esperada. Cuando un operador despierta a las 4 AM, no se piensa: se sigue el runbook. Si el runbook está mal, se actualiza en el postmortem.
Síntoma: Empresa Demo Cuyo S.A. subió archivo, pasaron > 30 min y no aparece en staging. JOB-ING-001 aparece en DLQ o trabado en running.
raw_imports y import_batches para esa empresa: SELECT * FROM faro.raw_imports WHERE company_id = $1 ORDER BY created_at DESC LIMIT 5;error_code del último intento. Mapear contra taxonomía (sección 6).VALIDATION_ERROR: marcar como data issue, notificar al data owner de la empresa, no requeue automático.STORAGE_ERROR o INTEGRATION_ERROR transitorio: requeue desde /faro/ops/dead-letter.label = ops/ingest, asignar a dev backend.resolution_note.Cuándo escalar: si más de 3 empresas distintas fallan en la misma ventana o si el parser falla en archivos previamente válidos, escalar a SEV-1 (posible regresión).
Síntoma: JOB-RULE-001 aparece en running hace más de 30 min. Score no se actualiza, tensiones nuevas no aparecen.
job_run_id stuck: SELECT job_run_id, company_id, started_at, locked_by FROM ops.job_runs WHERE job_code = 'JOB-RULE-001' AND status = 'running' AND started_at < now() - interval '30 minutes';locked_by, comparar con workers activos en Redis/orquestador).status = 'retrying', locked_by = NULL, next_retry_at = now().JOIN sin índice en datos grandes).score_model_version activa: SELECT * FROM faro.score_model_versions WHERE is_active = true;v_kpi_latest, v_period_keys) que no estén explotando por NULL nuevos.engine_skip_company para esa company_id y reintentar el resto.Mitigación temporal: activar engine_safe_mode que limita el motor a las reglas críticas (TNS-001..TNS-010) mientras se diagnostica.
Síntoma: Score de Empresa Demo Cuyo S.A. aparece igual durante > 24h pese a que hay tensiones cerradas, o salta más de 20 puntos en una sola corrida.
SELECT * FROM ops.job_runs WHERE job_code = 'JOB-SCORE-001' AND company_id = $1 ORDER BY created_at DESC LIMIT 10;error_code, error_message y result de la última corrida exitosa.score_model_version activa y que coincide con la versión esperada.v_score_inputs_commercial, v_score_inputs_finance, etc. Confirmar que devuelven datos para esa empresa.POST /api/v1/ops/jobs/JOB-SCORE-001/run con { "company_id": "..." }.SELECT * FROM faro.score_snapshots WHERE company_id = $1 ORDER BY period_end DESC LIMIT 3;score_impact de las tensiones cerradas/abiertas en la ventana.data_quality_issue.Síntoma: dead_letter_jobs > 50 en health snapshot. Alerta automática disparada.
SELECT error_code, count(*) FROM ops.dead_letter_queue WHERE status = 'pending' GROUP BY error_code ORDER BY 2 DESC;INTEGRATION_ERROR de un provider externo: verificar status del provider, ventana de incidente. Si ya volvió, requeue masivo.VALIDATION_ERROR repetido: revisar último cambio de schema/contrato. Coordinar con dev backend.DB_ERROR por constraint: identificar el constraint, decidir si hay que loosen o si los datos son inválidos.POST /api/v1/ops/dlq/requeue-bulk con filtro por error_code + window. Limitar a 100 por batch.cancelled los que ya no aplican (datos viejos sin valor de reproceso).ops.jobs.is_active = false temporalmente, abrir incidente, escalar.Prevención: revisar la alerta de DLQ con threshold > 20 (warning) antes de llegar a 50 (critical).
Síntoma: ai_status = degraded en health snapshot. Reportes ejecutivos muestran texto fallback. Métrica faro_ai_fallback_used_count creciendo.
ai.requests: SELECT status_code, count(*) FROM ai.requests WHERE created_at > now() - interval '15 minutes' GROUP BY status_code;RATE_LIMIT: activar feature flag ai_throttle_mode para bajar concurrencia. No reintentar más rápido.ai_provider_secondary y monitorear.Comunicación: si dura > 2 h, comunicar a los GG de empresas activas: "los reportes de hoy llegan con resumen sintético; la explicación ejecutiva completa vuelve mañana".
Regla de runbooks vivos. Cada postmortem SEV-1/SEV-2 debe revisar el runbook que se usó (o que faltaba) y proponer mejora concreta. Un runbook que no se actualiza en 6 meses está desactualizado por definición.
No alcanza con "hay backup". Hay que verificar último backup exitoso, edad del backup, restore test periódico y alerta si no existe backup reciente. JOB-BACKUP-001 corre cada día y deja registro en ops.backup_checks.
| Recurso | Estrategia | Frecuencia | Retención |
|---|---|---|---|
| PostgreSQL | Snapshot diario + PITR (si provider lo permite) | diario · WAL continuo | 30 días daily + 12 meses semanal |
| Storage evidencias | Versionado del bucket + backup cross-region | continuo | 365 días |
| Reportes PDF | Storage privado replicado | al generar | 5 años (audit trail) |
| Configuración | Tabla + export periódico a object storage | diario | 90 días |
| Prompts IA | En DB + versionado en migraciones (git) | al cambiar | indefinido (audit) |
| Código | Git con mirror cross-provider | continuo | indefinido |
| Secrets | Secret manager · NO en repo · backup propio del provider | continuo | según provider |
ops.backup_checks + verificaciónDDL acotada usada por JOB-BACKUP-001 para registrar el estado verificado de cada recurso backupable.
CREATE TABLE IF NOT EXISTS ops.backup_checks ( backup_check_id uuid PRIMARY KEY DEFAULT gen_random_uuid(), resource_type text NOT NULL CHECK ( resource_type IN ('database', 'storage', 'config', 'reports', 'evidence') ), status text NOT NULL CHECK ( status IN ('ok', 'warning', 'failed') ), last_backup_at timestamptz NULL, last_restore_test_at timestamptz NULL, backup_size_bytes bigint NULL, provider text NULL, details jsonb NOT NULL DEFAULT '{}'::jsonb, checked_at timestamptz NOT NULL DEFAULT now() ); CREATE INDEX IF NOT EXISTS idx_backup_checks_time ON ops.backup_checks(resource_type, checked_at DESC);
Un backup que nunca se probó no es backup. Restore drill mensual obligatorio: tomar el snapshot de production, levantarlo en ambiente aislado, validar consistencia con un set de queries canónicas y registrar el éxito en last_restore_test_at.
job_run exitoso, último score_snapshot.last_restore_test_at = now() en ops.backup_checks.Objetivos operativos del MVP. No son SLAs contractuales (eso es enterprise).
| Indicador | Definición | Target MVP | Cómo se mide |
|---|---|---|---|
| RTO | Recovery Time Objective · tiempo desde incidente hasta servicio operativo | 4 h | Cronómetro desde detected_at hasta mitigated_at en ops.incidents |
| RPO | Recovery Point Objective · máximo data loss aceptable | 15 min | WAL continuo + snapshot diario |
| MTTR | Mean Time To Resolution · promedio de SEV-1/SEV-2 resueltos | < 2 h | Avg resolved_at - detected_at mensual |
| MTBF | Mean Time Between Failures · entre SEV-1 consecutivos | > 30 días | Diff entre incident.started_at SEV-1 consecutivos |
Sin restore drill, no hay backup. Tener un dump diario que nunca se probó es marketing interno. El día que se necesita es el día que se descubre que está corrupto, incompleto o mal versionado. Drill mensual no es opcional.
Toda interrupción operativa relevante se gestiona como incidente formal. Cuatro severidades canónicas, una tabla ops.incidents, postmortem obligatorio para SEV-1/SEV-2 y rotación on-call documentada. Esto no se hace para llenar planillas — se hace para que el próximo incidente sea más corto que el anterior.
Caída total o impacto en todas las empresas. Bloquea operación.
Ejemplo: DB caída, error rate > 10%, login no funciona globalmente.
Una capacidad clave no opera. El resto del sistema funciona.
Ejemplo: Score no recalcula, reportes no se generan, motor stuck.
Funciona con problema acotado. UX deteriorada pero no bloqueante.
Ejemplo: Email provider falla intermitente, IA caída con fallback activo.
No bloquea operación, sin urgencia.
Ejemplo: UI muestra métrica mal, link roto, tooltip incorrecto.
ops.incidentsCREATE TABLE IF NOT EXISTS ops.incidents ( incident_id uuid PRIMARY KEY DEFAULT gen_random_uuid(), severity text NOT NULL CHECK ( severity IN ('SEV-1', 'SEV-2', 'SEV-3', 'SEV-4') ), status text NOT NULL DEFAULT 'open' CHECK ( status IN ('open', 'investigating', 'mitigated', 'resolved', 'closed') ), title text NOT NULL, description text NOT NULL, impact_summary text NULL, root_cause text NULL, resolution_summary text NULL, started_at timestamptz NOT NULL DEFAULT now(), detected_at timestamptz NOT NULL DEFAULT now(), mitigated_at timestamptz NULL, resolved_at timestamptz NULL, closed_at timestamptz NULL, owner_user_id uuid NULL, related_alert_ids uuid[] NOT NULL DEFAULT ARRAY[]::uuid[], metadata jsonb NOT NULL DEFAULT '{}'::jsonb, created_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now() ); CREATE INDEX IF NOT EXISTS idx_incidents_status_time ON ops.incidents(status, started_at DESC); CREATE INDEX IF NOT EXISTS idx_incidents_severity_open ON ops.incidents(severity) WHERE status IN ('open', 'investigating', 'mitigated');
Para SEV-1 y SEV-2 es obligatorio dentro de las 72 h del cierre. Sin postmortem, el incidente no se considera cerrado. Sin culpables, sin show — un documento corto, honesto, con acciones concretas.
Template de postmortem (10 puntos obligatorios): qué pasó · cuándo empezó · cuándo se detectó · impacto cuantificado · causa raíz · qué se hizo (timeline) · qué se aprendió · acciones preventivas concretas con dueño · responsable del postmortem · fecha compromiso de cierre de las acciones. No es para buscar culpables. Es para que no vuelva a pasar. A veces el culpable es "no había proceso" — y la acción es escribir el proceso.
MVP: rotación semanal sobre un grupo acotado (mínimo 2 personas para evitar burnout). Definición clara de "qué despierta": SEV-1 siempre, SEV-2 en horario laboral o si afecta a múltiples empresas, SEV-3 y SEV-4 nunca despiertan — entran al backlog técnico.
| Severidad | Despierta on-call | Canal alerta primario | Tiempo respuesta target |
|---|---|---|---|
| SEV-1 | Sí · 24/7 | PagerDuty + WhatsApp + email | < 15 min |
| SEV-2 | Solo horario hábil (07-22 hora empresa) | PagerDuty + email | < 1 h |
| SEV-3 | No | Email + Slack | < 24 h |
| SEV-4 | No | Slack / issue tracker | Sprint siguiente |
ops.feature_flags + evaluation engineActivar funcionalidades por empresa o ambiente sin redeploy. Permite rollout gradual, kill switch ante problemas y experimentación controlada. Toda nueva funcionalidad relevante entra detrás de un flag.
| flag_code | Default | Uso |
|---|---|---|
ai_enabled | true | Activa/desactiva explicaciones IA por empresa |
ai_provider_secondary | false | Cambia a provider IA secundario (failover) |
ai_throttle_mode | false | Baja concurrencia al gateway IA ante rate limit |
score_v2_enabled | false | Activa Score model v2 (rollout por empresa) |
weekly_report_enabled | true | Genera reporte semanal automático |
evidence_strict_validation_enabled | true | Aplica validación estricta de evidencia |
new_workflow_engine_enabled | false | Migración gradual al nuevo workflow engine |
engine_safe_mode | false | Motor solo reglas críticas (TNS-001..010) · ante incidente |
engine_skip_company | — | Bypass de empresa específica · solo para mitigación |
ops.feature_flagsCREATE TABLE IF NOT EXISTS ops.feature_flags ( feature_flag_id uuid PRIMARY KEY DEFAULT gen_random_uuid(), flag_code text NOT NULL, company_id uuid NULL, -- null = global enabled boolean NOT NULL DEFAULT false, config jsonb NOT NULL DEFAULT '{}'::jsonb, rollout_percentage integer NULL CHECK ( rollout_percentage IS NULL OR (rollout_percentage BETWEEN 0 AND 100) ), description text NULL, created_by uuid NULL, created_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now(), UNIQUE(flag_code, company_id) ); CREATE INDEX IF NOT EXISTS idx_feature_flags_lookup ON ops.feature_flags(flag_code, company_id);
Resolución determinística: primero busca flag específico de la empresa, después el global, si no existe devuelve el default. rollout_percentage evalúa hash(company_id) % 100 contra el threshold.
export async function isFeatureEnabled(params: { client: any; flagCode: string; companyId?: string | null; defaultValue?: boolean; }): Promise<boolean> { const rows = await params.client.query( ` SELECT enabled, rollout_percentage FROM ops.feature_flags WHERE flag_code = $1 AND (company_id = $2 OR company_id IS NULL) ORDER BY company_id NULLS LAST LIMIT 1 `, [params.flagCode, params.companyId ?? null] ); if (!rows.rows[0]) return params.defaultValue ?? false; const row = rows.rows[0]; if (!row.enabled) return false; // Si hay rollout parcial, hash determinista por company if (row.rollout_percentage != null && params.companyId) { const bucket = hashToBucket(params.companyId); return bucket < row.rollout_percentage; } return true; } function hashToBucket(companyId: string): number { let hash = 0; for (let i = 0; i < companyId.length; i++) { hash = ((hash << 5) - hash + companyId.charCodeAt(i)) | 0; } return Math.abs(hash) % 100; }
Regla de oro: todo flag que no se evalúa en 90 días se debe limpiar (o de la tabla, o del código). Los flags zombi son deuda técnica disfrazada de configurabilidad.
Toda migración versionada, reversible cuando se puede, registrada y nunca destructiva sin backup verificado. Cuatro ambientes mínimos. Una regla brutal: nunca probar migraciones por primera vez en producción. Sí, suena obvio. También es obvio no mezclar caja personal con caja empresa y acá estamos.
VNNN__nombre_descriptivo.sql. Numeración secuencial. Sin saltos.DROP COLUMN no lo es. Documentar el rollback.CREATE TABLE IF NOT EXISTS, CREATE INDEX IF NOT EXISTS, INSERT ... ON CONFLICT DO NOTHING.| Tipo | Ejemplo | Reversible | Requiere backup |
|---|---|---|---|
| Schema | Crear tabla, agregar columna NULL | Sí | No (si es aditiva) |
| Seed | Insertar catálogo (jobs, tensiones, roles) | Sí (delete by code) | No |
| Patch | Agregar índice, agregar check constraint | Sí | No |
| Data migration | Transformar registros existentes | A medias | Sí |
| RLS migration | Agregar policies de seguridad | Sí | No |
| Index migration | Crear índice (usar CONCURRENTLY si volumen alto) | Sí | No |
| Cleanup | Deprecar tabla/columna obsoleta | No (típicamente) | Sí |
| Ambiente | Propósito | Datos | Promote desde |
|---|---|---|---|
| Local | Desarrollo individual | Fixtures + demo dataset | — |
| Dev | Integración técnica de features en curso | Fixtures + datos sintéticos | main branch |
| Staging | Prueba pre-producción · validación QA | Snapshot anonimizado de prod | release branch |
| Production | Clientes reales (Empresa Demo Cuyo S.A. y resto) | Datos reales | Tag versión validada en staging |
Toda timestamp se guarda en UTC (timestamptz) en la base. Toda presentación al usuario y todo cron de jobs scheduled se evalúa en la timezone de la empresa.
America/Argentina/Mendoza.faro.companies.timezone.period de KPIs (day, week, month) se calculan sobre la timezone de la empresa, no la del servidor.-- KPI diario de Empresa Demo Cuyo S.A. SELECT c.company_id, c.timezone, date_trunc('day', sale_at AT TIME ZONE c.timezone) AS period_day, SUM(net_amount) AS net_sales FROM faro.sales s JOIN faro.companies c ON c.company_id = s.company_id WHERE c.company_id = '...empresa-demo-cuyo' AND sale_at AT TIME ZONE c.timezone >= now() AT TIME ZONE c.timezone - interval '30 days' GROUP BY 1, 2, 3 ORDER BY period_day DESC;
Criterios duros para cerrar FARO-OPS-001, motivos de rechazo, riesgos identificados con mitigación y el roadmap de implementación en seis fases. Sin estos chequeos, el módulo no se considera entregado.
| Criterio | Esperado |
|---|---|
Existe catálogo de 13 jobs MVP en ops.jobs | Sí |
Se registran job runs en ops.job_runs | Sí |
| Jobs tienen status con CHECK constraint | Sí |
| Jobs tienen reintentos con backoff exponencial | Sí |
Jobs tienen DLQ explícita (ops.dead_letter_queue) | Sí |
Jobs tienen dedupe_key con unique index parcial | Sí |
| Jobs P1 críticos generan alerta automática en DLQ | Sí |
| Health check live + ready + deep funcionando | Sí |
Errores se registran en ops.error_events | Sí |
| Logs estructurados con request_id propagado | Sí |
Métricas Prometheus expuestas en /metrics | Sí |
| 5 runbooks operativos documentados y accesibles | Sí |
| Backups verificados con restore drill mensual | Sí |
Incidentes registrados con tabla ops.incidents | Sí |
| Feature flags con evaluation engine | Sí |
UI OPS básica disponible en /faro/ops/* | Sí |
Cualquiera de estos bloquea la entrega del módulo.
| Caso | Severidad |
|---|---|
Jobs fallan sin registro en ops.job_runs | Crítica |
| Jobs duplican tensiones o acciones (sin dedupe) | Crítica |
| No hay dead letter queue | Alta |
| No hay política de reintentos con backoff | Alta |
| No hay health check de ningún tipo | Alta |
| No hay error tracking centralizado | Alta |
| No hay verificación de backups | Alta |
| No hay logs estructurados (solo texto plano) | Media/Alta |
| No hay runbooks documentados | Media/Alta |
| JOB-REPORT-001 falla en producción y nadie se entera | Crítica |
| JOB-SCORE-001 falla y no queda evento ni alerta | Alta |
| JOB-NOTIF-001 falla silenciosamente | Alta |
| Riesgo | Mitigación |
|---|---|
| Jobs duplicados | dedupe_key + unique index parcial + constraints en tablas destino |
| Workers paralelos pisan mismos datos | ops.job_locks + SELECT ... FOR UPDATE en operaciones críticas |
| Errores silenciosos | ops.error_events + alertas técnicas obligatorias por categoría |
| Reintentos infinitos | max_attempts con default 3 · DLQ obligatoria |
| Queue atrasada sin diagnóstico | Métrica queue_depth con threshold de alerta |
| Reportes no generados | Alerta dedicada en JOB-REPORT-001 · destinatario técnico + GG |
| Score desactualizado | JOB-SCORE-001 + health check específico + alerta si pasa > 24h sin snapshot |
| Backups falsos / corruptos | Restore drill mensual + alerta si last_restore_test_at > 45d |
| AI cost descontrolado | Métricas diarias + budget hard cap por empresa + fallback automático |
| Logs con datos sensibles | Redacción automática de fields conocidos (email, telefono, CUIT) |
| Operación manual caótica | 5 runbooks documentados · actualización obligatoria en postmortems |
| Migración rompe producción | Backup verificado + smoke en staging + rollback documentado |
ops schemaops.jobsops.job_runsops.job_locksops.error_events
jobRegistryrunJobhandleJobFailureretryDLQscheduler
/health/live/health/ready/api/health/deephealth_snapshotserror_eventsalertas técnicas
workflow checkerscore recalcnotificationsweekly reportbackup checker
healthjob runsDLQerrorsbackupsincidentsfeature flags
SentryOpenTelemetryGrafanalogs centralizadosalerting
El módulo OPS no vive solo. Estos son los puntos donde se consume, se valida o se complementa con otras piezas del pack NDA.
Sistema de roles, permisos, RLS y audit. Los permisos ops:* y el contexto de empresa para workers se definen ahí.
Mapeo de frecuencias por fuente de datos. Define el cron exacto de JOB-ING, JOB-DQ, JOB-NORM, JOB-KPI.
Las 25 etapas del pipeline ETL → motor → Score → reportes. Los jobs MVP son los runtime de esas etapas.
Matriz visual del avance MVP. FARO-OPS-001 actualiza el estado de los 13 jobs y del schema ops.
DDL consolidado del sistema. Incluye las 8 tablas del schema ops y todas las migraciones V097..V108.
Ambientes, deploy, release management, blue/green, rollback. Cierra el contrato de operación end-to-end.
CI/CD, quality gates, regression suite, dataset demo. Asegura que cada cambio no rompa la operación definida acá.
El workflow checker corre como JOB-WF-001. Las reglas de escalamiento se evalúan en cada tick del job.
El dispatcher de notificaciones corre como JOB-NOTIF-001. Acá se definen plantillas, canales y dedupe de alertas.
El recálculo de Score corre como JOB-SCORE-001, con lock para evitar dos corridas paralelas por empresa.
El generador de reporte semanal corre como JOB-REPORT-001 los Lunes 07:00 hora empresa.
El runner setea app.company_id antes de cada job para que RLS aplique correctamente en queries del handler.
No alcanza con que funcione. Tiene que correr todos los días, avisar cuando falla, reintentarse sin romper, dejar rastro y poder recuperarse. Sin OPS, FARO es una demo poderosa. Con OPS, empieza a comportarse como una plataforma real.
→ Volver al hub modelos NDA