info-sistema.sh respondía preguntas sobre un instante. Los logs responden preguntas sobre el tiempo: qué pasó, cuándo empezó, cuánto duró y si vuelve a pasar. En Veloz Envíos hay dos ficheros que nadie mira hasta que algo falla —/var/log/veloz/acceso.log y /var/log/veloz/app.log— y entonces alguien abre un tail -f y adivina. Este proyecto sustituye la adivinación por analiza-logs.sh, la herramienta más ambiciosa del curso en procesamiento de texto: agrega, dibuja, detecta anomalías y emite un informe en texto o JSON, sobre ficheros vivos, rotados y comprimidos.

Contenido

  1. Las preguntas que debe responder
  2. Requisitos y decisiones de diseño
  3. Leer cualquier log: la función abre_log
  4. Versión 1: contar códigos de respuesta
  5. Por qué awk para acceso.log y BASH_REMATCH para app.log
  6. Filtros por fecha y por nivel
  7. Las agregaciones en una sola pasada
  8. El histograma por horas
  9. Detección de anomalías
  10. El informe: texto y JSON
  11. Rendimiento sobre un millón de líneas
  12. Anonimizar antes de compartir

  1. Las preguntas que debe responder

Un analizador de logs no se diseña listando funciones, sino escribiendo las preguntas que alguien hará a las tres de la mañana:

Pregunta Fichero Qué implica
¿Cuántas peticiones por hora, y cuándo fue el pico? acceso.log Agrupar por hora y dibujar
¿Qué rutas y qué IPs concentran el tráfico? acceso.log Top-N con conteo
¿Qué tasa de errores 5xx tenemos? acceso.log Cociente, no valor absoluto
¿Qué componente genera más ERROR, y cuándo apareció por primera y última vez un patrón? app.log Extraer el componente y las marcas de tiempo extremas
¿Hay una IP con un número desproporcionado de peticiones? acceso.log Comparar contra la mediana

Requisitos derivados: una sola pasada por fichero, soporte de .1 y .2.gz, filtros por rango de fechas y por nivel, salida en texto y JSON, y capacidad de anonimizar IPs.

  1. Requisitos y decisiones de diseño

Tres decisiones marcan todo el script:

  1. Una pasada, no seis. La versión ingenua hace un grep por cada métrica: seis lecturas del fichero. Con awk y arrays asociativos (06-01) se calcula todo en una. En 08-02 medimos por qué: sobre un log grande, la diferencia es de un orden de magnitud, y no por awk en sí, sino por leer el disco una vez en lugar de seis.
  2. La lectura se abstrae. El resto del script no debe saber si el fichero está comprimido: una función abre_log devuelve el flujo y ya está.
  3. Recolección y presentación separadas, igual que en 09-01: awk produce líneas metrica<TAB>clave<TAB>valor, y los formateadores las convierten en informe de texto o en JSON.

  1. Leer cualquier log: la función abre_log

Los logs rotados son acceso.log, acceso.log.1 y acceso.log.2.gz. Tratarlos por separado duplicaría el código; la solución es decidir el lector por la extensión (05-01):

abre_log() {  # $1 = ruta -> escribe el contenido en stdout
  local f=$1
  [[ -r $f ]] || { veloz_log_error "no legible: $f"; return 1; }
  case $f in
    *.gz) zcat -- "$f" ;;
    *)    cat  -- "$f" ;;
  esac
}

expande_logs() {  # $1 = ruta base -> rotados de mas antiguo a mas nuevo
  local base=$1 f
  for f in "$base".[0-9]*.gz "$base".[0-9]* "$base"; do
    [[ -e $f ]] && printf '%s\n' "$f"
  done
}

# consumo, con sustitucion de procesos (05-05)
while IFS= read -r fichero; do
  abre_log "$fichero" || continue
done < <(expande_logs "$LOG") | awk -f "$LIBEXEC/acceso.awk"

El case sobre el nombre es la técnica de 04-05 y evita ejecutar file. Los -- protegen ante nombres que empiecen por guion (08-03). El orden de expande_logs no es casual —de más antiguo a más nuevo, para que el informe salga cronológico sin ordenar después—. Y se usa done < <(...) y no expande_logs | while por la razón de 05-05: en una tubería, el while corre en una subshell y cualquier contador que incremente se pierde al terminar.

  1. Versión 1: contar códigos de respuesta

