The AI-Native IDP -- Parte 7

Respuesta a Incidentes Asistida por IA

#platform-engineering #backstage #ai #incident-response #observability

El Problema

La alerta salta a las 3:12am. Invoice-api, errores 500, tasa de errores por encima del umbral.

El ingeniero de guardia abre el dashboard de monitorización. Ve un pico de errores que empezó a las 3:08am. Abre los logs. Navega por páginas de JSON estructurado. Encuentra un NpgsqlException: connection refused. Comprueba la base de datos. PostgreSQL está corriendo. Comprueba el connection string. Parece correcto. Comprueba los deployments recientes. Hubo un deployment hace 20 minutos. Mira el diff. Alguien cambió el formato del connection string de Host= a Server=. Npgsql no acepta Server=.

Eso llevó 40 minutos. El fix tarda 2 minutos — revertir el deployment.

El problema no es el fix. El problema es la investigación. El ingeniero de guardia no sabía que hubo un deployment hace 20 minutos. No sabía que invoice-api usa PostgreSQL. No sabía que el formato del connection string importa. Toda esta información existe en el sistema — en el catálogo, en el historial de deployments, en el GOTCHA.md — pero un humano tarda 40 minutos en conectar los puntos.

La Solución

Un plugin de incident response que hace la investigación de forma automática. Cuando salta una alerta:

  1. Identifica el servicio afectado a partir de la alerta
  2. Lee el catálogo: ¿de qué depende este servicio?
  3. Comprueba deployments recientes: ¿qué cambió?
  4. Lee los logs: ¿qué errores están apareciendo?
  5. Lee el GOTCHA.md: ¿cuáles son las heurísticas conocidas?
  6. Envía todo esto al modelo de IA
  7. Devuelve un diagnóstico con la causa probable y las acciones sugeridas

El ingeniero sigue decidiendo. Pero en vez de empezar desde cero, empieza con una hipótesis.

Execute

El Endpoint de Análisis de Incidentes

app.MapPost("/api/incident/analyze", async (IncidentRequest request, IConfiguration config) =>
{
    var endpoint = config["AI:Endpoint"];
    var apiKey = config["AI:Key"];
    var model = config["AI:ChatModel"] ?? "mistral-small-3.2-24b-instruct-2506";
    var provider = config["AI:Provider"] ?? "openai";

    ChatClient chatClient = provider.ToLowerInvariant() switch
    {
        "azure" => new AzureOpenAIClient(
            new Uri(endpoint!), new ApiKeyCredential(apiKey!))
            .GetChatClient(model),
        _ => new OpenAIClient(
            new ApiKeyCredential(apiKey!),
            new OpenAIClientOptions { Endpoint = new Uri(endpoint!) })
            .GetChatClient(model),
    };

    var systemPrompt = $"""
        You are an incident response assistant for a cloud platform.
        A service is experiencing issues. Analyze the available information
        and provide a diagnosis.

        SERVICE CONTEXT:
        Name: {request.ServiceName}
        Description: {request.ServiceDescription}
        Dependencies: {string.Join(", ", request.Dependencies)}
        Tags: {string.Join(", ", request.Tags)}

        RECENT DEPLOYMENTS:
        {request.RecentDeployments}

        RECENT ERRORS (from logs):
        {request.RecentErrors}

        ARCHITECTURAL RULES (from GOTCHA.md):
        {request.GotchaHeuristics}

        Provide:
        1. PROBABLE CAUSE — what most likely caused this incident, based on the evidence
        2. EVIDENCE — which specific pieces of information led you to this conclusion
        3. SUGGESTED ACTIONS — ordered list of steps to investigate and fix
        4. RELATED SERVICES — which other services might be affected, based on dependencies

        Be specific. Reference actual error messages, deployment changes, and service dependencies.
        If you don't have enough information to diagnose, say so and suggest what data to collect.
        """;

    try
    {
        var completion = await chatClient.CompleteChatAsync(
        [
            new SystemChatMessage(systemPrompt),
            new UserChatMessage(
                $"Alert: {request.AlertTitle}\nSeverity: {request.Severity}\nStarted: {request.StartedAt}"),
        ]);

        var analysis = completion.Value.Content[0].Text.Trim();
        return Results.Ok(new { analysis });
    }
    catch (ClientResultException ex) when (ex.Status == 401)
    {
        return Results.Json(new { error = "AI provider authentication failed." }, statusCode: 503);
    }
    catch (Exception ex)
    {
        return Results.Json(new { error = $"AI provider error: {ex.Message}" }, statusCode: 502);
    }
});

