The AI-Native IDP -- Parte 6

El Dashboard de Governance para IA

#platform-engineering #backstage #ai #governance #observability

El Problema

Has construido cuatro features de IA en la plataforma. El catálogo se enriquece solo. El scaffolder genera proyectos a partir de texto en lenguaje natural. El plugin de code review lee GOTCHA.md antes de revisar PRs. El sistema RAG responde preguntas desde TechDocs.

Ahora tu CTO pregunta: “¿Cuánto cuesta esto?” Y no lo sabes.

Luego un team lead pregunta: “¿Podemos desactivar el AI code review para nuestro equipo? Es demasiado ruidoso para nuestros servicios de frontend.” Y no puedes hacerlo sin cambiar código.

Después alguien de seguridad pregunta: “¿Qué servicios tienen metadata del catálogo generada por IA? ¿Podemos auditar eso?” Y no hay ningún log que consultar.

Cada feature de IA que añades crea tres necesidades: visibilidad (qué está pasando), control (quién puede hacer qué) y auditoría (qué pasó). Sin governance, las features de IA se convierten en una caja negra que solo el equipo de plataforma entiende — y hasta ellos pierden el hilo después de unas semanas.

La Solución

Un plugin de Backstage con dos partes:

  1. Backend: Registra cada acción de IA (enrichment, scaffold, review, RAG query), rastrea costes y aplica policies
  2. Frontend: Un dashboard que muestra uso, costes y permite al equipo de plataforma configurar policies por equipo o servicio

Todos los endpoints de IA ya existen. No necesitamos cambiar su lógica — añadimos una capa de middleware que registra y controla el acceso.

El backend usa PostgreSQL para almacenamiento y Npgsql para acceso a datos. El frontend usa Material UI — la misma librería de componentes que usa Backstage.

Ejecución

La Tabla de Log de Uso

CREATE TABLE ai_usage_log (
    id SERIAL PRIMARY KEY,
    timestamp TIMESTAMP DEFAULT NOW(),
    action VARCHAR(50) NOT NULL,
    entity_ref VARCHAR(255),
    team VARCHAR(100),
    user_ref VARCHAR(255),
    input_tokens INTEGER DEFAULT 0,
    output_tokens INTEGER DEFAULT 0,
    model VARCHAR(100),
    duration_ms INTEGER DEFAULT 0,
    status VARCHAR(20) DEFAULT 'success',
    metadata JSONB DEFAULT '{}'
);

CREATE INDEX idx_usage_action ON ai_usage_log(action);
CREATE INDEX idx_usage_team ON ai_usage_log(team);
CREATE INDEX idx_usage_timestamp ON ai_usage_log(timestamp);

CREATE TABLE ai_policies (
    id SERIAL PRIMARY KEY,
    team VARCHAR(100),
    action VARCHAR(50) NOT NULL,
    enabled BOOLEAN DEFAULT true,
    max_daily_calls INTEGER,
    updated_at TIMESTAMP DEFAULT NOW(),

    UNIQUE (team, action)
);

El Middleware de Logging

En el servicio .NET de IA, un middleware que envuelve cada llamada de IA:

// Middleware/AiUsageMiddleware.cs
public class AiUsageLogger
{
    private readonly NpgsqlDataSource _db;

    public AiUsageLogger(NpgsqlDataSource db) => _db = db;

    public async Task<T> Track<T>(
        string action,
        string? entityRef,
        string? team,
        string? userRef,
        Func<Task<(T result, int inputTokens, int outputTokens)>> operation)
    {
        var sw = System.Diagnostics.Stopwatch.StartNew();
        var status = "success";

        try
        {
            // Check policy first
            if (!await IsAllowed(action, team))
            {
                status = "blocked";
                throw new InvalidOperationException(
                    $"Action '{action}' is disabled for team '{team}'");
            }

            // Check daily limit
            if (!await WithinDailyLimit(action, team))
            {
                status = "rate_limited";
                throw new InvalidOperationException(
                    $"Daily limit reached for '{action}' (team: {team})");
            }

            var (result, inputTokens, outputTokens) = await operation();
            sw.Stop();

            await LogUsage(action, entityRef, team, userRef,
                inputTokens, outputTokens, sw.ElapsedMilliseconds, status);

            return result;
        }
        catch (Exception) when (status != "success")
        {
            sw.Stop();
            await LogUsage(action, entityRef, team, userRef,
                0, 0, sw.ElapsedMilliseconds, status);
            throw;
        }
    }

