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
- Las preguntas que debe responder
- Requisitos y decisiones de diseño
- Leer cualquier log: la función
abre_log - Versión 1: contar códigos de respuesta
- Por qué
awkparaacceso.logyBASH_REMATCHparaapp.log - Filtros por fecha y por nivel
- Las agregaciones en una sola pasada
- El histograma por horas
- Detección de anomalías
- El informe: texto y JSON
- Rendimiento sobre un millón de líneas
- Anonimizar antes de compartir
- 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.
- Requisitos y decisiones de diseño
Tres decisiones marcan todo el script:
- Una pasada, no seis. La versión ingenua hace un
greppor cada métrica: seis lecturas del fichero. Conawky 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 porawken sí, sino por leer el disco una vez en lugar de seis. - La lectura se abstrae. El resto del script no debe saber si el fichero está comprimido: una función
abre_logdevuelve el flujo y ya está. - Recolección y presentación separadas, igual que en 09-01:
awkproduce líneasmetrica<TAB>clave<TAB>valor, y los formateadores las convierten en informe de texto o en JSON.
- Leer cualquier log: la función
abre_log
abre_logLos 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.
- Versión 1: contar códigos de respuesta
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.
- Por qué
awk para acceso.log y BASH_REMATCH para app.log
awk para acceso.log y BASH_REMATCH para app.logLos 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.
- 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.
- 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"; }.
- 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.
- 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.
- 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.
- 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.
- 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:
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
greppor 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 delawk. - Ordenar dentro de
awk.awkno garantiza el orden defor (k in array). Ordena fuera consort -rn, o usaasortisi te atas a GNU awk. - Olvidar los rotados. Un análisis de «los últimos siete días» que solo lee
acceso.logmiente en cuanto haya rotación diaria.expande_logsno 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-03en aritmética no es una fecha, compárala como cadena. - Consejo: guarda el fichero intermedio de tres columnas en un temporal con
mktempytrap EXIT(05-02). Permite recalcular vistas distintas sin releer el log y es lo que hace posible que texto y JSON coincidan.
Ejercicios
- Top de agentes de usuario. El formato combinado lleva el
User-Agenten el último campo entre comillas. Añade la métricaagentealawkagrupando por familia (Chrome, Firefox, curl, bot) en lugar de por cadena completa. - Modo
--seguir. Añade un modo que analice en tiempo real contail -f, mostrando cada 10 segundos las peticiones del último minuto y avisando si la tasa de 5xx supera el umbral. - Primera y última aparición. Añade
--patron REGEXque 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
- ¿Qué es Bash?
- Configurando tu Entorno
- Navegación Básica en la Línea de Comandos
- Entendiendo el Shell
- Encontrar Ayuda: man, help y --help
Módulo 2: Comandos Básicos de Bash
- Operaciones con Archivos y Directorios
- Comandos de Procesamiento de Texto
- Permisos y Propiedad de Archivos
- Redirección y Tuberías
- Comodines y Expansión de Rutas
- Historial y Atajos de Teclado
Módulo 3: Fundamentos de Scripting
- Creando y Ejecutando un Script
- Variables y Constantes
- Operadores Básicos
- Sentencias Condicionales
- Argumentos y Entrada del Usuario
- Comillas, Expansión y Sustitución
Módulo 4: Scripting Intermedio
- Bucles en Bash
- Funciones en Bash
- Arrays y Arrays Asociativos
- Manipulación de Cadenas
- La Sentencia case y los Menús Interactivos
- Aritmética y Cálculos Numéricos
Módulo 5: Técnicas Avanzadas de Scripting
- Operaciones Avanzadas con Archivos
- Gestión de Procesos
- Manejo de Errores y Depuración
- Expresiones Regulares
- Entrada/Salida Avanzada: Descriptores y Here-Documents
- Scripts Modulares y Librerías Reutilizables
Módulo 6: Trabajando con Herramientas Externas
Módulo 7: Automatización y Programación
- Trabajos Cron
- Automatizando Tareas
- Scripts de Respaldo y Restauración
- Monitoreo y Registro
- Servicios y Temporizadores con systemd
- Automatización Remota con SSH
Módulo 8: Mejores Prácticas y Optimización
- Escribiendo Código Legible
- Optimizando Scripts en Bash
- Consideraciones de Seguridad
- Control de Versiones con Git
- Análisis Estático con ShellCheck y shfmt
- Pruebas Automatizadas con Bats
- Portabilidad: POSIX sh frente a Bashismos