record IncidentRequest(
    string ServiceName,
    string ServiceDescription,
    string[] Dependencies,
    string[] Tags,
    string RecentDeployments,
    string RecentErrors,
    string GotchaHeuristics,
    string AlertTitle,
    string Severity,
    string StartedAt);

El Plugin de Incidentes en Backstage

El plugin escucha alertas (via webhook desde tu sistema de monitorización) y recopila todo el contexto antes de llamar a la IA:

// plugins/ai-incident/src/plugin.ts
import {
  coreServices,
  createBackendPlugin,
} from '@backstage/backend-plugin-api';
import { catalogServiceRef } from '@backstage/plugin-catalog-node';
import { createRouter } from './router';

export const aiIncidentPlugin = createBackendPlugin({
  pluginId: 'ai-incident',
  register(env) {
    env.registerInit({
      deps: {
        logger: coreServices.logger,
        httpRouter: coreServices.httpRouter,
        config: coreServices.rootConfig,
        catalog: catalogServiceRef,
        auth: coreServices.auth,
      },
      async init({ logger, httpRouter, config, catalog, auth }) {
        const aiServiceUrl = config.getString('forge.aiServiceUrl');

        const router = await createRouter({
          logger, catalog, auth, aiServiceUrl,
        });

        httpRouter.use(router);
        httpRouter.addAuthPolicy({
          path: '/webhook/alert',
          allow: 'unauthenticated',
        });
        logger.info('AI Incident Response plugin initialized');
      },
    });
  },
});

El Context Gatherer

Esta es la lógica principal — recopila información de múltiples fuentes:

// plugins/ai-incident/src/gather.ts
import type { Entity } from '@backstage/catalog-model';
import type { LoggerService } from '@backstage/backend-plugin-api';
import { Octokit } from '@octokit/rest';

interface IncidentContext {
  serviceName: string;
  serviceDescription: string;
  dependencies: string[];
  tags: string[];
  recentDeployments: string;
  recentErrors: string;
  gotchaHeuristics: string;
}

export async function gatherIncidentContext(
  entity: Entity,
  logger: LoggerService,
): Promise<IncidentContext> {
  const slug =
    entity.metadata.annotations?.['github.com/project-slug'] ?? '';
  const [owner, repo] = slug.split('/');
  const octokit = new Octokit({ auth: process.env.GITHUB_TOKEN });

  const serviceName = entity.metadata.name;
  const serviceDescription = entity.metadata.description ?? 'No description';
  const tags = (entity.metadata.tags as string[]) ?? [];
  const dependencies = tags; // In production, read from catalog relations

  // 1. Recent deployments (last 5 GitHub releases or deployment events)
  let recentDeployments = 'No deployment data available.';
  try {
    const { data: commits } = await octokit.repos.listCommits({
      owner,
      repo,
      per_page: 5,
    });

    recentDeployments = commits
      .map(
        c =>
          `${c.commit.author?.date} — ${c.commit.message} (${c.sha.slice(0, 7)})`,
      )
      .join('\n');
  } catch {
    logger.info(`Could not fetch commits for ${slug}`);
  }

  // 2. GOTCHA.md heuristics
  let gotchaHeuristics = 'No GOTCHA.md found.';
  try {
    const { data: gotchaFile } = await octokit.repos.getContent({
      owner,
      repo,
      path: 'GOTCHA.md',
      mediaType: { format: 'raw' },
    });
    const gotchaContent = gotchaFile as unknown as string;

    const heuristicsMatch = gotchaContent.match(
      /## HEURISTICS\s*\n([\s\S]*?)(?=\n## [A-Z]|\n---|\$)/,
    );
    if (heuristicsMatch) {
      gotchaHeuristics = heuristicsMatch[1].trim();
    }
  } catch {
    // No GOTCHA.md
  }

  // 3. Recent errors (in production, query your log aggregator)
  // This is a placeholder — replace with your actual log source
  const recentErrors =
    'Connect to log aggregator API to fetch recent errors.';

  return {
    serviceName,
    serviceDescription,
    dependencies,
    tags,
    recentDeployments,
    recentErrors,
    gotchaHeuristics,
  };
}