    private async Task LogUsage(
        string action, string? entityRef, string? team,
        string? userRef, int inputTokens, int outputTokens,
        long durationMs, string status)
    {
        await using var cmd = _db.CreateCommand();
        cmd.CommandText = """
            INSERT INTO ai_usage_log
                (action, entity_ref, team, user_ref,
                 input_tokens, output_tokens, duration_ms, status)
            VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
            """;
        cmd.Parameters.AddWithValue(action);
        cmd.Parameters.AddWithValue(entityRef ?? (object)DBNull.Value);
        cmd.Parameters.AddWithValue(team ?? (object)DBNull.Value);
        cmd.Parameters.AddWithValue(userRef ?? (object)DBNull.Value);
        cmd.Parameters.AddWithValue(inputTokens);
        cmd.Parameters.AddWithValue(outputTokens);
        cmd.Parameters.AddWithValue((int)durationMs);
        cmd.Parameters.AddWithValue(status);

        await cmd.ExecuteNonQueryAsync();
    }

    private async Task<bool> IsAllowed(string action, string? team)
    {
        if (team == null) return true;

        await using var cmd = _db.CreateCommand();
        cmd.CommandText = """
            SELECT enabled FROM ai_policies
            WHERE action = $1 AND (team = $2 OR team = '*')
            ORDER BY CASE WHEN team = $2 THEN 0 ELSE 1 END
            LIMIT 1
            """;
        cmd.Parameters.AddWithValue(action);
        cmd.Parameters.AddWithValue(team);

        var result = await cmd.ExecuteScalarAsync();
        return result == null || (bool)result;
    }

    private async Task<bool> WithinDailyLimit(string action, string? team)
    {
        if (team == null) return true;

        await using var cmd = _db.CreateCommand();
        cmd.CommandText = """
            SELECT p.max_daily_calls, COUNT(l.id) as today_calls
            FROM ai_policies p
            LEFT JOIN ai_usage_log l ON l.action = p.action
                AND l.team = p.team
                AND l.timestamp >= CURRENT_DATE
                AND l.status = 'success'
            WHERE p.action = $1 AND p.team = $2
            GROUP BY p.max_daily_calls
            """;
        cmd.Parameters.AddWithValue(action);
        cmd.Parameters.AddWithValue(team);

        await using var reader = await cmd.ExecuteReaderAsync();
        if (!await reader.ReadAsync()) return true;

        var limit = reader.IsDBNull(0) ? int.MaxValue : reader.GetInt32(0);
        var calls = reader.GetInt64(1);
        return calls < limit;
    }
}

Los Endpoints de Uso

El servicio de IA expone endpoints para el dashboard:

// Usage summary
app.MapGet("/api/governance/usage", async (string? action, string? team, int? days, IConfiguration config) =>
{
    var connStr = config["Rag:PostgresConnection"];
    if (string.IsNullOrEmpty(connStr))
        return Results.Json(new { error = "Governance not configured." }, statusCode: 503);

    var daysValue = days ?? 30;
    await using var dataSource = NpgsqlDataSource.Create(connStr);
    await using var cmd = dataSource.CreateCommand();
    cmd.CommandText = """
        SELECT action, team, status,
               COUNT(*) as call_count,
               SUM(input_tokens) as total_input_tokens,
               SUM(output_tokens) as total_output_tokens,
               AVG(duration_ms) as avg_duration_ms
        FROM ai_usage_log
        WHERE timestamp >= NOW() - INTERVAL '1 day' * $1
          AND ($2 = '' OR action = $2)
          AND ($3 = '' OR team = $3)
        GROUP BY action, team, status
        ORDER BY call_count DESC
        """;

    cmd.Parameters.AddWithValue(daysValue > 0 ? daysValue : 30);
    cmd.Parameters.AddWithValue(action ?? "");
    cmd.Parameters.AddWithValue(team ?? "");

    var results = new List<object>();
    await using var reader = await cmd.ExecuteReaderAsync();
    while (await reader.ReadAsync())
    {
        results.Add(new
        {
            Action = reader.GetString(0),
            Team = reader.IsDBNull(1) ? "unknown" : reader.GetString(1),
            Status = reader.GetString(2),
            CallCount = reader.GetInt64(3),
            TotalInputTokens = reader.GetInt64(4),
            TotalOutputTokens = reader.GetInt64(5),
            AvgDurationMs = reader.GetDouble(6)
        });
    }

    return Results.Ok(results);
});

