Auditoría de Costes — GitHub App

Cliente: CodeSight AI  ·  Periodo: 10–17 Jun 2026  ·  Analista: CULTIVA IA
3 hallazgos críticos Ahorro estimado: $1.062 / semana
Coste total (semana)
$1.374
▲ +340% vs semana anterior
PRs duplicadas detectadas
847
De 107 eventos legítimos
Usuarios Free → Opus
23
8,9M tokens premium sin billing
Race conditions cuota
312
Reservas concurrentes escapadas
Desglose de factura Anthropic
Comparativa semana anterior vs semana auditada (USD)
Sem. anterior — Sonnet
$312
Sonnet (legítimo est.)
~$240
Sonnet (PR recursivo)
$785
Opus (fuga Free tier)
$349
● Legítimo estimado: $240 ● Burn evitable: $1.134 (82%) ● Ahorro semanal post-fix: $1.062
Hallazgos — ordenados por impacto en burn
P0
Recursión de PRs: pull_request.synchronize re-encola análisis sobre ramas propias
$785/sem

El webhook router en src/index.ts encola un análisis completo para los eventos push, pull_request.opened y pull_request.synchronize sin distinguir si el autor del push es la propia GitHub App. Cuando el worker crea una PR de sugerencias, GitHub emite un pull_request.synchronize sobre esa PR recién creada — lo que dispara un nuevo análisis, que crea otra PR, generando un bucle autorreferencial.

push legítimo
analysis-worker
PR creada por app
synchronize event
re-encola análisis
∞ loop
// src/index.ts — ruta de ingreso problemática app.webhooks.on('pull_request.synchronize', async ({ payload }) => { // ❌ Sin check de si el sender es la propia app await enqueueAnalysis({ repoId: payload.repository.id, prNumber: payload.number }); });
Fix recomendado — src/index.ts

Añadir guard al inicio del handler: si payload.sender.type === 'Bot' o payload.sender.login termina en [bot], retornar sin encolar. Adicionalmente, comprobar si el PR head branch sigue el patrón de ramas generadas por la app (codesight/suggestions-*) y rechazarlo como ingress.

P0
Race condition en cuota: gate comprobado pre-enqueue, descuento ejecutado en worker
$0 directo, riesgo de límites

En src/billing/quota.ts la función checkQuota(userId) consulta usage_reservations y compara con el plan. Pero el incrementUsage() solo ocurre dentro del worker, después de llamar a Anthropic. Bajo carga concurrente (varios pushes simultáneos del mismo repo), múltiples requests pasan el gate con cuota disponible y todas inician análisis — superando el límite del plan antes de que ninguna haya terminado de incrementar.

// src/billing/quota.ts export async function checkQuota(userId: string): Promise<boolean> { const used = await db.from('usage_reservations').select('count')... return used < plan.limit; // ← lee snapshot, no reserva atómica } // src/workers/analysis-worker.ts async function runAnalysis(job) { await callAnthropic(job.data); // ← gasto real aquí await incrementUsage(job.data.userId); // ← demasiado tarde }
Fix recomendado — quota.ts + index.ts

Convertir el gate en una reserva optimista atómica con INSERT INTO usage_reservations ... ON CONFLICT DO UPDATE SET count = count + 1 WHERE count < limit RETURNING id. Si el INSERT no devuelve fila, rechazar el enqueue. Liberar la reserva si el worker falla antes de consumir tokens.

P1
Fuga de modelo premium: usuarios Free enrutan a claude-opus-4-5
$349/sem

En src/ai/model-router.ts la selección del modelo lee la variable de entorno ANTHROPIC_OPUS_KEY. Si la clave existe (como ocurre en producción Railway), todos los usuarios — incluyendo los del plan Free — pasan por la rama premium. El campo subscription.tier no se evalúa en esa función.

// src/ai/model-router.ts export function selectModel(ctx: AnalysisContext): string { if (process.env.ANTHROPIC_OPUS_KEY) { return 'claude-opus-4-5'; // ❌ ignora ctx.user.tier } return 'claude-sonnet-4'; }
Fix recomendado — model-router.ts

Añadir condición: if (process.env.ANTHROPIC_OPUS_KEY && ctx.subscription.tier === 'premium'). Añadir test unitario que verifique que un usuario Free con OPUS_KEY presente en env recibe claude-sonnet-4.

P2
Jobs duplicados: el mismo SHA se encola múltiples veces sin deduplicación
Burn residual

BullMQ permite configurar jobId como clave de deduplicación. Actualmente el worker usa un UUID aleatorio por job — si llegan dos webhooks del mismo evento (GitHub reintenta webhooks fallidos o el cliente hace force-push seguido), el mismo commit SHA se analiza dos veces.

Fix recomendado — queue producer

Usar jobId: \`${repoId}:${sha}:${eventType}\` al encolar. BullMQ descartará el segundo job si el primero ya está en cola o procesando. Coste de implementación: ~15 min.

Impacto estimado por fix
Prioridad Fix Archivos afectados Ahorro est. / semana Esfuerzo Estado
P0 Guard bot sender en webhook synchronize src/index.ts $785 30 min Pendiente
P0 Reserva atómica de cuota pre-enqueue src/billing/quota.ts ~$0 directo, previene overflow 2h Pendiente
P1 Verificar tier antes de asignar Opus src/ai/model-router.ts $349 20 min Pendiente
P2 Deduplicación de jobs por SHA src/queue/producer.ts ~$28 15 min Pendiente
Total ahorro semanal estimado post-fix $1.062 ROI: 1 día de trabajo vs $4.500/mes
Plan de verificación
1

Simular webhook de bot (src/index.ts)

Enviar payload con sender.type=Bot al handler local y verificar que no se encola ningún job en BullMQ dashboard.

2

Test de concurrencia de cuota

Lanzar 10 requests paralelas para el mismo usuario en plan Free (límite 5). Verificar que exactamente 5 reservas persisten en DB.

3

Unit test model-router.ts

selectModel({ subscription: {tier:'free'} }) debe retornar 'claude-sonnet-4' incluso con ANTHROPIC_OPUS_KEY presente en env.

4

Deduplicación BullMQ

Encolar dos jobs con mismo SHA, verificar que BullMQ reporta 1 activo y 1 descartado en la UI de Railway.

Separación de impactos

Causa raíz técnica (repo-side burn)

  • Recursión de webhook synchronize
  • Race condition pre/post enqueue
  • Routing de modelo sin tier check
  • SHA duplicados en cola

Impacto cliente (billing-facing)

  • 847 PRs no solicitadas creadas
  • Repos de clientes contaminados
  • 23 cuentas Free cobradas implícitamente

Backlog producto

  • Dashboard de uso por cliente
  • Alertas de burn rate en tiempo real
  • Límite de PRs auto por repo/semana
Estado actual de la auditoría
Auditado localmente — 4 rutas de burn trazadas con rutas de archivo exactas
Cambios pendientes — Ninguna modificación aplicada aún al repo
Sin push/deploy — Esperando aprobación del equipo