El Alert Router

// plugins/ai-incident/src/router.ts
import { Router, json } from 'express';
import type { LoggerService, AuthService } from '@backstage/backend-plugin-api';
import type { CatalogService } from '@backstage/plugin-catalog-node';
import { gatherIncidentContext } from './gather';

interface RouterOptions {
  logger: LoggerService;
  catalog: CatalogService;
  auth: AuthService;
  aiServiceUrl: string;
}

export async function createRouter(options: RouterOptions): Promise<Router> {
  const { logger, catalog, auth, aiServiceUrl } = options;
  const router = Router();
  router.use(json());

  // Webhook from monitoring system (Prometheus Alertmanager, Grafana, etc.)
  router.post('/webhook/alert', async (req, res) => {
    const { serviceName, alertTitle, severity, startedAt, errors } =
      req.body;

    logger.info(`Alert received: ${alertTitle} for ${serviceName}`);

    // Look up the service
    const credentials = await auth.getOwnServiceCredentials();
    const entities = await catalog.getEntities(
      {
        filter: {
          kind: 'Component',
          'metadata.name': serviceName,
        },
      },
      { credentials },
    );

    if (entities.items.length === 0) {
      logger.info(`No catalog entity for ${serviceName}`);
      res.status(200).json({ skipped: 'not in catalog' });
      return;
    }

    const entity = entities.items[0];

    // Gather context from multiple sources
    const context = await gatherIncidentContext(entity, logger);

    // Call AI service
    const aiRes = await fetch(`${aiServiceUrl}/api/incident/analyze`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        ...context,
        recentErrors: errors ?? context.recentErrors,
        alertTitle,
        severity,
        startedAt,
      }),
    });

    if (!aiRes.ok) {
      logger.error(`AI incident analysis failed: ${aiRes.status}`);
      res.status(500).json({ error: 'AI analysis failed' });
      return;
    }

    const analysis = await aiRes.json();

    logger.info(`Incident analysis complete for ${serviceName}`);

    res.status(200).json(analysis);
  });

  // Manual analysis from the Backstage UI
  router.post('/analyze', async (req, res) => {
    const { entityRef, alertTitle, errors } = req.body;

    // Parse entityRef (e.g. "component:default/invoice-api")
    const name = entityRef.split('/').pop();
    const credentials = await auth.getOwnServiceCredentials();
    const entities = await catalog.getEntities(
      {
        filter: {
          kind: 'Component',
          'metadata.name': name,
        },
      },
      { credentials },
    );

    if (entities.items.length === 0) {
      res.status(404).json({ error: 'Entity not found' });
      return;
    }

    const entity = entities.items[0];

    const context = await gatherIncidentContext(entity, logger);

    const aiRes = await fetch(`${aiServiceUrl}/api/incident/analyze`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        ...context,
        recentErrors: errors ?? context.recentErrors,
        alertTitle: alertTitle ?? 'Manual analysis',
        severity: 'unknown',
        startedAt: new Date().toISOString(),
      }),
    });

    const analysis = await aiRes.json();
    res.status(200).json(analysis);
  });

  return router;
}

El Componente Incident Card

Una card que aparece en la página de la entidad del servicio, permitiendo análisis bajo demanda:

// plugins/ai-incident/src/components/IncidentCard.tsx
import React, { useState } from 'react';
import {
  Button,
  TextField,
  Typography,
  CircularProgress,
} from '@material-ui/core';
import WarningIcon from '@material-ui/icons/Warning';
import { InfoCard } from '@backstage/core-components';
import { useEntity } from '@backstage/plugin-catalog-react';
import { useApi, fetchApiRef, discoveryApiRef } from '@backstage/core-plugin-api';

export const IncidentCard = () => {
  const { entity } = useEntity();
  const fetchApi = useApi(fetchApiRef);
  const discoveryApi = useApi(discoveryApiRef);
  const [errors, setErrors] = useState('');
  const [analysis, setAnalysis] = useState<string | null>(null);
  const [loading, setLoading] = useState(false);

  const entityRef = `component:default/${entity.metadata.name}`;

  const handleAnalyze = async () => {
    setLoading(true);
    setAnalysis(null);

    try {
      const baseUrl = await discoveryApi.getBaseUrl('ai-incident');
      const res = await fetchApi.fetch(`${baseUrl}/analyze`, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({
          entityRef,
          errors: errors || undefined,
        }),
      });

      if (!res.ok) {
        throw new Error(`Analysis failed: ${res.status}`);
      }

      const data = await res.json();
      setAnalysis(data.analysis);
    } catch {
      setAnalysis('Failed to analyze. Check the AI service connection.');
    } finally {
      setLoading(false);
    }
  };

  return (
    <InfoCard title="Incident Analysis" subheader="AI-assisted diagnosis">
      <TextField
        fullWidth
        multiline
        rows={3}
        variant="outlined"
        placeholder="Paste error messages or log excerpts here (optional)"
        value={errors}
        onChange={e => setErrors(e.target.value)}
        disabled={loading}
      />
      <Button
        variant="contained"
        color="primary"
        onClick={handleAnalyze}
        disabled={loading}
        startIcon={loading ? <CircularProgress size={16} /> : <WarningIcon />}
        style={{ marginTop: 8 }}
      >
        {loading ? 'Analyzing...' : 'Analyze Incident'}
      </Button>

      {analysis && (
        <Typography
          variant="body1"
          style={{
            whiteSpace: 'pre-wrap',
            marginTop: 16,
            padding: 16,
            borderRadius: 4,
            fontFamily: 'monospace',
            fontSize: '0.85rem',
          }}
        >
          {analysis}
        </Typography>
      )}
    </InfoCard>
  );
};

Cómo Se Ve un Análisis

El ingeniero de guardia abre invoice-api en Backstage. Pega el error: Npgsql.NpgsqlException: Failed to connect to 10.0.1.5:5432. Hace clic en “Analyze.” Y recibe:

## PROBABLE CAUSE

The most recent deployment (20 minutes ago, commit a3f7c2d) changed the
database connection string format. The commit message says "refactor config
to use Server= syntax." Npgsql uses `Host=`, not `Server=`.

## EVIDENCE

- Error: `NpgsqlException: Failed to connect` — indicates a connection-level failure
- Recent deployment: `a3f7c2d — refactor config to use Server= syntax` (deployed 3:08am)
- Error spike started at 3:08am — matches the deployment time exactly
- Service dependencies: PostgreSQL (from catalog)
- GOTCHA heuristic: "No secrets in code — all from environment variables" — the
  connection string should come from DB_CONNECTION env var

## SUGGESTED ACTIONS

1. **Immediate**: Revert deployment a3f7c2d or hotfix the connection string
   to use `Host=` instead of `Server=`
2. **Verify**: Check the DB_CONNECTION environment variable in Kubernetes —
   confirm the format uses `Host=`
3. **After fix**: Add a startup health check that validates the DB connection
   before accepting traffic

## RELATED SERVICES

- notification-service: consumes events from invoice-api via Service Bus.
  If invoice-api can't write to the DB, no events are published,
  so notifications will stop.