// Cost estimation
app.MapGet("/api/governance/costs", async (int? days, IConfiguration config) =>
{
    var connStr = config["Rag:PostgresConnection"];
    if (string.IsNullOrEmpty(connStr))
        return Results.Json(new { error = "Governance not configured." }, statusCode: 503);

    var daysValue = days ?? 30;
    await using var dataSource = NpgsqlDataSource.Create(connStr);
    await using var cmd = dataSource.CreateCommand();
    cmd.CommandText = """
        SELECT DATE(timestamp) as day,
               SUM(input_tokens) as input_tokens,
               SUM(output_tokens) as output_tokens
        FROM ai_usage_log
        WHERE timestamp >= NOW() - INTERVAL '1 day' * $1
          AND status = 'success'
        GROUP BY DATE(timestamp)
        ORDER BY day
        """;
    cmd.Parameters.AddWithValue(daysValue > 0 ? daysValue : 30);

    var results = new List<object>();
    await using var reader = await cmd.ExecuteReaderAsync();
    while (await reader.ReadAsync())
    {
        var inputTokens = reader.GetInt64(1);
        var outputTokens = reader.GetInt64(2);
        // Adjust pricing per your provider and model
        var cost = (inputTokens * 2.0 / 1_000_000) +
                   (outputTokens * 6.0 / 1_000_000);

        results.Add(new
        {
            Day = reader.GetDateTime(0).ToString("yyyy-MM-dd"),
            InputTokens = inputTokens,
            OutputTokens = outputTokens,
            EstimatedCostUsd = Math.Round(cost, 4)
        });
    }

    return Results.Ok(results);
});

// Policies CRUD
app.MapGet("/api/governance/policies", async (IConfiguration config) =>
{
    var connStr = config["Rag:PostgresConnection"];
    if (string.IsNullOrEmpty(connStr))
        return Results.Json(new { error = "Governance not configured." }, statusCode: 503);

    await using var dataSource = NpgsqlDataSource.Create(connStr);
    await using var cmd = dataSource.CreateCommand();
    cmd.CommandText = "SELECT id, team, action, enabled, max_daily_calls FROM ai_policies ORDER BY team, action";

    var results = new List<object>();
    await using var reader = await cmd.ExecuteReaderAsync();
    while (await reader.ReadAsync())
    {
        results.Add(new
        {
            Id = reader.GetInt32(0),
            Team = reader.GetString(1),
            Action = reader.GetString(2),
            Enabled = reader.GetBoolean(3),
            MaxDailyCalls = reader.IsDBNull(4) ? (int?)null : reader.GetInt32(4)
        });
    }
    return Results.Ok(results);
});

app.MapPut("/api/governance/policies", async (PolicyUpdate update, IConfiguration config) =>
{
    var connStr = config["Rag:PostgresConnection"];
    if (string.IsNullOrEmpty(connStr))
        return Results.Json(new { error = "Governance not configured." }, statusCode: 503);

    await using var dataSource = NpgsqlDataSource.Create(connStr);
    await using var cmd = dataSource.CreateCommand();
    cmd.CommandText = """
        INSERT INTO ai_policies (team, action, enabled, max_daily_calls)
        VALUES ($1, $2, $3, $4)
        ON CONFLICT (team, action)
        DO UPDATE SET enabled = EXCLUDED.enabled,
                      max_daily_calls = EXCLUDED.max_daily_calls,
                      updated_at = NOW()
        """;
    cmd.Parameters.AddWithValue(update.Team);
    cmd.Parameters.AddWithValue(update.Action);
    cmd.Parameters.AddWithValue(update.Enabled);
    cmd.Parameters.AddWithValue(update.MaxDailyCalls.HasValue
        ? update.MaxDailyCalls.Value : DBNull.Value);

    await cmd.ExecuteNonQueryAsync();
    return Results.Ok();
});

