The AI-Native IDP -- Parte 6
El Dashboard de Governance para IA
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:
- Backend: Registra cada acción de IA (enrichment, scaffold, review, RAG query), rastrea costes y aplica policies
- 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 equiposreview: 612 llamadas, disparadas por cada PRask: 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_logcreada con los indices correctos - Tabla
ai_policiescreada 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.
Loading comments...