40 minutos de investigación, resueltos en 8 segundos. El ingeniero lee el análisis, confirma que tiene sentido, revierte el deployment. Listo.

Conectar con Fuentes de Logs Reales

El placeholder en gatherIncidentContext debería conectarse a tu log aggregator real. Aquí tienes un ejemplo con Grafana Loki:

async function fetchRecentErrors(
  serviceName: string,
  minutes: number = 30,
): Promise<string> {
  const lokiUrl = process.env.LOKI_URL;
  if (!lokiUrl) return 'Log aggregator not configured.';

  const query = encodeURIComponent(
    `{app="${serviceName}"} |~ "error|exception"`,
  );
  const end = Date.now() * 1_000_000; // nanoseconds
  const start = end - minutes * 60 * 1_000_000_000;

  const res = await fetch(
    `${lokiUrl}/loki/api/v1/query_range?query=${query}&start=${start}&end=${end}&limit=50`,
  );

  if (!res.ok) return 'Failed to query log aggregator.';

  const data = await res.json();
  const lines = data.data.result
    .flatMap((stream: { values: string[][] }) =>
      stream.values.map(v => v[1]),
    )
    .slice(0, 20);

  return lines.join('\n') || 'No recent errors found.';
}

Registrar el Plugin

En packages/backend/src/index.ts:

import { aiIncidentPlugin } from '@internal/plugin-ai-incident';

backend.add(aiIncidentPlugin);

El IncidentCard es un plugin de frontend separado (@internal/plugin-ai-incident-widget). Lo añades a la entity page:

import { IncidentCard } from '@internal/plugin-ai-incident-widget';

// In the overviewContent grid:
<Grid item md={6}>
  <IncidentCard />
</Grid>

El widget del frontend usa discoveryApiRef y fetchApiRef para llamar al backend — el mismo patrón que usamos en el widget Ask AI y en el Governance Dashboard.

Checklist

  • El endpoint /api/incident/analyze acepta contexto del servicio + errores y devuelve un análisis
  • El webhook de alertas recopila contexto del catálogo, commits de GitHub y GOTCHA.md
  • Análisis manual disponible desde la entity page con el IncidentCard
  • El análisis incluye causa probable, evidencia, acciones sugeridas y servicios relacionados
  • Integración con log aggregator (Loki, Application Insights, etc.) conectada
  • Plugin registrado en el backend de Backstage

Antes del Siguiente Artículo

Hemos construido seis funcionalidades de IA en la plataforma: enriquecimiento del catálogo, scaffolding inteligente, code review con contexto, RAG de documentación, governance dashboard e incident response. Cada una lee el catálogo. Cada una usa el prompt GOTCHA. Cada una registra su uso para governance.

Pero son plugins separados. El artículo final lo junta todo en una arquitectura de referencia — cómo se conectan estas piezas, cómo desplegarlas y cómo extender la plataforma con nuevas funcionalidades de IA.

Ese es el artículo 8: The Reference Architecture.

El código completo está en GitHub.

Troubleshooting

El IncidentCard muestra “Failed to analyze”

Asegúrate de que el servicio de IA está corriendo en el puerto 5100 y que forge.aiServiceUrl está configurado en app-config.yaml. El plugin del backend llama al servicio de IA directamente, no a través del proxy.

El webhook devuelve “not in catalog”

El serviceName en el body de la alerta tiene que coincidir con el metadata.name de un componente en el catálogo de Backstage. Compruébalo con curl http://localhost:7007/api/catalog/entities para ver qué entidades existen.

Los commits de GitHub no cargan

El plugin lee los commits del repo en github.com/project-slug. Asegúrate de que la annotation existe en el catalog-info.yaml de la entidad y que GITHUB_TOKEN está configurado.


Si esta serie te resulta útil, considera invitarme a un café.

Este es el artículo 7 de la serie AI-Native IDP. Anterior: The AI Governance Dashboard. Siguiente: The Reference Architecture.

Comments

Loading comments...