record PolicyUpdate(string Team, string Action, bool Enabled, int? MaxDailyCalls);

El Componente del Dashboard

// plugins/ai-governance/src/components/GovernanceDashboard.tsx
import React, { useEffect, useState } from 'react';
import {
  Card,
  CardContent,
  CardHeader,
  Grid,
  Typography,
  Table,
  TableBody,
  TableCell,
  TableHead,
  TableRow,
  Switch,
} from '@material-ui/core';
import { useApi, fetchApiRef, discoveryApiRef } from '@backstage/core-plugin-api';

interface UsageSummary {
  action: string;
  team: string;
  status: string;
  callCount: number;
  totalInputTokens: number;
  totalOutputTokens: number;
  avgDurationMs: number;
}

interface CostEntry {
  day: string;
  inputTokens: number;
  outputTokens: number;
  estimatedCostUsd: number;
}

interface Policy {
  id: number;
  team: string;
  action: string;
  enabled: boolean;
  maxDailyCalls: number | null;
}

export const GovernanceDashboard = () => {
  const [usage, setUsage] = useState<UsageSummary[]>([]);
  const [costs, setCosts] = useState<CostEntry[]>([]);
  const [policies, setPolicies] = useState<Policy[]>([]);
  const fetchApi = useApi(fetchApiRef);
  const discoveryApi = useApi(discoveryApiRef);
  const [days, setDays] = useState(30);

  useEffect(() => {
    fetchData();
  }, [days]);

  const fetchData = async () => {
    const proxyUrl = await discoveryApi.getBaseUrl('proxy');
    const [usageRes, costsRes, policiesRes] = await Promise.all([
      fetchApi.fetch(`${proxyUrl}/ai-service/api/governance/usage?days=${days}`),
      fetchApi.fetch(`${proxyUrl}/ai-service/api/governance/costs?days=${days}`),
      fetchApi.fetch(`${proxyUrl}/ai-service/api/governance/policies`),
    ]);

    if (usageRes.ok) setUsage(await usageRes.json());
    if (costsRes.ok) setCosts(await costsRes.json());
    if (policiesRes.ok) setPolicies(await policiesRes.json());
  };

  const totalCost = costs.reduce((sum, c) => sum + c.estimatedCostUsd, 0);
  const totalCalls = usage
    .filter(u => u.status === 'success')
    .reduce((sum, u) => sum + u.callCount, 0);

  const togglePolicy = async (policy: Policy) => {
    const proxyUrl = await discoveryApi.getBaseUrl('proxy');
    await fetchApi.fetch(`${proxyUrl}/ai-service/api/governance/policies`, {
      method: 'PUT',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        team: policy.team,
        action: policy.action,
        enabled: !policy.enabled,
        maxDailyCalls: policy.maxDailyCalls,
      }),
    });
    fetchData();
  };

  return (
    <Grid container spacing={3}>
      {/* Summary cards */}
      <Grid item md={4}>
        <Card>
          <CardContent>
            <Typography variant="h4">{totalCalls}</Typography>
            <Typography color="textSecondary">
              AI calls (last {days} days)
            </Typography>
          </CardContent>
        </Card>
      </Grid>
      <Grid item md={4}>
        <Card>
          <CardContent>
            <Typography variant="h4">
              ${totalCost.toFixed(2)}
            </Typography>
            <Typography color="textSecondary">
              Estimated cost (last {days} days)
            </Typography>
          </CardContent>
        </Card>
      </Grid>
      <Grid item md={4}>
        <Card>
          <CardContent>
            <Typography variant="h4">
              {usage.filter(u => u.status === 'blocked').length}
            </Typography>
            <Typography color="textSecondary">
              Blocked by policy
            </Typography>
          </CardContent>
        </Card>
      </Grid>

      {/* Usage by action */}
      <Grid item md={12}>
        <Card>
          <CardHeader title="Usage by Action" />
          <CardContent>
            <Table size="small">
              <TableHead>
                <TableRow>
                  <TableCell>Action</TableCell>
                  <TableCell>Team</TableCell>
                  <TableCell align="right">Calls</TableCell>
                  <TableCell align="right">Input Tokens</TableCell>
                  <TableCell align="right">Output Tokens</TableCell>
                  <TableCell align="right">Avg Duration</TableCell>
                  <TableCell>Status</TableCell>
                </TableRow>
              </TableHead>
              <TableBody>
                {usage.map((row, i) => (
                  <TableRow key={i}>
                    <TableCell>{row.action}</TableCell>
                    <TableCell>{row.team}</TableCell>
                    <TableCell align="right">{row.callCount}</TableCell>
                    <TableCell align="right">
                      {row.totalInputTokens.toLocaleString()}
                    </TableCell>
                    <TableCell align="right">
                      {row.totalOutputTokens.toLocaleString()}
                    </TableCell>
                    <TableCell align="right">
                      {Math.round(row.avgDurationMs)}ms
                    </TableCell>
                    <TableCell>{row.status}</TableCell>
                  </TableRow>
                ))}
              </TableBody>
            </Table>
          </CardContent>
        </Card>
      </Grid>

      {/* Policies */}
      <Grid item md={12}>
        <Card>
          <CardHeader title="Policies" />
          <CardContent>
            <Table size="small">
              <TableHead>
                <TableRow>
                  <TableCell>Team</TableCell>
                  <TableCell>Action</TableCell>
                  <TableCell>Enabled</TableCell>
                  <TableCell>Daily Limit</TableCell>
                </TableRow>
              </TableHead>
              <TableBody>
                {policies.map(policy => (
                  <TableRow key={policy.id}>
                    <TableCell>{policy.team}</TableCell>
                    <TableCell>{policy.action}</TableCell>
                    <TableCell>
                      <Switch
                        checked={policy.enabled}
                        onChange={() => togglePolicy(policy)}
                      />
                    </TableCell>
                    <TableCell>
                      {policy.maxDailyCalls ?? 'unlimited'}
                    </TableCell>
                  </TableRow>
                ))}
              </TableBody>
            </Table>
          </CardContent>
        </Card>
      </Grid>

      {/* Daily costs */}
      <Grid item md={12}>
        <Card>
          <CardHeader title="Daily Cost Breakdown" />
          <CardContent>
            <Table size="small">
              <TableHead>
                <TableRow>
                  <TableCell>Date</TableCell>
                  <TableCell align="right">Input Tokens</TableCell>
                  <TableCell align="right">Output Tokens</TableCell>
                  <TableCell align="right">Est. Cost (USD)</TableCell>
                </TableRow>
              </TableHead>
              <TableBody>
                {costs.map((row, i) => (
                  <TableRow key={i}>
                    <TableCell>{row.day}</TableCell>
                    <TableCell align="right">
                      {row.inputTokens.toLocaleString()}
                    </TableCell>
                    <TableCell align="right">
                      {row.outputTokens.toLocaleString()}
                    </TableCell>
                    <TableCell align="right">
                      ${row.estimatedCostUsd.toFixed(4)}
                    </TableCell>
                  </TableRow>
                ))}
              </TableBody>
            </Table>
          </CardContent>
        </Card>
      </Grid>
    </Grid>
  );
};

