info-sistema.sh responia preguntes sobre un instant. Els registres responen preguntes sobre el temps: què va passar, quan va començar, quant va durar i si torna a passar. A Veloz Envíos hi ha dos fitxers que ningú no mira fins que alguna cosa falla —/var/log/veloz/acces.log i /var/log/veloz/app.log— i llavors algú obre un tail -f i endevina. Aquest projecte substitueix l'endevinació per analitza-logs.sh, l'eina més ambiciosa del curs en processament de text: agrega, dibuixa, detecta anomalies i emet un informe en text o JSON, sobre fitxers vius, rotats i comprimits.

Contingut

  1. Les preguntes que ha de respondre
  2. Requisits i decisions de disseny
  3. Llegir qualsevol registre: la funció obre_log
  4. Versió 1: comptar codis de resposta
  5. Per què awk per a acces.log i BASH_REMATCH per a app.log
  6. Filtres per data i per nivell
  7. Les agregacions en una sola passada
  8. L'histograma per hores
  9. Detecció d'anomalies
  10. L'informe: text i JSON
  11. Rendiment sobre un milió de línies
  12. Anonimitzar abans de compartir

  1. Les preguntes que ha de respondre

Un analitzador de registres no es dissenya llistant funcions, sinó escrivint les preguntes que algú farà a les tres de la matinada:

Pregunta Fitxer Què implica
Quantes peticions per hora, i quan va ser el pic? acces.log Agrupar per hora i dibuixar
Quines rutes i quines IP concentren el trànsit? acces.log Top-N amb recompte
Quina taxa d'errors 5xx tenim? acces.log Quocient, no valor absolut
Quin component genera més ERROR, i quan va aparèixer per primera i última vegada un patró? app.log Extreure el component i les marques de temps extremes
Hi ha una IP amb un nombre desproporcionat de peticions? acces.log Comparar contra la mediana

Requisits derivats: una sola passada per fitxer, suport de .1 i .2.gz, filtres per rang de dates i per nivell, sortida en text i JSON, i capacitat d'anonimitzar IP.

  1. Requisits i decisions de disseny

Tres decisions marquen tot l'script:

  1. Una passada, no sis. La versió ingènua fa un grep per cada mètrica: sis lectures del fitxer. Amb awk i arrays associatius (06-01) es calcula tot en una. A 08-02 vam mesurar per què: sobre un registre gran, la diferència és d'un ordre de magnitud, i no per awk en si, sinó per llegir el disc una vegada en lloc de sis.
  2. La lectura s'abstreu. La resta de l'script no ha de saber si el fitxer està comprimit: una funció obre_log retorna el flux i llestos.
  3. Recollida i presentació separades, igual que a 09-01: awk produeix línies metrica<TAB>clau<TAB>valor, i els formatadors les converteixen en informe de text o en JSON.

  1. Llegir qualsevol registre: la funció obre_log

Els registres rotats són acces.log, acces.log.1 i acces.log.2.gz. Tractar-los per separat duplicaria el codi; la solució és decidir el lector per l'extensió (05-01):

obre_log() {  # $1 = cami -> escriu el contingut a stdout
  local f=$1
  [[ -r $f ]] || { veloz_log_error "no llegible: $f"; return 1; }
  case $f in
    *.gz) zcat -- "$f" ;;
    *)    cat  -- "$f" ;;
  esac
}

expandeix_logs() {  # $1 = cami base -> rotats de mes antic a mes nou
  local base=$1 f
  for f in "$base".[0-9]*.gz "$base".[0-9]* "$base"; do
    [[ -e $f ]] && printf '%s\n' "$f"
  done
}

# consum, amb substitucio de processos (05-05)
while IFS= read -r fitxer; do
  obre_log "$fitxer" || continue
done < <(expandeix_logs "$LOG") | awk -f "$LIBEXEC/acces.awk"

El case sobre el nom és la tècnica de 04-05 i evita executar file. Els -- protegeixen davant de noms que comencin per guionet (08-03). L'ordre d'expandeix_logs no és casual —de més antic a més nou, perquè l'informe surti cronològic sense haver d'ordenar després—. I es fa servir done < <(...) i no expandeix_logs | while per la raó de 05-05: en una canonada, el while corre en una subshell i qualsevol comptador que incrementi es perd en acabar.

  1. Versió 1: comptar codis de resposta

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

La primera versió útil cap en una línia i ja respon a mitja pregunta: $9 és el codi de resposta en el format combinat. A partir d'aquí, cada millora afegeix una mètrica al mateix awk en lloc d'afegir un grep nou; aquest és tot el mètode de construcció d'aquesta lliçó.

  1. Per què awk per a acces.log i BASH_REMATCH per a app.log

Els dos fitxers tenen formats molt diferents i mereixen eines diferents:

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

