La lliçó anterior va deixar CicloUrbana vigilada: Prometheus recull, Grafana dibuixa i Alertmanager avisa. Però les alertes acaben sempre a la mateixa frase. A les quatre de la matinada arriba un missatge que diu «taxa d'error del 3 % a l'inici de lloguer», i la pregunta immediata —què ha fallat exactament— no la respon cap gràfic. Una mètrica sap comptar; no sap explicar què va passar.

Això ho respon el segon pilar de l'observabilitat, i és el que fem servir sense tractar-lo seriosament des de la primera lliçó: el registre d'esdeveniments. Fins ara el log de CicloUrbana és el que Spring Boot porta de fàbrica més un patró amb el rastreId que vam afegir a 03-06. Serveix en un portàtil. En producció, amb tres instàncies a Kubernetes escrivint alhora, és una cinta de text que ningú no pot consultar.

Aquesta lliçó el converteix en una eina. Veurem la pila de logging de Spring Boot i per què mai no es programa contra la implementació, els nivells amb una política concreta per a cada capa de CicloUrbana, la configuració per propietats i el logback-spring.xml complet amb consola llegible a dev i JSON a prod, els logs estructurats que converteixen un text en una dada consultable, el MDC que fa que totes les línies d'una petició comparteixin identificador, el que mai s'ha d'escriure sobre els ciutadans de Ribalta, el cost real del logging i l'agregació centralitzada que permet buscar a les tres instàncies com si fossin una.

Contingut

  1. Què és un log i en què es diferencia d'una mètrica
  2. La pila de logging de Spring Boot
  3. Obtenir un logger
  4. Els nivells i la política de CicloUrbana
  5. Missatges parametritzats
  6. Configuració per propietats
  7. logback-spring.xml i els perfils
  8. Logs estructurats en JSON
  9. Context: el MDC i el rastreId
  10. Què no registrar mai
  11. El cost del logging
  12. Agregació centralitzada
  13. Loki i LogQL
  14. Correlacionar logs de diverses instàncies
  15. Auditoria de seguretat davant del logging d'aplicació
  16. Provar els logs
  17. Errors Comuns i Consells
  18. Exercicis

  1. Què és un log i en què es diferencia d'una mètrica

Un log és un esdeveniment discret amb context: una cosa que va passar, quan, on i amb quines dades. Una mètrica és una agregació numèrica: quantes vegades, quant va trigar. La diferència no és de format, és de pregunta.

Mètrica Log
Unitat Un número per interval Un esdeveniment
Respon a Quants? Quant? Des de quan? Què va passar exactament?
Cardinalitat Ha de ser baixa (09-03) Alta: cada línia és única
Identificadors concrets Prohibits com a etiqueta La seva raó de ser
Cost Constant amb el trànsit Proporcional al trànsit
Retenció Mesos o anys Dies o setmanes
Per alertar Sí Només per taxes

La relació entre tots dos és de complement exacte: el que a 09-03 estava prohibit posar en una etiqueta —l'identificador de l'usuari, el del lloguer, la URI concreta— és precisament el que ha d'anar al log. La mètrica diu «el 3 % dels inicis de lloguer falla»; el log diu «el lloguer de l'usuari 4711 a l'estació 2 va fallar perquè la passarel·la va retornar TARGETA_CADUCADA».