Registrar la Pagina del Dashboard en Backstage

// plugins/ai-governance/src/plugin.ts (frontend)
import {
  createPlugin,
  createRouteRef,
  createRoutableExtension,
} from '@backstage/core-plugin-api';

const rootRouteRef = createRouteRef({ id: 'ai-governance' });

export const aiGovernancePlugin = createPlugin({
  id: 'ai-governance',
  routes: {
    root: rootRouteRef,
  },
});

export const GovernancePage = aiGovernancePlugin.provide(
  createRoutableExtension({
    name: 'GovernancePage',
    component: () =>
      import('./components/GovernanceDashboard').then(
        m => m.GovernanceDashboard,
      ),
    mountPoint: rootRouteRef,
  }),
);

En packages/app/src/App.tsx:

import { GovernancePage } from '@internal/plugin-ai-governance';

// Dentro de <FlatRoutes>:
<Route path="/ai-governance" element={<GovernancePage />} />

Y en el sidebar:

<SidebarItem icon={DashboardIcon} to="ai-governance" text="AI Governance" />

Que Muestra el Dashboard

El equipo de plataforma abre /ai-governance y ve:

  • Total de llamadas de IA: 1.247 en los ultimos 30 dias
  • Coste estimado: $8.34
  • Llamadas bloqueadas: 3 (team-frontend intento usar el scaffolder, que esta desactivado para ellos)