abre_log /var/log/veloz/acceso.log | awk '{c[$9]++} END {for (k in c) print c[k], k}' | sort -rn

La primera versión útil cabe en una línea y ya responde a media pregunta: $9 es el código de respuesta en el formato combinado. A partir de aquí, cada mejora añade una métrica al mismo awk en lugar de añadir un grep nuevo; ese es todo el método de construcción de esta lección.

  1. Por qué awk para acceso.log y BASH_REMATCH para app.log

Los dos ficheros tienen formatos muy distintos y merecen herramientas distintas:

203.0.113.7 - - [03/Aug/2026:10:15:22 +0200] "GET /envios?ciudad=Valencia HTTP/1.1" 200 1843
2026-08-03 10:15:22 [ERROR] api.envios: timeout consultando repartidor jruiz

acceso.log es posicional: campos separados por espacios, siempre el mismo número, la ruta en $7, el código en $9, los bytes en $10. Eso es exactamente para lo que existe awk (06-01): partir por campos es gratis y el volumen es alto.

app.log es semiestructurado: la parte fija es la fecha, la hora y el nivel; el resto es prosa donde el componente aparece antes de los dos puntos, pero no siempre. Aquí conviene una expresión regular con captura (05-04):

analiza_app() {
  local linea fecha hora nivel comp
  declare -A por_nivel por_componente
  local re='^([0-9]{4}-[0-9]{2}-[0-9]{2}) ([0-9:]{8}) \[(INFO|WARN|ERROR)\] ([a-z.]+):'
  while IFS= read -r linea; do
    [[ $linea =~ $re ]] || { ((sin_formato++)); continue; }
    fecha=${BASH_REMATCH[1]}; hora=${BASH_REMATCH[2]}
    nivel=${BASH_REMATCH[3]}; comp=${BASH_REMATCH[4]}
    [[ -n $DESDE && $fecha < $DESDE ]] && continue
    ((por_nivel[$nivel]++)); ((por_componente[$comp]++))
  done
}

La regla práctica: awk cuando el formato es tabular y el volumen alto; =~ con BASH_REMATCH cuando hay que capturar trozos de una línea irregular y decidir cosas distintas según lo capturado. Y una advertencia medida en 08-02: este bucle procesa unas 50.000 líneas por segundo, mientras awk supera el millón. Para app.log (miles de líneas al día) sobra; si creciera a millones, habría que llevar el patrón dentro de awk.

  1. Filtros por fecha y por nivel

Las opciones siguen el patrón de 09-01, con una validación explícita de la fecha antes de usarla:

valida_fecha() {
  [[ $1 =~ ^[0-9]{4}-[0-9]{2}-[0-9]{2}$ ]] || veloz_morir 2 "fecha invalida: $1 (use AAAA-MM-DD)"
  date -d "$1" >/dev/null 2>&1 || veloz_morir 2 "fecha inexistente: $1"
}

Dos comprobaciones porque hacen falta las dos: la regex descarta 03/08/2026 y date -d descarta 2026-02-31, que tiene forma correcta y no existe. Filtrar por fecha con comparación de cadenas ([[ $fecha < $DESDE ]]) funciona solo porque el formato AAAA-MM-DD ordena igual como texto que como fecha; es la razón por la que ese formato se eligió en 07-04 y una de las mejores decisiones que puede tomar quien diseña un log. En acceso.log, en cambio, la fecha viene como 03/Aug/2026, así que el awk la normaliza con una tabla de meses:

BEGIN { split("Jan Feb Mar Apr May Jun Jul Aug Sep Oct Nov Dec", m, " ")
        for (i in m) num[m[i]] = sprintf("%02d", i) }
{ split($4, t, /[:\[\/]/); iso = t[4] "-" num[t[3]] "-" t[2]; hora = t[5] }

split con una clase de caracteres como separador (06-01) parte [03/Aug/2026:10:15:22 de una vez, sin sed previo ni tuberías adicionales.

  1. Las agregaciones en una sola pasada

El núcleo del proyecto vive en libexec/acceso.awk, no en una cadena embebida entre comillas: así es más legible, shellcheck no se pelea con él y se puede probar por separado.

$0 ~ /^$/ { next }
{
  split($4, t, /[:\[\/]/); iso = t[4] "-" num[t[3]] "-" t[2]; hora = t[5]
  if (desde != "" && iso < desde) next
  if (hasta != "" && iso > hasta) next
  total++; bytes += $10
  ruta = $7; sub(/\?.*/, "", ruta)     # agrupa /envios?ciudad=X como /envios
  por_hora[iso " " hora]++; por_ruta[ruta]++; por_ip[$1]++
  cod = substr($9, 1, 1) "xx"; por_codigo[cod]++
  if (cod == "5xx") { err5++; if (primer5 == "") primer5 = iso " " hora }
}
END {
  printf "resumen\ttotal\t%d\nresumen\tbytes\t%d\n", total, bytes
  printf "resumen\ttasa5xx\t%.2f\n", (total ? err5 * 100 / total : 0)
  printf "resumen\tprimer5xx\t%s\n", (primer5 == "" ? "-" : primer5)
  for (k in por_hora)   printf "hora\t%s\t%d\n", k, por_hora[k]
  for (k in por_ruta)   printf "ruta\t%s\t%d\n", k, por_ruta[k]
  for (k in por_ip)     printf "ip\t%s\t%d\n",  k, por_ip[k]
  for (k in por_codigo) printf "codigo\t%s\t%d\n", k, por_codigo[k]
}

Todo el trabajo ocurre en una lectura. sub(/\?.*/, "", ruta) agrupa la cadena de consulta, porque si no /envios?ciudad=Valencia y /envios?ciudad=Madrid cuentan como rutas distintas y el top-N se llena de ruido. substr($9,1,1) "xx" convierte 503 en 5xx con una operación de cadena en lugar de una cascada de if. Y la salida es el formato interno de tres columnas: metrica, clave, valor, que en Bash se recoge con mapfile o se filtra con awk -F'\t' '$1=="ruta"'.

Ordenar el top-N se hace fuera, donde es barato y con las herramientas adecuadas: top_n() { awk -F'\t' -v m="$1" '$1 == m {print $3 "\t" $2}' "$CRUDO" | sort -rn | head -n "$2"; }.

  1. El histograma por horas

Un número por hora se entiende mal; una barra se entiende de un vistazo. La técnica es escalar el valor máximo a un ancho fijo:

repite() { local i s=''; for ((i = 0; i < $2; i++)); do s+=$1; done; printf '%s' "$s"; }
histograma() {  # lee "etiqueta<TAB>valor" por stdin
  local ancho=40 max=0 etiq val f filas=()
  mapfile -t filas
  for f in "${filas[@]}"; do val=${f##*$'\t'}; ((val > max)) && max=$val; done
  for f in "${filas[@]}"; do
    etiq=${f%%$'\t'*}; val=${f##*$'\t'}
    printf '  %-16s %6d %s\n' "$etiq" "$val" "$(repite '█' "$(( max ? val * ancho / max : 0 ))")"
  done
}

${f%%$'\t'*} y ${f##*$'\t'} parten la línea por el tabulador sin lanzar cut (04-04, y el ahorro de procesos de 08-02). La aritmética entera val * ancho / max se hace multiplicando antes de dividir: al revés, val / max sería 0 para todo y todas las barras saldrían vacías, uno de los errores clásicos de 04-06. Y el max ? ... : 0 evita la división por cero cuando el rango filtrado no tiene datos.

  1. Detección de anomalías

Tres detecciones simples cubren la mayoría de los incidentes reales de Veloz Envíos:

detecta_anomalias() {
  local tasa ip peticiones mediana
  tasa=$(awk -F'\t' '$1=="resumen" && $2=="tasa5xx" {print $3}' "$CRUDO")
  awk -v t="$tasa" -v u="$UMBRAL_5XX" 'BEGIN {exit !(t+0 > u)}' &&
    aviso "tasa de 5xx del ${tasa}% (umbral ${UMBRAL_5XX}%) desde $(primer5xx)"
  mediana=$(top_n ip 1000 | awk '{v[NR]=$1} END {print v[int(NR/2)]+0}')
  while read -r peticiones ip; do
    (( mediana > 0 && peticiones > mediana * 20 )) &&
      aviso "IP $ip con $peticiones peticiones (mediana $mediana): posible escaneo"
  done < <(top_n ip 5)
}

Comparar contra la mediana y no contra un número fijo es lo que hace que la detección siga siendo válida cuando el tráfico se duplique: un umbral absoluto de «1000 peticiones» hay que reajustarlo cada trimestre; «veinte veces la mediana» se ajusta solo.

La tercera detección, ráfaga de errores, se hace sobre app.log convirtiendo la hora a segundos (04-06) y comprobando cuántos ERROR caben en una ventana:

rafaga_errores() {  # N errores en menos de M segundos
  awk -v n="$1" -v m="$2" '
    $3 == "[ERROR]" { t[++i] = mktime(gensub(/[-:]/, " ", "g", $1 " " $2))
      if (i >= n && t[i] - t[i-n+1] <= m) { print "rafaga:", n, "errores en", t[i]-t[i-n+1], "s hasta", $1, $2; exit } }'
}

mktime de GNU awk convierte 2026 08 03 10 15 22 a segundos desde época, y gensub prepara ese formato sustituyendo guiones y dos puntos por espacios (06-01). Restar posiciones separadas n-1 en el array es la ventana deslizante más barata posible.

  1. El informe: texto y JSON

El informe de texto compone las piezas ya construidas; el JSON sale del mismo fichero intermedio:

informe_json() {
  jq -Rn --slurpfile _ /dev/null '
    [inputs | split("\t") | {metrica: .[0], clave: .[1], valor: .[2]}]
    | group_by(.metrica)
    | map({(.[0].metrica): map({key: .clave, value: (.valor | tonumber? // .valor)}) | from_entries})
    | add' < "$CRUDO"
}

jq -Rn con inputs lee líneas crudas (06-05), group_by agrupa por métrica y from_entries convierte cada grupo en un objeto. El tonumber? // .valor mantiene los números como números y deja las cadenas intactas —el ? evita que jq aborte ante un valor no numérico—. Que el JSON salga del mismo fichero intermedio que el texto garantiza que ambos informes digan lo mismo, que es el motivo de haber separado recolección y presentación en los dos proyectos.

  1. Rendimiento sobre un millón de líneas

Con un log real generado para la prueba, las cifras de 08-02 se confirman:

Enfoque Tiempo (1.000.000 líneas) Por qué
Seis grep/cut/sort encadenados 11,4 s Seis lecturas del fichero
Bucle while read en Bash puro 96 s Un ciclo del intérprete por línea
Un solo awk 1,7 s Una lectura, todo en memoria
El mismo awk con LC_ALL=C 0,9 s Sin decodificación UTF-8 por carácter

Un export LC_ALL=C arriba del script casi divide por dos el tiempo y no cambia ningún resultado, porque las IPs, las fechas y los códigos son ASCII. Medir antes de optimizar sigue siendo la regla: aquí la medición dice que el cuello de botella era el número de pasadas, no el lenguaje.

  1. Anonimizar antes de compartir

Una IP es un dato personal, y un informe que sale del servidor —a un ticket, a un proveedor, a un canal de chat— no debe llevarlas (08-03). lib/comun.sh ya tiene la pieza:

veloz_anonimiza_log() { sed -E 's/\b([0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3})\.[0-9]{1,3}\b/\1.0/g'; }

Enmascara el último octeto (06-02): se conserva la red, que es lo que sirve para detectar el escaneo, y se pierde el identificador del individuo. El script lo aplica con --anonimizar, y el runbook dice que cualquier informe que salga de la infraestructura pasa por esa opción. Ojo con el orden: se anonimiza al presentar, no al recolectar, porque agrupar por IP truncada juntaría equipos distintos.

Errores Comunes y Consejos

  • Un grep por métrica. Es la causa número uno de lentitud. Si el script recorre el fichero más de una vez, hay una agregación que debería estar dentro del awk.
  • Ordenar dentro de awk. awk no garantiza el orden de for (k in array). Ordena fuera con sort -rn, o usa asorti si te atas a GNU awk.
  • Olvidar los rotados. Un análisis de «los últimos siete días» que solo lee acceso.log miente en cuanto haya rotación diaria. expande_logs no es un lujo.
  • Dividir antes de multiplicar en la escala del histograma: todas las barras salen a cero (04-06). Y no compares fechas ISO como números: 2026-08-03 en aritmética no es una fecha, compárala como cadena.
  • Consejo: guarda el fichero intermedio de tres columnas en un temporal con mktemp y trap EXIT (05-02). Permite recalcular vistas distintas sin releer el log y es lo que hace posible que texto y JSON coincidan.

Ejercicios

  1. Top de agentes de usuario. El formato combinado lleva el User-Agent en el último campo entre comillas. Añade la métrica agente al awk agrupando por familia (Chrome, Firefox, curl, bot) en lugar de por cadena completa.
  2. Modo --seguir. Añade un modo que analice en tiempo real con tail -f, mostrando cada 10 segundos las peticiones del último minuto y avisando si la tasa de 5xx supera el umbral.
  3. Primera y última aparición. Añade --patron REGEX que informe de cuántas veces aparece el patrón, con su primera y última marca de tiempo.

Soluciones

1. Dentro del awk, el agente es el último campo compuesto; se extrae con una regex sobre $0 y se clasifica con una cascada:

{ match($0, /"[^"]*"$/); ua = substr($0, RSTART+1, RLENGTH-2)
  fam = (ua ~ /bot|spider/) ? "bot" : (ua ~ /curl|wget/) ? "cli" :
        (ua ~ /Firefox/) ? "firefox" : (ua ~ /Chrome/) ? "chrome" : "otro"
  por_agente[fam]++ }

Agrupar por familia es lo que hace útil el top: sin agrupar, cada versión menor de Chrome sería una fila distinta.

2. El modo seguir no puede usar el awk con END, porque END nunca llega. Se procesa por ventanas:

seguir() {
  local -a ventana=(); local ahora
  tail -F -n0 "$LOG" | while IFS= read -r linea; do
    ahora=$(date +%s); ventana+=("$ahora ${linea}")
    ventana=("${ventana[@]/#$((ahora - 60))*/}")   # descarta lo viejo
    (( ahora % 10 == 0 )) && resume_ventana "${ventana[@]}"
  done
}

Se usa tail -F (mayúscula) y no -f: sigue el fichero por nombre, así que sobrevive a la rotación de logrotate, exactamente el escenario de 07-04.

3. --patron se resuelve con un awk de tres líneas: $0 ~ p { n++; if (pri=="") pri = marca; ult = marca } END { print n, pri, ult }, pasando el patrón con -v p="$PATRON" y nunca interpolándolo en el programa, que sería la inyección de 08-03 aplicada a awk.

Conclusión

analiza-logs.sh responde ya las seis preguntas del apartado 1 sobre ficheros vivos, rotados y comprimidos, con filtros por fecha y nivel, histograma, tres detecciones de anomalía e informe en texto y JSON. Las lecciones del proyecto son cuatro. Una sola pasada: el coste está en releer el fichero, no en el lenguaje, y la medición de 08-02 lo confirmó con un factor de diez. Cada herramienta en su formato: awk para lo tabular y voluminoso, BASH_REMATCH para capturar de líneas irregulares donde hay que decidir. Un formato intermedio explícito (metrica<TAB>clave<TAB>valor) que permite que el informe de texto y el JSON no puedan contradecirse. Y anonimizar al presentar, no al recolectar, para no perder la información que hace falta para detectar el ataque. Por el camino se han usado zcat y globbing de rotados (05-01), done < <(...) (05-05), expansiones de cadena en lugar de cut (04-04), aritmética entera con cuidado del orden (04-06), jq -Rn con group_by (06-05) y sed -E para el enmascarado (06-02).

En 09-03 cambia el riesgo. Un informe equivocado se corrige; un respaldo equivocado se descubre el día que hay que restaurar y ya no hay nada que hacer. Llevaremos respaldo.sh —el esbozo de 07-03— a un sistema completo con perfiles configurables, manifiesto y verificación, retención con promoción, copia remota, cifrado y una prueba de restauración automática semanal, bajo el principio de que un respaldo no verificado no existe.

Curso de Programación en Bash

Módulo 1: Introducción a Bash

Módulo 2: Comandos Básicos de Bash

Módulo 3: Fundamentos de Scripting

Módulo 4: Scripting Intermedio

Módulo 5: Técnicas Avanzadas de Scripting

Módulo 6: Trabajando con Herramientas Externas

Módulo 7: Automatización y Programación

Módulo 8: Mejores Prácticas y Optimización

Módulo 9: Proyectos del Mundo Real

© Copyright 2026. Todos los derechos reservados