acces.log és posicional: camps separats per espais, sempre el mateix nombre, la ruta a $7, el codi a $9, els bytes a $10. Això és exactament per al que existeix awk (06-01): partir per camps és gratis i el volum és alt.

app.log és semiestructurat: la part fixa és la data, l'hora i el nivell; la resta és prosa on el component apareix abans dels dos punts, però no sempre. Aquí convé una expressió regular amb captura (05-04):

analitza_app() {
  local linia data hora nivell comp
  declare -A per_nivell per_component
  local re='^([0-9]{4}-[0-9]{2}-[0-9]{2}) ([0-9:]{8}) \[(INFO|WARN|ERROR)\] ([a-z.]+):'
  while IFS= read -r linia; do
    [[ $linia =~ $re ]] || { ((sense_format++)); continue; }
    data=${BASH_REMATCH[1]}; hora=${BASH_REMATCH[2]}
    nivell=${BASH_REMATCH[3]}; comp=${BASH_REMATCH[4]}
    [[ -n $DES_DE && $data < $DES_DE ]] && continue
    ((per_nivell[$nivell]++)); ((per_component[$comp]++))
  done
}

La regla pràctica: awk quan el format és tabular i el volum alt; =~ amb BASH_REMATCH quan cal capturar trossos d'una línia irregular i decidir coses diferents segons el que s'ha capturat. I un advertiment mesurat a 08-02: aquest bucle processa unes 50.000 línies per segon, mentre que awk supera el milió. Per a app.log (milers de línies al dia) va de sobres; si creixés a milions, caldria portar el patró dins de l'awk.

  1. Filtres per data i per nivell

Les opcions segueixen el patró de 09-01, amb una validació explícita de la data abans de fer-la servir:

valida_data() {
  [[ $1 =~ ^[0-9]{4}-[0-9]{2}-[0-9]{2}$ ]] || veloz_morir 2 "data invalida: $1 (feu servir AAAA-MM-DD)"
  date -d "$1" >/dev/null 2>&1 || veloz_morir 2 "data inexistent: $1"
}