La tabla de uso muestra:

  • enrich: 420 llamadas, la mayoria durante la noche (el scheduler de 24h)
  • scaffold: 28 llamadas, repartidas entre 4 equipos
  • review: 612 llamadas, disparadas por cada PR
  • ask: 187 llamadas, desarrolladores buscando en la documentacion

La tabla de policies les permite:

  • Desactivar el AI code review para team-frontend (demasiado ruidoso para cambios de CSS)
  • Poner un limite diario de 10 llamadas de scaffold por equipo (para prevenir abusos)
  • Mantener enrichment y RAG activados para todos

Checklist

  • Tabla ai_usage_log creada con los indices correctos
  • Tabla ai_policies creada con unicidad por team + action
  • Middleware AiUsageLogger envuelve todos los endpoints del servicio de IA
  • Endpoints de usage, costs y policies devuelven datos correctos
  • El dashboard muestra tarjetas de resumen, tabla de uso, policies y desglose de costes
  • El toggle de policy (activar/desactivar) funciona desde el dashboard
  • Las llamadas bloqueadas se registran con status = 'blocked'
  • El dashboard es accesible desde el sidebar de Backstage

Antes del Siguiente Articulo

El equipo de plataforma ahora puede ver que esta pasando, cuanto cuesta y controlar quien usa que. Cada feature de IA queda registrada, cada equipo tiene policies configurables y los costes son visibles.

Pero todo esto funciona cuando las cosas van bien. ¿Que pasa cuando algo se rompe? Cuando la invoice-api devuelve errores 500 a las 3 de la manana? El ingeniero de guardia abre la pagina de incidentes y ve… logs. Miles de lineas de logs. Sin contexto sobre que hace este servicio, de que depende o que cambio recientemente.

¿Y si la pagina de incidentes pudiera leer el catalogo, comprobar los deployments recientes, buscar en los logs y sugerir que ha fallado — antes de que el ingeniero termine su cafe?

Eso es el articulo 7: Respuesta a Incidentes con IA.

El codigo completo esta en GitHub.

Troubleshooting

El proxy devuelve respuestas vacias

Asegurate de que allowedMethods incluye todos los metodos HTTP que usa el dashboard. En app-config.yaml:

proxy:
  endpoints:
    /ai-service:
      target: http://localhost:5100
      allowedHeaders: ['Content-Type']
      allowedMethods: ['GET', 'POST', 'PUT']

better-sqlite3 falla al compilar en Node 24+

La base de datos por defecto de Backstage es better-sqlite3, que necesita compilacion nativa. Si falla, cambia a PostgreSQL en app-config.yaml:

backend:
  database:
    client: pg
    connection:
      host: localhost
      port: 5432
      user: postgres
      password: your-password

El dashboard muestra todo a cero

Los endpoints de governance necesitan la config Rag:PostgresConnection en el servicio de IA. Asegurate de arrancar el servicio de IA con el connection string:

Rag__PostgresConnection="Host=localhost;Database=forge;Username=postgres;Password=forge-dev" dotnet run

Si esta serie te resulta util, puedes invitarme a un cafe.

Este es el articulo 6 de la serie AI-Native IDP. Anterior: TechDocs RAG. Siguiente: Respuesta a Incidentes con IA.

Comments

Loading comments...