Un bon log de producció compleix tres condicions que el log per defecte no compleix: és consultable (es pot filtrar per camps, no només buscar text), és correlacionable (totes les línies d'una petició comparteixen identificador) i és segur (no conté res que no hauria de sortir del sistema). Els tres apartats centrals d'aquesta lliçó són aquests tres requisits.

  1. La pila de logging de Spring Boot

flowchart LR
    A[El teu codi<br/>log.info] --> B[SLF4J<br/>facana]
    C[Spring, Hibernate,<br/>HikariCP] --> B
    D[Biblioteques amb JCL,<br/>JUL o Log4j] --> E[Ponts<br/>jcl-over-slf4j, jul-to-slf4j]
    E --> B
    B --> F[Logback<br/>per defecte]
    B -.alternativa.-> G[Log4j2]
    F --> H1[Consola<br/>stdout]
    F --> H2[Fitxer<br/>rotat]
    F --> H3[JSON<br/>agregador]

SLF4J és la façana: defineix Logger, LoggerFactory i els mètodes trace/debug/info/warn/error. Logback és la implementació per defecte d'spring-boot-starter-logging, inclòs a tots els starters. Els ponts redirigeixen a SLF4J el que les biblioteques antigues escriuen amb altres APIs, que és la raó per la qual el log de CicloUrbana és homogeni encara que Hibernate, HikariCP i Spring facin servir mecanismes diferents.

Per què es programa contra SLF4J Conseqüència
El codi no depèn de la implementació Canviar a Log4j2 no toca una línia de negoci
Les biblioteques també la fan servir Un únic fitxer de configuració governa tot
Els missatges parametritzats són d'SLF4J L'apartat 5 no existeix fora de la façana
És el que Spring Boot configura Tot funciona sense escriure res

Canviar a Log4j2 —que aporta appenders asíncrons molt eficients i una configuració una mica més potent— és substituir una dependència: s'exclou spring-boot-starter-logging del starter web i s'afegeix spring-boot-starter-log4j2.

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
    <exclusions>
        <exclusion>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-logging</artifactId>
        </exclusion>
    </exclusions>
</dependency>

La decisió de CicloUrbana és Logback, el predeterminat: és suficient, està més ben documentat a l'ecosistema Spring i els seus <springProfile> (apartat 7) són una comoditat real. Log4j2 es justifica amb volums de log molt alts.

  1. Obtenir un logger

package com.ciclourbana.lloguers;

public class LloguerService {
    private static final Logger log = LoggerFactory.getLogger(LloguerService.class);
    // ...
}

Tres detalls que no són estètics. static final: un logger per classe, no per instància. Es passa la classe, no una cadena: així el nom del logger coincideix amb el nom complet de la classe, que és el que permet configurar nivells per paquet (logging.level.com.ciclourbana.lloguers: DEBUG). I s'importen org.slf4j.Logger i org.slf4j.LoggerFactory, no les de Logback: importar ch.qos.logback.classic.Logger trenca l'abstracció sencera.

Lombok evita la línia repetida amb @Slf4j, que genera exactament aquest camp. És còmode i molt estès; CicloUrbana el declara explícit perquè es vegi d'on surt log, però totes dues opcions són correctes.

  1. Els nivells i la política de CicloUrbana

Nivell Significat Algú ha d'actuar? En producció?
ERROR Alguna cosa va fallar i no es va poder complir la petició Sí, ara o demà Sí
WARN Alguna cosa anòmala que s'ha pogut gestionar Vigilar; si es repeteix, sí Sí
INFO Fita rellevant del negoci o del cicle de vida No Sí
DEBUG Detall per diagnosticar No No (temporalment sí)
TRACE Detall exhaustiu No Mai

La política de nivells de CicloUrbana, per capa:

Controladors. Res al camí feliç: http.server.requests de 09-03 ja compta i cronometra cada petició molt millor que una línia de log. Registrar «entrant al mètode X» a cada petició és duplicar una mètrica al triple de cost.

Serveis de domini. INFO a les fites de negoci: lloguer iniciat, lloguer finalitzat amb import, bicicleta posada en manteniment. Són els esdeveniments que un operari de l'ajuntament entendria, i els que es consulten en investigar. DEBUG per al detall del càlcul.

Errors de negoci esperats. Un usuari que ja té un lloguer en curs, un saldo insuficient: WARN o fins i tot INFO, mai ERROR. Són el sistema funcionant, i el GestorGlobalExcepcions de 03-06 ja retorna el ProblemDetail correcte. Si es registren com a ERROR, l'alerta de «pic d'errors» es dispara cada dia i deixa de creure's.

Errors inesperats. ERROR amb l'excepció completa, al gestor global i en un sol lloc. Aquest és el nivell que alimenta l'alerta sobre logback_events_total{level="error"}.

Integracions (07-06). WARN a cada reintent, ERROR en esgotar-los, INFO en obrir-se i tancar-se el tallacircuits.

I la regla més important de totes, que mereix ser explícita: un catch que només fa e.printStackTrace() és un error greu. Escriu a System.err, sense marca de temps, sense nivell, sense logger, sense rastreId i sense passar per cap configuració: no es pot filtrar, no es pot desactivar, no arriba a l'agregador i no es pot correlacionar. Pitjor encara és el catch buit, que fa desaparèixer el problema. Un catch legítim fa una de tres coses: rellança una excepció de domini, la registra amb log.error("...", e) passant l'excepció com a últim argument —mai e.getMessage(), que perd la traça de pila— o la ignora amb un comentari que expliqui per què.

try {
    passarela.cobrar(idLloguer, importTotal);
} catch (PassarelaNoDisponibleException e) {         // l'excepcio, com a ultim argument
    log.warn("Cobrament diferit del lloguer {}: la passarela no respon", idLloguer, e);
    cobramentsPendents.encuar(idLloguer, importTotal);
}

  1. Missatges parametritzats

log.debug("Lloguer {} iniciat per l'usuari {} a l'estacio {}", id, idUsuari, idEstacio);

Mai així:

log.debug("Lloguer " + id + " iniciat per l'usuari " + idUsuari);   // MALAMENT

La raó és de rendiment i és concreta. A la versió amb +, la concatenació passa sempre, abans de cridar el mètode, encara que el nivell DEBUG estigui desactivat: es creen objectes String, s'invoquen toString() i es genera brossa que el GC haurà de recollir, tot per descartar-ho. A la versió parametritzada, SLF4J rep la plantilla i l'array d'arguments, comprova el nivell i només si està actiu construeix el missatge. En un mètode que s'executa cent vegades per segon amb DEBUG apagat, la diferència és real i apareix als perfilatges com una torre inesperada sota StringBuilder.append.

Tres detalls útils: el marcador és {} i no admet índexs, així que l'ordre importa; l'excepció va com a últim argument sense {} (log.error("Fallada en cobrar {}", id, e)), i SLF4J la detecta i escriu la traça de pila; i si construir un argument és car de debò —serialitzar un objecte gran—, es protegeix amb if (log.isDebugEnabled()), que en qualsevol altre cas és soroll innecessari.

  1. Configuració per propietats

Per a la majoria dels casos no cal un fitxer XML: n'hi ha prou amb el YAML de 02-04.

logging:
  level:
    root: INFO
    com.ciclourbana.lloguers: DEBUG            # nomes el paquet que s'investiga
    org.hibernate.SQL: WARN
  pattern:
    console: "%d{HH:mm:ss.SSS} %-5level [%X{rastreId:-sense-rastre}] %logger{36} - %msg%n"
    file: "%d{yyyy-MM-dd HH:mm:ss.SSS} %-5level [%thread] [%X{rastreId:-}] %logger - %msg%n"
  file.name: /var/log/ciclourbana/aplicacio.log
  logback.rollingpolicy:
    max-file-size: 100MB
    max-history: 7            # dies d'historic
    total-size-cap: 2GB       # tope dur del directori
    file-name-pattern: ${LOG_FILE}.%d{yyyy-MM-dd}.%i.gz    # comprimit

Els elements del patró: %d data, %-5level nivell alineat a cinc caràcters, %thread el fil, %X{rastreId:-sense-rastre} llegeix el MDC amb valor per defecte després de :-, %logger{36} el nom abreujat a 36 caràcters, %msg el missatge i %n el salt de línia.

La política de rotació mereix atenció perquè és la que evita l'incident clàssic. Sense total-size-cap, un DEBUG oblidat omple el disc, i un disc ple atura l'aplicació: escriure un log deixa de ser una operació innòcua. Les tres proteccions es combinen —mida per fitxer, dies d'història i límit total— i el .gz redueix el text al voltant del 90 %.

I una limitació de la qual neix l'apartat següent: les propietats no permeten condicionar per perfil ni definir diverses destinacions amb formats diferents. Per tenir consola llegible a dev i JSON a prod cal l'XML.

  1. logback-spring.xml i els perfils

El nom importa: logback-spring.xml (i no logback.xml) és el que carrega Spring Boot, i només aquest permet <springProfile> i <springProperty>, perquè l'altre el llegeix Logback abans que existeixi el context de Spring.

<?xml version="1.0" encoding="UTF-8"?>
<configuration scan="false">

    <!-- Porta els valors per defecte de Spring Boot: colors, conversors, CONSOLE_LOG_PATTERN -->
    <include resource="org/springframework/boot/logging/logback/defaults.xml"/>

    <springProperty scope="context" name="APP" source="spring.application.name"/>
    <springProperty scope="context" name="ENTORN" source="spring.profiles.active"/>

    <!-- ============ dev i test: consola llegible per humans ============ -->
    <springProfile name="dev,test,local">
        <appender name="CONSOLA" class="ch.qos.logback.core.ConsoleAppender">
            <encoder>
                <pattern>%clr(%d{HH:mm:ss.SSS}){faint} %clr(%-5level) %clr([%X{rastreId:-sense-rastre}]){magenta} %clr(%logger{36}){cyan} - %msg%n</pattern>
            </encoder>
        </appender>
        <root level="INFO"><appender-ref ref="CONSOLA"/></root>
        <logger name="com.ciclourbana" level="DEBUG"/>
    </springProfile>

    <!-- ============ pre i prod: JSON a stdout ============ -->
    <springProfile name="pre,prod">
        <appender name="JSON" class="ch.qos.logback.core.ConsoleAppender">
            <encoder class="net.logstash.logback.encoder.LogstashEncoder">
                <includeMdcKeyName>rastreId</includeMdcKeyName>
                <includeMdcKeyName>usuariId</includeMdcKeyName>
                <fieldNames>
                    <timestamp>@timestamp</timestamp>
                    <message>missatge</message>
                </fieldNames>
                <customFields>{"aplicacio":"${APP}","entorn":"${ENTORN}"}</customFields>
                <throwableConverter class="net.logstash.logback.stacktrace.ShortenedThrowableConverter">
                    <maxDepthPerThrowable>30</maxDepthPerThrowable>
                    <exclude>^sun\.reflect\..*</exclude>
                </throwableConverter>
            </encoder>
        </appender>

        <!-- Esmorteeix els pics d'E/S sense bloquejar el fil de la peticio -->
        <appender name="ASYNC" class="ch.qos.logback.classic.AsyncAppender">
            <queueSize>2048</queueSize>
            <discardingThreshold>0</discardingThreshold>   <!-- no descartar WARN ni ERROR -->
            <neverBlock>true</neverBlock>                  <!-- si la cua s'omple, descartar -->
            <appender-ref ref="JSON"/>
        </appender>

        <root level="INFO"><appender-ref ref="ASYNC"/></root>
        <logger name="org.hibernate.SQL" level="WARN"/>
    </springProfile>
</configuration>

Les decisions, una a una. <include> dels valors per defecte evita reescriure els conversors i patrons de Spring Boot. <springProperty> porta valors de l'application.yml a l'XML, cosa que permet que el nom de l'aplicació i l'entorn viatgin a cada línia de JSON. %clr(...) acoloreix només a dev, on hi ha una persona mirant. A prod la destinació és la consola, no un fitxer: és la decisió de l'apartat 12 i del 12-Factor de 08-01. I AsyncAppender amb neverBlock: true decideix una cosa important per endavant: davant d'una allau, es prefereix perdre línies de log abans que frenar les peticions dels ciutadans; discardingThreshold: 0 garanteix que el que es descarti no siguin els WARN ni els ERROR.

  1. Logs estructurats en JSON

En producció, un log no és un text: és una dada. La diferència es veu comparant el mateix esdeveniment.

Text pla:

2026-08-31 08:14:22.481 INFO  [http-nio-8080-exec-7] [a3f19c2e] c.c.l.LloguerService - Lloguer 84213 iniciat per l'usuari 4711 a l'estacio 2 amb tarifa ESTUDIANT

El mateix esdeveniment en JSON:

{
  "@timestamp": "2026-08-31T08:14:22.481+02:00", "level": "INFO",
  "thread_name": "http-nio-8080-exec-7",
  "logger_name": "com.ciclourbana.lloguers.LloguerService",
  "missatge": "Lloguer 84213 iniciat per l'usuari 4711 a l'estacio 2 amb tarifa ESTUDIANT",
  "rastreId": "a3f19c2e", "aplicacio": "ciclourbana", "entorn": "prod",
  "idLloguer": 84213, "idEstacio": 2, "tarifa": "ESTUDIANT"
}

El que canvia: sobre el text pla només es poden fer cerques de subcadena i expressions regulars fràgils; sobre el JSON es pot consultar tarifa = "ESTUDIANT" AND level = "ERROR", agrupar per idEstacio, comptar per entorn i construir un panell. L'estructura converteix un arxiu en una base de dades consultable, i aquest és tot l'argument.

Spring Boot 3.4 porta suport natiu sense dependències:

logging.structured:
  format.console: ecs        # ecs (Elastic Common Schema), gelf o logstash
  ecs.service:
    name: ciclourbana
    version: ${APP_VERSION:desconeguda}
    environment: ${SPRING_PROFILES_ACTIVE:local}

L'alternativa, disponible des d'abans i encara més flexible, és logstash-logback-encoder (la de l'apartat 7):

<dependency>
    <groupId>net.logstash.logback</groupId>
    <artifactId>logstash-logback-encoder</artifactId>
    <version>7.4</version>
</dependency>
Natiu d'Spring Boot 3.4 logstash-logback-encoder
Dependències Cap Una
Configuració Propietats YAML XML de Logback
Formats ECS, GELF, Logstash Logstash i personalitzat
Camps propis logging.structured.json.add customFields, StructuredArguments
Control fi Limitat Total

Els camps idLloguer, idEstacio i tarifa de l'exemple surten d'arguments estructurats, que afegeixen camps al JSON sense embrutar el missatge:

import static net.logstash.logback.argument.StructuredArguments.kv;

log.info("Lloguer {} iniciat per l'usuari {} a l'estacio {} amb tarifa {}",
         kv("idLloguer", id), kv("idUsuari", idUsuari),
         kv("idEstacio", idEstacio), kv("tarifa", tarifa));

kv escriu el valor al missatge i l'afegeix com a camp indexable. És el que permet després comptar lloguers per estació des del mateix log, creuant amb les mètriques de 09-04.

  1. Context: el MDC i el rastreId

El MDC (Mapped Diagnostic Context) és un mapa associat al fil actual el contingut del qual s'afegeix a totes les línies que aquest fil escrigui. És el que converteix línies soltes en la història d'una petició.

CicloUrbana ja el fa servir des de 03-06: el FiltreRastreig genera un identificador, el posa al MDC sota la clau rastreId, el retorna en una capçalera i el retira en un finally —aquest MDC.remove() no és opcional, perquè el fil es reutilitza i l'identificador s'enganxaria a la petició següent—. Amb això, el patró %X{rastreId:-sense-rastre} i el <includeMdcKeyName>rastreId</includeMdcKeyName> del JSON fan la resta.

Val la pena afegir una mica més de context al mateix filtre, amb la mateixa disciplina de neteja:

MDC.put("rastreId", rastre);
MDC.put("metode", request.getMethod());
MDC.put("ruta", request.getRequestURI());
Optional.ofNullable(SecurityContextHolder.getContext().getAuthentication())
        .map(Authentication::getName)
        .ifPresent(nom -> MDC.put("usuariId", nom));          // identificador, NO el correu
try {
    cadena.doFilter(request, response);
} finally {
    MDC.clear();                        // imprescindible: el fil torna al pool
}

Propagar-lo als fils asíncrons. El MDC viu en un ThreadLocal, així que no viatja sol a l'executor d'@Async. Ja vam resoldre això a 07-03 amb el DecoradorMdc, un TaskDecorator que copia el mapa al fil cridant i el restaura al finally del fil treballador. La conseqüència pràctica és exactament la d'aquesta lliçó: la línia de log de l'enviament del correu de confirmació porta el mateix rastreId que la petició POST /api/v1/lloguers que el va originar, encara que s'escrigui deu segons després des d'un altre fil.

Per què això ho canvia tot: amb el rastreId a cada línia i en format JSON, una incidència s'investiga amb una sola consulta —rastreId: "a3f19c2e"— que retorna la història completa d'aquella petició, en ordre, incloses les línies de la feina asíncrona. Sense ell, cal reconstruir-la a partir de marques de temps entre milers de línies de peticions concurrents barrejades. La diferència entre cinc segons i una hora.

  1. Què no registrar mai

Advertiment. Els logs de CicloUrbana contenen dades de ciutadans de Ribalta i estan subjectes al RGPD. Un log no és un espai privat: es copia a un agregador, es replica, es desa setmanes, el consulten persones que no són de l'equip i sovint acaba en un servei de tercers. Tot el que s'hi escriu surt del sistema.

Mai Per què Què fer en el seu lloc
Contrasenyes, en clar o xifrades Compromís directe de comptes Res; ni tan sols la seva longitud
Tokens JWT (05-04) Qui llegeixi el log pot suplantar l'usuari El sub o l'identificador intern
Números de targeta, CVV Prohibit per PCI-DSS Els quatre últims dígits, si cal
Correu, telèfon, DNI, adreça Dades personals del RGPD L'identificador intern (usuariId: 4711)
Coordenades GPS del ciutadà Permeten seguir una persona L'estació, que ja és pública
Cossos complets de peticions Solen contenir tot l'anterior Camps concrets triats
Capçaleres Authorization, Cookie Credencials El nom de la capçalera, sense valor
e.getMessage() d'un error de base de dades Pot filtrar SQL i dades d'altres files Un missatge propi (03-06)

Emmascarament. Quan una dada ha d'aparèixer, s'emmascara abans de registrar-la:

public final class Emmascarador {

    public static String correu(String c) {           // [email protected] -> a***@ribalta.example
        if (c == null || !c.contains("@")) return "***";
        return c.charAt(0) + "***" + c.substring(c.indexOf('@'));
    }

    public static String targeta(String numero) {     // -> **** **** **** 4242
        return numero == null || numero.length() < 4 ? "****"
                : "**** **** **** " + numero.substring(numero.length() - 4);
    }
}

Per al cas que alguna cosa s'escapi, Logback permet un RegexReplaceRule o un MessageConverter propi que substitueixi patrons —números llargs de 16 dígits, cadenes que comencen per eyJ, típiques d'un JWT— abans d'escriure. És una xarxa de seguretat, no una excusa per relaxar la disciplina al codi.

Retenció i drets. Dues conseqüències del RGPD que sorprenen i que convé decidir aviat: els logs han de tenir un termini de conservació definit i aplicat —30 dies per al log d'aplicació és un valor habitual i defensable—, i si contenen dades personals, el dret de supressió d'un ciutadà arriba també als logs. La manera més barata de complir totes dues coses és la de la taula: registrar identificadors interns i no dades personals, amb la qual cosa el log deixa de ser un arxiu de dades personals i el problema desapareix en origen.

  1. El cost del logging

Escriure un log no és gratis i el seu cost té tres components: formatar el missatge, serialitzar a text o JSON i escriure a la destinació. El tercer domina, perquè una escriptura a disc o a un sòcol és E/S bloquejant al fil de la petició.

Els números orientatius, per línia: un INFO a consola costa de l'ordre de desenes de microsegons; amb AsyncAppender el lliurament baixa a unitats de microsegons, perquè el fil només encua. Sembla poc fins que es multiplica: deixar DEBUG activat al paquet d'Hibernate en producció produeix fàcilment vint línies per petició, i amb 100 peticions per segon són 2.000 línies per segon, desenes de megabytes per minut, un disc ple en hores i una latència notablement pitjor. És una de les causes més freqüents de degradació després d'un desplegament, i és enterament autoinfligida.

Tres mesures concretes. AsyncAppender (apartat 7), que desacobla el fil de la petició de l'escriptura, amb la decisió explícita de descartar abans que bloquejar. Missatges parametritzats (apartat 5), que eviten construir el que no s'escriurà. I /actuator/loggers de 07-01, que resol el dilema sencer:

# Pujar el detall d'un paquet concret, en calent, sense reiniciar
curl -X POST -u admin:*** -H 'Content-Type: application/json' \
     -d '{"configuredLevel":"DEBUG"}' \
     http://localhost:8081/actuator/loggers/com.ciclourbana.lloguers

# I tornar-lo al seu lloc en acabar: {"configuredLevel":null}

Aquesta és la manera correcta d'investigar en producció: INFO com a base permanent, DEBUG durant quinze minuts en un paquet concret i en una sola instància, i tornada enrere. Mai un DEBUG global «per veure què passa», i mai deixar-lo posat.

Convé a més vigilar el mateix log com una mètrica: logback_events_total{level="error"} de 09-03 permet alertar d'un pic d'errors a 09-04 sense llegir ni una línia, que és la manera barata de fer servir logs per alertar.

  1. Agregació centralitzada

Amb tres rèpliques a Kubernetes, cadascuna escriu les seves línies i cap no té la història completa. Pitjor: els contenidors són efímers, i quan el pod que va fallar desapareix, el seu log desapareix amb ell. L'agregació centralitzada resol totes dues coses.

El primer pas és una conseqüència del principi XI dels 12-Factor de 08-01: els logs són un flux d'esdeveniments i l'aplicació escriu a stdout. No gestiona fitxers, ni rotació, ni destinacions. Les raons:

  • El contenidor no té un disc durador: un fitxer dins del contenidor es perd a cada reinici i no el veu ningú.
  • La rotació ja la fa la plataforma; fer-la també a l'aplicació duplica feina i omple el disc del node.
  • Escriure a stdout fa que docker logs i kubectl logs funcionin, que és el primer que algú intentarà.
  • El recol·lector —Promtail, Fluent Bit, l'agent del núvol— llegeix aquesta sortida i afegeix metadades del pod que l'aplicació no coneix.

D'aquí que al logback-spring.xml el perfil prod faci servir un ConsoleAppender, encara que sembli contradictori amb la política de rotació de l'apartat 6: aquesta política és per a execucions fora de contenidor.

Pila Components Forta en Cost Quan
ELK / Elastic Elasticsearch + Logstash/Beats + Kibana Cerca de text completa i molt potent Alt: memòria i operació Volum gran i necessitat d'anàlisi
Grafana Loki Loki + Promtail + Grafana Indexa etiquetes, no contingut: barat Baix CicloUrbana: ja hi ha Grafana
CloudWatch Logs (08-03) Agent inclòs a ECS Zero operació a AWS Mitjà, per GB ingerit Tot a AWS
Datadog Logs Agent Integrat amb mètriques i APM Alt Ja es paga Datadog

L'elecció de CicloUrbana és Loki, per tres raons: el seu model d'indexació el fa molt barat d'operar, es consulta des del mateix Grafana de 09-04 —així que un panell pot mostrar mètriques i logs junts— i fa servir el mateix vocabulari d'etiquetes que Prometheus, cosa que redueix a la meitat el que cal aprendre.

  1. Loki i LogQL

# s'afegeix a docker-compose.observabilitat.yml de 09-04
  loki:
    image: grafana/loki:3.0.0
    command: ["-config.file=/etc/loki/local-config.yaml"]
    ports: ["3100:3100"]
    volumes: ["loki-dades:/loki"]
    networks: [xarxa-ciclourbana]

  promtail:
    image: grafana/promtail:3.0.0
    command: ["-config.file=/etc/promtail/config.yml"]
    volumes:
      - ./observabilitat/promtail.yml:/etc/promtail/config.yml:ro
      - /var/lib/docker/containers:/var/lib/docker/containers:ro
    depends_on: [loki]
    networks: [xarxa-ciclourbana]

I l'origen de dades aprovisionat a Grafana, al costat del de Prometheus:

  - name: Loki
    type: loki
    access: proxy
    url: http://loki:3100

Com funciona Loki. No indexa el contingut de les línies, només un petit conjunt d'etiquetes (app, entorn, pod, level). Primer selecciona per etiquetes —cosa que és instantània— i després filtra el text d'aquest subconjunt per força bruta. D'aquí que sigui barat i d'aquí també la seva regla d'or, idèntica a la de 09-03: les etiquetes han de ser de baixa cardinalitat; etiquetar per rastreId a Loki és el mateix desastre que etiquetar-hi a Prometheus.

LogQL comença com PromQL i hi afegeix filtres i anàlisi:

# 1. Totes les linies d'una peticio concreta: la consulta que resol incidencies
{app="ciclourbana", entorn="prod"} | json | rastreId = "a3f19c2e"

# 2. Errors d'una estacio concreta, fent servir els camps de StructuredArguments
{app="ciclourbana"} | json | level = "ERROR" | idEstacio = 2

# 3. De log a metrica: errors per segon i per pod
sum by (pod) (rate({app="ciclourbana"} | json | level = "ERROR" [5m]))

# 4. Els lloguers finalitzats per minut, comptats des del log
sum(count_over_time({app="ciclourbana"} |= "Lloguer finalitzat" [1m]))

# 5. Latencia extreta del mateix missatge
{app="ciclourbana"} | json | unwrap duradaMs | quantile_over_time(0.95, [5m])

Els operadors clau: |= i != filtren per subcadena, |~ per expressió regular, | json interpreta la línia JSON i n'exposa els camps —cosa que fa que l'apartat 8 rendeixi—, i rate, count_over_time i quantile_over_time converteixen logs en sèries temporals que es dibuixen al mateix panell que les mètriques de Prometheus.

Aquesta última capacitat és la que justifica l'elecció: en un sol quadre de comandament de Grafana es pot tenir el gràfic de latència p95 de 09-04 i, just a sota, les línies de log d'ERROR del mateix interval, sincronitzades en el temps. Detectar i entendre a la mateixa pantalla.

  1. Correlacionar logs de diverses instàncies

Amb Loki i el rastreId al MDC, investigar una incidència es converteix en un procediment de tres passos:

  1. El ciutadà o l'alerta aporta l'identificador. El GestorGlobalExcepcions de 03-06 ja retorna el rastreId al ProblemDetail i a la capçalera de resposta, així que qui truca a l'ajuntament pot llegir-lo a la seva pantalla.
  2. Una consulta LogQL amb aquest identificador retorna totes les línies d'aquella petició, de la instància que fos i del fil que fos, incloses les de la feina asíncrona gràcies al TaskDecorator.
  3. Es llegeix la història completa en ordre: entrada de la petició, decisions del domini, l'excepció amb la seva traça de pila, el cobrament diferit.

Sense correlació, aquesta mateixa feina consisteix a buscar per marca de temps aproximada entre les línies de tres pods barrejades, amb desenes de peticions concurrents. El rastreId és, amb diferència, la millor inversió per línia de codi de tot aquest mòdul.

Queda una limitació honesta: el rastreId de CicloUrbana només existeix dins de l'aplicació. Quan la petició surt cap a la passarel·la de pagaments (07-06), el proveïdor no el coneix i el seu log no el conté. I si demà el monòlit es divideix (07-05), cada servei generarà el seu i la correlació es trencarà a la frontera. La solució a això és un estàndard de propagació entre processos, i és exactament el tema de la lliçó següent.

  1. Auditoria de seguretat davant del logging d'aplicació

Els esdeveniments de seguretat de 05-05 —inicis de sessió, fallades d'autenticació, denegacions de @PreAuthorize, canvis de rol— semblen logs i no ho són del tot:

Log d'aplicació Registre d'auditoria
Propòsit Diagnosticar Retre comptes: qui va fer què i quan
Públic L'equip tècnic Seguretat, compliment, un jutge
Retenció Dies o setmanes Mesos o anys, per normativa
Es pot perdre Sí (descart de l'AsyncAppender) No: ha de ser fiable
Modificable Irrellevant Ha de ser inalterable
Volum Alt Baix
On viu Loki, 30 dies Taula a PostgreSQL o magatzem WORM

Per què convé separar-los. Un registre d'auditoria que es descarta quan la cua s'omple no serveix com a prova; un que caduca als 30 dies no compleix la normativa; i un barrejat entre milions de línies d'INFO no es pot lliurar a ningú. A CicloUrbana, els AuthenticationSuccessEvent, AuthenticationFailureBadCredentialsEvent i AuthorizationDeniedEvent de 05-05 s'escolten amb un @EventListener i es desen en una taula auditoria_seguretat —amb la seva migració de Flyway— i a més es registren com a WARN al log d'aplicació per poder veure'ls en context. El primer és la prova; el segon, la comoditat.

Un advertiment final que enllaça amb l'apartat 10: el registre d'auditoria sí que conté identificadors de persones, per definició. Precisament per això ha d'estar controlat —accés restringit, retenció definida— i no barrejat amb el log general al qual té accés tot l'equip.

  1. Provar els logs

Quan una línia de log és part del comportament esperat —un avís de seguretat, un error que alimenta una alerta— convé provar-la. JUnit 5 i Spring Boot ofereixen dues formes.

OutputCaptureExtension, que captura la sortida estàndard:

@ExtendWith(OutputCaptureExtension.class)
class LloguerServiceLogTest {
    @Test
    void avisaQuanDifereixElCobrament(CapturedOutput sortida) {
        lloguerService.finalitzar(84213L, new FinalitzarLloguerRequest(2L));
        assertThat(sortida).contains("Cobrament diferit del lloguer 84213")
                           .doesNotContain("4111111111111111");   // la targeta, mai
    }
}

ListAppender de Logback, més precís perquè inspecciona els esdeveniments en lloc del text:

class GestorGlobalExcepcionsTest {

    private final ListAppender<ILoggingEvent> appender = new ListAppender<>();
    private final Logger logger = (Logger) LoggerFactory.getLogger(GestorGlobalExcepcions.class);

    @BeforeEach
    void enganxarAppender() { appender.start(); logger.addAppender(appender); }

    @AfterEach
    void deslligarAppender() { logger.detachAppender(appender); }

    @Test
    void unErrorDeNegociNoEsRegistraComAError() {
        gestor.gestionar(new LloguerEnCursException(4711L));

        assertThat(appender.list).singleElement().satisfies(esdeveniment -> {
            assertThat(esdeveniment.getLevel()).isEqualTo(Level.WARN);   // WARN, no ERROR
            assertThat(esdeveniment.getFormattedMessage()).contains("4711");
        });
    }
}

La segona prova és la interessant: verifica el nivell, que és justament la decisió de l'apartat 4 i la que fa que l'alerta d'errors de 09-04 sigui creïble. I el doesNotContain de la primera és una manera barata i efectiva de convertir la política de l'apartat 10 en una prova automàtica: una prova que falla si algú registra una targeta.

Dos advertiments: ListAppender requereix el Logger de Logback i per això és l'única excepció a la regla de no importar la implementació; i cal deslligar l'appender al @AfterEach, perquè el logger és estàtic i es filtraria a les proves següents.

Errors Comuns i Consells

e.printStackTrace() o un catch buit. Sense nivell, sense marca de temps, sense rastreId, fora de tota configuració i sense arribar a l'agregador. Sempre log.error("missatge", e) amb l'excepció com a últim argument, o rellançar.

Concatenar en lloc de parametritzar. log.debug("Lloguer " + id) construeix la cadena encara que DEBUG estigui apagat. Sempre {}.

Registrar e.getMessage() en lloc de l'excepció. Es perd la traça de pila, que és l'única cosa que permet localitzar la fallada, i el missatge d'una excepció de base de dades pot filtrar dades.

Marcar com a ERROR els errors de negoci esperats. Un usuari amb lloguer en curs no és una fallada del sistema. Si es registren com a ERROR, l'alerta d'errors es dispara cada dia i l'equip deixa de mirar-la.

Registrar tokens, contrasenyes, correus o cossos complets. Un JWT al log permet suplantar un ciutadà de Ribalta, i el log es copia, es replica i el llegeix gent aliena a l'equip. Identificadors interns i emmascarament.

Oblidar MDC.clear(). El fil torna al pool amb el rastreId de la petició anterior i contamina el log justament quan més falta fa. Sempre al finally.

Deixar DEBUG posat en producció. Desenes de megabytes per minut, latència pitjor i disc ple. Es puja en calent amb /actuator/loggers, en un paquet i per una estona.

Escriure a fitxer dins d'un contenidor. El fitxer es perd amb el pod i ningú no el veu. A stdout, i que la plataforma el reculli (12-Factor, 08-01).

Consell: adopta JSON a pre i prod des del primer dia, amb consola acolorida només a dev. Migrar després obliga a refer totes les consultes de l'agregador.

Consell: posa total-size-cap i max-history sempre que escriguis a fitxer. Un disc ple atura l'aplicació, i és un incident evitable amb dues línies.

Consell: escriu una prova que falli si apareix una dada sensible al log. És l'única manera que la política de l'apartat 10 sobrevisqui a la rotació de l'equip.

Exercicis

Exercici 1: corregir un mètode

Aquest mètode concentra sis errors de logging dels tractats a la lliçó. Troba'ls, explica el risc de cadascun i escriu la versió corregida.

@PostMapping("/api/v1/lloguers")
public ResponseEntity<LloguerResponse> iniciar(@RequestBody IniciarLloguerRequest p,
                                               @RequestHeader("Authorization") String token) {
    log.info("Entrant a iniciar amb " + p.toString() + " i token " + token);
    try {
        LloguerResponse r = lloguerService.iniciar(p);
        log.info("Lloguer creat");
        return ResponseEntity.status(201).body(r);
    } catch (UsuariAmbLloguerEnCursException e) {
        log.error("Error: " + e.getMessage());
        throw e;
    } catch (Exception e) {
        e.printStackTrace();
        throw e;
    }
}

Exercici 2: la investigació

Són les 04:12. Alertmanager avisa: «Taxa d'error 4,1 % a prod». Tens Grafana amb Prometheus i Loki, tres rèpliques, logs en JSON amb rastreId, i el quadre de comandament de 09-04. Descriu el procediment complet d'investigació pas a pas, escrivint les consultes PromQL i LogQL concretes que executaries a cada pas i què decidiries segons el que retorni cadascuna.

Exercici 3: dissenyar la política de logs

L'ajuntament de Ribalta demana una política de logging per escrit abans de l'auditoria de protecció de dades. Redacta-la per a CicloUrbana cobrint: què es registra a cada capa i amb quin nivell, format i destinació per entorn, quines dades estan prohibides i com es garanteix, retenció de cada tipus de registre, qui hi té accés, i com es puja el detall per investigar sense desplegar. Justifica cada decisió.

Solucions

Solució 1.

Error 1 — concatenació en lloc de parametrització. "Entrant a iniciar amb " + p.toString() es construeix sempre, fins i tot amb INFO desactivat.

Error 2 — registrar el token. La fallada greu: qui llegeixi aquest log pot suplantar el ciutadà fins que el JWT caduqui, i el log es copia a l'agregador i el veu tot l'equip. És a més un incident de seguretat notificable.

Error 3 — abocar el cos complet de la petició. p.toString() pot contenir dades personals presents o futures; n'hi ha prou que algú afegeixi un camp al record perquè comenci a filtrar-se sense que ningú se n'adoni.

Error 4 — log d'entrada al controlador. Duplica el que http.server.requests de 09-03 ja mesura millor i més barat, i multiplica el volum pel trànsit.

Error 5 — ERROR per a un error de negoci, i només el missatge. Un usuari amb lloguer en curs és la regla de l'índex únic parcial de 04-08 funcionant: és WARN o INFO. I e.getMessage() perd la traça de pila.

Error 6 — e.printStackTrace(). Va a System.err sense nivell, sense marca de temps, sense rastreId i sense arribar a Loki. A més, aquest catch no aporta res: el GestorGlobalExcepcions de 03-06 ja centralitza el tractament i el registre.

Versió corregida:

@PostMapping("/api/v1/lloguers")
public ResponseEntity<LloguerResponse> iniciar(@RequestBody IniciarLloguerRequest p) {
    LloguerResponse r = lloguerService.iniciar(p);
    return ResponseEntity.status(201).body(r);
}

El controlador no registra res, que és el correcte: la mètrica compta la petició i el gestor global tracta els errors. La fita de negoci es registra on passa, al servei:

log.info("Lloguer iniciat {} {} {}",
         kv("idLloguer", lloguer.getId()),
         kv("idUsuari", usuari.getId()),            // identificador, mai el correu
         kv("idEstacio", peticio.estacioOrigenId()));

I el gestor global decideix el nivell segons la naturalesa de l'error: WARN sense traça de pila per als de negoci, ERROR amb l'excepció completa per als inesperats. El rastreId l'aporta el MDC del FiltreRastreig, sense que ningú l'escrigui.

Solució 2.

Pas 1 — quin endpoint i quina instància? A Grafana, sobre Prometheus:

sum by (uri, instance) (rate(http_server_requests_seconds_count{outcome="SERVER_ERROR"}[5m]))

Si els errors es reparteixen entre les tres instàncies, el problema és comú —codi, base de dades o un servei extern—; si es concentren en una, és aquella instància —memòria, disc, un pod degradat— i l'acció immediata pot ser treure-la del balancejador. Suposem que es reparteixen i que la uri és /api/v1/lloguers.

Pas 2 — des de quan i coincideix amb alguna cosa? S'amplia el rang a 6 hores i es compara l'inici de la pujada amb el panell de desplegaments o amb application_ready_time_seconds. Si va començar just després d'un desplegament, la hipòtesi principal és una regressió i l'acció és revertir (08-05) abans de continuar investigant: primer es restableix el servei.

Pas 3 — quin error concret? A Loki:

{app="ciclourbana", entorn="prod"} | json | level = "ERROR"

Es busca el patró dominant. Si apareixen desenes de PassarelaNoDisponibleException, la causa és externa; si apareix CannotAcquireLockException o timeouts de connexió, és la base de dades.

Pas 4 — quantificar per tipus d'error:

sum by (logger_name) (rate({app="ciclourbana"} | json | level = "ERROR" [5m]))

Converteix els logs en una sèrie temporal i diu quin component domina, en lloc de deduir-ho llegint.

Pas 5 — la història completa d'un cas. Es pren un rastreId d'una línia d'ERROR i:

{app="ciclourbana", entorn="prod"} | json | rastreId = "9c4b21fa"

Retorna la petició sencera, en ordre, incloses les línies asíncrones: què es va intentar, què va respondre el sistema extern, amb quin missatge i en quin punt es va trencar.

Pas 6 — confirmar amb les mètriques de la causa. Si el sospitós és la passarel·la: resilience4j_circuitbreaker_state{state="open"} i histogram_quantile(0.95, sum by (le) (rate(http_client_requests_seconds_bucket[5m]))). Si és el pool: hikaricp_connections_pending. L'objectiu d'aquest pas és no quedar-se amb la primera hipòtesi: els logs mostren el símptoma en un cas, les mètriques confirmen que és general.

Decisió: si la causa és externa i el tallacircuits està fent la seva feina, es documenta, s'avisa el proveïdor i es torna a dormir, perquè el respatller de 07-06 manté el servei. Si és una regressió pròpia, es reverteix. Si és la base de dades, es mira el postgres_exporter i pg_stat_activity a la recerca de bloqueigs. En els tres casos, la investigació ha durat minuts perquè les tres senyals estaven preparades per endavant.

Solució 3.

Què es registra i amb quin nivell. Controladors: res al camí feliç, perquè http.server.requests ja ho mesura. Serveis: INFO a les fites de negoci —lloguer iniciat, lloguer finalitzat amb import, bicicleta a manteniment, cobrament diferit—, amb identificadors interns com a arguments estructurats; DEBUG per al detall del càlcul, apagat per defecte. Errors de negoci esperats: WARN sense traça de pila. Errors inesperats: ERROR amb l'excepció completa, únicament al GestorGlobalExcepcions. Integracions: WARN per reintent, ERROR en esgotar-los, INFO als canvis d'estat del tallacircuits. Seguretat: els esdeveniments de 05-05 a la taula d'auditoria i a WARN.

Format i destinació. dev i test: consola acolorida i llegible, com.ciclourbana a DEBUG. pre i prod: JSON a stdout amb AsyncAppender, nivell arrel INFO, recollit per Promtail cap a Loki. Justificació: els humans llegeixen text i les màquines llegeixen JSON, i en contenidor l'única destinació sensata és la sortida estàndard (12-Factor).

Dades prohibides i garantia. Prohibides: contrasenyes, tokens, targetes, correus, telèfons, DNI, adreces, coordenades i cossos complets de peticions. Es registren identificadors interns. Garanties en tres capes: revisió de codi amb aquesta política com a criteri explícit; proves automàtiques que afirmen doesNotContain sobre dades sensibles als camins crítics; i un RegexReplaceRule a Logback que emmascara números de 16 dígits i cadenes amb aspecte de JWT, com a xarxa de seguretat i no com a substitut de l'anterior.

Retenció. Log d'aplicació a Loki: 30 dies, suficient per investigar i proporcionat per al RGPD. Auditoria de seguretat a PostgreSQL: 2 anys, amb accés restringit i sense esborrat. Mètriques a Prometheus: 30 dies, i agregats anuals en emmagatzematge de llarga durada si l'ajuntament demana informes. Com que el log d'aplicació no conté dades personals per disseny, la retenció de 30 dies no planteja conflicte amb el dret de supressió: només la taula d'auditoria ho fa, i la seva base legal és l'obligació de retre comptes.

Accés. Loki, a través de Grafana amb autenticació i integració amb el directori de l'ajuntament: tot l'equip tècnic. La taula d'auditoria: només el responsable de seguretat, i la seva consulta queda al seu torn registrada.

Investigar sense desplegar. /actuator/loggers de 07-01, protegit amb ADMIN al port 8081, permet pujar un paquet concret a DEBUG en calent. Procediment obligat: un sol paquet, una sola instància si és possible, un temps acotat i tornar-lo a null en acabar. Queda expressament prohibit un DEBUG global a prod, pel seu efecte sobre la latència i el disc.

Conclusió

El segon pilar és dret. Saps en què es diferencia un log d'una mètrica i per què són complements exactes: el que a 09-03 estava prohibit posar en una etiqueta és justament el que ha d'anar al log. Coneixes la pila de Spring Boot —SLF4J com a façana, Logback per defecte, els ponts que unifiquen el que escriuen Hibernate i HikariCP, i Log4j2 com a alternativa que es canvia amb una exclusió— i per què mai no es programa contra la implementació. Tens una política de nivells per capa, amb la decisió que fa creïble l'alerta d'errors de 09-04: els errors de negoci esperats no són ERROR. I tens tipificat l'error greu, e.printStackTrace(), amb les tres úniques coses legítimes que pot fer un catch.

Saps per què es parametritzen els missatges amb {} i què costa no fer-ho; configures el logging per propietats amb una política de rotació que impedeix omplir el disc; i tens el logback-spring.xml complet de CicloUrbana amb <springProfile>, consola acolorida a dev, JSON a stdout a prod i un AsyncAppender que decideix per endavant perdre línies abans que frenar els ciutadans. Entens per què en producció un log és una dada i no un text, amb el mateix esdeveniment comparat en tots dos formats, el suport natiu d'Spring Boot 3.4 (logging.structured.format.console: ecs) davant de logstash-logback-encoder, i els arguments estructurats que afegeixen camps consultables sense embrutar el missatge.

El MDC i el rastreId del FiltreRastreig de 03-06 —propagat als fils asíncrons pel TaskDecorator de 07-03— converteixen línies soltes en la història d'una petició, i amb Loki i LogQL aquesta història es recupera amb una sola consulta des del mateix Grafana on vius les mètriques, creuant rate sobre logs amb rate sobre mètriques al mateix panell. Tens la taula de piles d'agregació i la raó d'escriure a stdout en contenidor, la separació entre auditoria de seguretat i logging d'aplicació, i les proves amb OutputCaptureExtension i ListAppender que converteixen en verificable tant el nivell d'un esdeveniment com l'absència de dades sensibles. I, per damunt de tot, l'advertiment que no admet matisos: contrasenyes, tokens, targetes i dades personals dels ciutadans de Ribalta mai no entren en un log, perquè el log es copia, es replica, es desa setmanes i el llegeix gent aliena a l'equip.

Queda el límit que va aparèixer al final de l'apartat 14 i que cap de les dues senyals no pot superar. El rastreId de CicloUrbana existeix només dins de l'aplicació: quan la petició surt cap a la passarel·la de pagaments de 07-06, el proveïdor no el coneix; si demà el monòlit es divideix en serveis (07-05), cadascun generarà el seu i la correlació es trencarà a la frontera. I hi ha una pregunta que ni les mètriques ni els logs responen bé: quan una petició triga dos segons i travessa el controlador, tres consultes, una memòria cau i una crida remota, on van anar aquests dos segons? Ni l'agregat d'una mètrica ni una successió de línies amb marques de temps ho diuen amb precisió. L'última lliçó del mòdul, Traçabilitat Distribuïda, respon a totes dues coses: spans i traces, propagació del context amb traceparent de W3C, Micrometer Tracing en lloc del descontinuat Sleuth, spans propis amb l'Observation API de 09-03, exemplars que salten d'un punt d'un gràfic a la traça concreta, i un backend on veure la cascada completa d'un lloguer i assenyalar amb el dit el span que es va menjar el 80 % del temps.

Curs de Spring Boot

Mòdul 1: Introducció a Spring Boot

Mòdul 2: Conceptes bàsics de Spring Boot

Mòdul 3: Construint serveis web RESTful

Mòdul 4: Accés a dades amb Spring Boot

Mòdul 5: Seguretat a Spring Boot

Mòdul 6: Proves a Spring Boot

Mòdul 7: Funcions avançades de Spring Boot

Mòdul 8: Desplegament d'aplicacions Spring Boot

Mòdul 9: Rendiment i monitoratge

Mòdul 10: Millors pràctiques i consells

© Copyright 2026. Tots els drets reservats