Dues comprovacions perquè calen totes dues: la regex descarta 03/08/2026 i date -d descarta 2026-02-31, que té la forma correcta i no existeix. Filtrar per data amb comparació de cadenes ([[ $data < $DES_DE ]]) funciona només perquè el format AAAA-MM-DD ordena igual com a text que com a data; és la raó per la qual aquest format es va triar a 07-04 i una de les millors decisions que pot prendre qui dissenya un registre. A acces.log, en canvi, la data ve com a 03/Aug/2026, així que l'awk la normalitza amb una taula de mesos:

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 amb una classe de caràcters com a separador (06-01) parteix [03/Aug/2026:10:15:22 d'una tacada, sense sed previ ni canonades addicionals.

  1. Les agregacions en una sola passada

El nucli del projecte viu a libexec/acces.awk, no en una cadena encastada entre cometes: així és més llegible, shellcheck no s'hi barallarà i es pot provar per separat.

$0 ~ /^$/ { next }
{
  split($4, t, /[:\[\/]/); iso = t[4] "-" num[t[3]] "-" t[2]; hora = t[5]
  if (des_de != "" && iso < des_de) next
  if (fins != "" && iso > fins) next
  total++; bytes += $10
  ruta = $7; sub(/\?.*/, "", ruta)     # agrupa /envios?ciudad=X com a /envios
  per_hora[iso " " hora]++; per_ruta[ruta]++; per_ip[$1]++
  cod = substr($9, 1, 1) "xx"; per_codi[cod]++
  if (cod == "5xx") { err5++; if (primer5 == "") primer5 = iso " " hora }
}
END {
  printf "resum\ttotal\t%d\nresum\tbytes\t%d\n", total, bytes
  printf "resum\ttaxa5xx\t%.2f\n", (total ? err5 * 100 / total : 0)
  printf "resum\tprimer5xx\t%s\n", (primer5 == "" ? "-" : primer5)
  for (k in per_hora)  printf "hora\t%s\t%d\n", k, per_hora[k]
  for (k in per_ruta)  printf "ruta\t%s\t%d\n", k, per_ruta[k]
  for (k in per_ip)    printf "ip\t%s\t%d\n",  k, per_ip[k]
  for (k in per_codi)  printf "codi\t%s\t%d\n", k, per_codi[k]
}

Tota la feina passa en una sola lectura. sub(/\?.*/, "", ruta) agrupa la cadena de consulta, perquè si no /envios?ciudad=Valencia i /envios?ciudad=Madrid compten com a rutes diferents i el top-N s'omple de soroll. substr($9,1,1) "xx" converteix 503 en 5xx amb una operació de cadena en lloc d'una cascada d'if. I la sortida és el format intern de tres columnes: metrica, clau, valor, que en Bash es recull amb mapfile o es filtra amb awk -F'\t' '$1=="ruta"'.

Ordenar el top-N es fa fora, on és barat i amb les eines adequades: top_n() { awk -F'\t' -v m="$1" '$1 == m {print $3 "\t" $2}' "$CRU" | sort -rn | head -n "$2"; }.

  1. L'histograma per hores

Un número per hora s'entén malament; una barra s'entén d'un cop d'ull. La tècnica és escalar el valor màxim a una amplada fixa:

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

${f%%$'\t'*} i ${f##*$'\t'} parteixen la línia pel tabulador sense llançar cut (04-04, i l'estalvi de processos de 08-02). L'aritmètica entera val * amplada / max es fa multiplicant abans de dividir: a l'inrevés, val / max seria 0 per a tot i totes les barres sortirien buides, un dels errors clàssics de 04-06. I el max ? ... : 0 evita la divisió per zero quan el rang filtrat no té dades.

  1. Detecció d'anomalies

Tres deteccions simples cobreixen la majoria dels incidents reals de Veloz Envíos:

detecta_anomalies() {
  local taxa ip peticions mediana
  taxa=$(awk -F'\t' '$1=="resum" && $2=="taxa5xx" {print $3}' "$CRU")
  awk -v t="$taxa" -v u="$LLINDAR_5XX" 'BEGIN {exit !(t+0 > u)}' &&
    avis "taxa de 5xx del ${taxa}% (llindar ${LLINDAR_5XX}%) des de $(primer5xx)"
  mediana=$(top_n ip 1000 | awk '{v[NR]=$1} END {print v[int(NR/2)]+0}')
  while read -r peticions ip; do
    (( mediana > 0 && peticions > mediana * 20 )) &&
      avis "IP $ip amb $peticions peticions (mediana $mediana): possible escaneig"
  done < <(top_n ip 5)
}

Comparar contra la mediana i no contra un número fix és el que fa que la detecció continuï sent vàlida quan el trànsit es dupliqui: un llindar absolut de «1000 peticions» s'ha de reajustar cada trimestre; «vint vegades la mediana» s'ajusta tot sol.

La tercera detecció, ràfega d'errors, es fa sobre app.log convertint l'hora a segons (04-06) i comprovant quants ERROR caben en una finestra:

rafega_errors() {  # N errors en menys de M segons
  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 "rafega:", n, "errors en", t[i]-t[i-n+1], "s fins a", $1, $2; exit } }'
}

mktime de GNU awk converteix 2026 08 03 10 15 22 a segons des de l'època, i gensub prepara aquest format substituint guionets i dos punts per espais (06-01). Restar posicions separades n-1 a l'array és la finestra lliscant més barata possible.

  1. L'informe: text i JSON

L'informe de text compon les peces ja construïdes; el JSON surt del mateix fitxer intermedi:

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

jq -Rn amb inputs llegeix línies crues (06-05), group_by agrupa per mètrica i from_entries converteix cada grup en un objecte. El tonumber? // .valor manté els números com a números i deixa les cadenes intactes —el ? evita que jq avorti davant d'un valor no numèric—. Que el JSON surti del mateix fitxer intermedi que el text garanteix que tots dos informes diguin el mateix, que és el motiu d'haver separat recollida i presentació en els dos projectes.

  1. Rendiment sobre un milió de línies

Amb un registre real generat per a la prova, les xifres de 08-02 es confirmen:

Enfocament Temps (1.000.000 línies) Per què
Sis grep/cut/sort encadenats 11,4 s Sis lectures del fitxer
Bucle while read en Bash pur 96 s Un cicle de l'intèrpret per línia
Un sol awk 1,7 s Una lectura, tot en memòria
El mateix awk amb LC_ALL=C 0,9 s Sense descodificació UTF-8 per caràcter

Un export LC_ALL=C a dalt de l'script gairebé divideix per dos el temps i no canvia cap resultat, perquè les IP, les dates i els codis són ASCII. Mesurar abans d'optimitzar continua sent la regla: aquí la mesura diu que el coll d'ampolla era el nombre de passades, no el llenguatge.

  1. Anonimitzar abans de compartir

Una IP és una dada personal, i un informe que surt del servidor —a un tiquet, a un proveïdor, a un canal de xat— no n'ha de portar (08-03). lib/comu.sh ja té la peça:

veloz_anonimitza_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'; }

Emmascara l'últim octet (06-02): es conserva la xarxa, que és el que serveix per detectar l'escaneig, i es perd l'identificador de l'individu. L'script ho aplica amb --anonimitza, i el runbook diu que qualsevol informe que surti de la infraestructura passa per aquesta opció. Compte amb l'ordre: s'anonimitza en presentar, no en recollir, perquè agrupar per IP truncada ajuntaria equips diferents.

Errors Habituals i Consells

  • Un grep per mètrica. És la causa número u de lentitud. Si l'script recorre el fitxer més d'una vegada, hi ha una agregació que hauria d'estar dins de l'awk.
  • Ordenar dins de l'awk. awk no garanteix l'ordre de for (k in array). Ordena fora amb sort -rn, o fes servir asorti si t'hi lligues a GNU awk.
  • Oblidar els rotats. Una anàlisi dels «últims set dies» que només llegeix acces.log menteix així que hi hagi rotació diària. expandeix_logs no és cap luxe.
  • Dividir abans de multiplicar a l'escala de l'histograma: totes les barres surten a zero (04-06). I no comparis dates ISO com a números: 2026-08-03 en aritmètica no és una data, compara-la com a cadena.
  • Consell: desa el fitxer intermedi de tres columnes en un temporal amb mktemp i trap EXIT (05-02). Permet recalcular vistes diferents sense rellegir el registre i és el que fa possible que text i JSON coincideixin.

Exercicis

  1. Top d'agents d'usuari. El format combinat porta el User-Agent a l'últim camp entre cometes. Afegeix la mètrica agent a l'awk agrupant per família (Chrome, Firefox, curl, bot) en lloc de per cadena completa.
  2. Mode --segueix. Afegeix un mode que analitzi en temps real amb tail -f, mostrant cada 10 segons les peticions de l'últim minut i avisant si la taxa de 5xx supera el llindar.
  3. Primera i última aparició. Afegeix --patro REGEX que informi de quantes vegades apareix el patró, amb la seva primera i última marca de temps.

Solucions

1. Dins de l'awk, l'agent és l'últim camp compost; s'extreu amb una regex sobre $0 i es classifica amb 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" : "altre"
  per_agent[fam]++ }

Agrupar per família és el que fa útil el top: sense agrupar, cada versió menor de Chrome seria una fila diferent.

2. El mode segueix no pot fer servir l'awk amb END, perquè END no arriba mai. Es processa per finestres:

segueix() {
  local -a finestra=(); local ara
  tail -F -n0 "$LOG" | while IFS= read -r linia; do
    ara=$(date +%s); finestra+=("$ara ${linia}")
    finestra=("${finestra[@]/#$((ara - 60))*/}")   # descarta el que ja es vell
    (( ara % 10 == 0 )) && resumeix_finestra "${finestra[@]}"
  done
}

Es fa servir tail -F (majúscula) i no -f: segueix el fitxer pel nom, així que sobreviu a la rotació de logrotate, exactament l'escenari de 07-04.

3. --patro es resol amb un awk de tres línies: $0 ~ p { n++; if (pri=="") pri = marca; ult = marca } END { print n, pri, ult }, passant el patró amb -v p="$PATRO" i mai interpolant-lo dins del programa, que seria la injecció de 08-03 aplicada a awk.

Conclusió

analitza-logs.sh ja respon les sis preguntes de l'apartat 1 sobre fitxers vius, rotats i comprimits, amb filtres per data i nivell, histograma, tres deteccions d'anomalia i informe en text i JSON. Les lliçons del projecte són quatre. Una sola passada: el cost és en rellegir el fitxer, no en el llenguatge, i la mesura de 08-02 ho va confirmar amb un factor de deu. Cada eina en el seu format: awk per al que és tabular i voluminós, BASH_REMATCH per capturar de línies irregulars on cal decidir. Un format intermedi explícit (metrica<TAB>clau<TAB>valor) que permet que l'informe de text i el JSON no puguin contradir-se. I anonimitzar en presentar, no en recollir, per no perdre la informació que fa falta per detectar l'atac. Pel camí s'han fet servir zcat i comodins de rotats (05-01), done < <(...) (05-05), expansions de cadena en lloc de cut (04-04), aritmètica entera amb cura de l'ordre (04-06), jq -Rn amb group_by (06-05) i sed -E per a l'emmascarament (06-02).

A 09-03 canvia el risc. Un informe equivocat es corregeix; una còpia de seguretat equivocada es descobreix el dia que cal restaurar i ja no hi ha res a fer. Portarem copia.sh —l'esbós de 07-03— a un sistema complet amb perfils configurables, manifest i verificació, retenció amb promoció, còpia remota, xifratge i una prova de restauració automàtica setmanal, sota el principi que una còpia de seguretat no verificada no existeix.

Curs de Programació en Bash

Mòdul 1: Introducció a Bash

Mòdul 2: Ordres Bàsiques de Bash

Mòdul 3: Fonaments de Scripting

Mòdul 4: Scripting Intermedi

Mòdul 5: Tècniques Avançades de Scripting

Mòdul 6: Treballar amb Eines Externes

Mòdul 7: Automatització i Programació

Mòdul 8: Bones Pràctiques i Optimització

Mòdul 9: Projectes del Món Real

© Copyright 2026. Tots els drets reservats