perf sentinelperf sentineldocs
FRENGitHub
Documentation / 06 · Ingestion & daemon

Ingestion et mode daemon

Ce sujet existe en version animée : voir l’étage Ingestion dans le synoptique →

Conversion OTLP

Conception en deux passes

convert_otlp_request() traite la requête en deux phases :

Passe 1 : Construction de l'index des spans :

rust
// Pseudocode : les index nommés couvrent les blocs, les anonymes restent locaux.
let per_service = build_span_indexes(request); // services nommés, tous blocs

Passe 2 : Conversion des spans I/O :

rust
// Pseudocode
for resource_spans in &request.resource_spans {
    let span_index = named_service_index_or_anonymous_block_index(resource_spans);
    convert_resource_spans(resource_spans, span_index, &mut events);
}

Pourquoi deux passes ? Dans OTLP, un span parent peut apparaître après son enfant dans le message protobuf. La première passe construit une table de recherche pour que la seconde passe puisse résoudre source.endpoint en remontant la chaîne d'ancêtres. Une approche en une seule passe manquerait les spans parents définis plus loin dans le message.

source.endpoint se résout en quatre étapes, chacune servant de repli à la précédente : la route HTTP entrante la plus externe dans la chaîne contiguë d'un service explicitement nommé (http.route, puis les replis URL réservés aux spans SERVER), ensuite le cadre de code code.* le plus externe pour les points d'entrée sans attribut HTTP (jobs planifiés, consumers de messages), puis la destination du span CONSUMER le plus proche (<messaging.system> <destination>), enfin le littéral "unknown". Quand http.route est un nom symbolique de framework sans / et que le même span possède un url.path exploitable, ce chemin représente l'endpoint. Les routes contenant / restent prioritaires. Une ressource anonyme se limite à la route prouvée la plus proche, car un nom de service absent ne permet pas d'établir une frontière sûre entre blocs. Toute remontée s'arrête après exactement huit sauts.

L'index utilise les paires d'octets bruts (trace_id, span_id), évite l'encodage hexadécimal et empêche deux ids de span égaux dans deux traces d'entrer en collision. Un index est construit par service.name explicite et couvre tous les blocs ResourceSpans de ce service. Les blocs anonymes reçoivent chacun un index local distinct. L'index de chaque service est plafonné à 100 000 spans pour prévenir l'épuisement mémoire depuis des payloads OTLP pathologiques, si bien qu'un service bruyant ne prive pas les autres de résolution de parent. Un tracing::warn! est émis quand un plafond est atteint pour aider les opérateurs à diagnostiquer une résolution de parent dégradée.

Pour les services explicitement nommés, le daemon exporte en plus les ids de trace valides de 16 octets, les ids de span valides de 8 octets, les arêtes parent et les routes entrantes facultatives comme contexte source. La TraceWindow conserve ce contexte sous ses plafonds LRU, TTL et par trace existants : les spans INTERNAL intermédiaires et les routes d'entrée peuvent ainsi arriver dans des exports OTLP ultérieurs sans créer d'événement I/O synthétique. Le contexte anonyme reste local à la conversion et n'est pas retenu entre exports. Les ids invalides sont ignorés avant la création de clés hexadécimales. L'échantillonnage réutilise la même décision déterministe par id de trace que les événements I/O.

Table de recherche bytes_to_hex

rust
fn bytes_to_hex(bytes: &[u8]) -> String {
    const HEX: &[u8; 16] = b"0123456789abcdef";
    let mut buf = Vec::with_capacity(bytes.len() * 2);
    for &b in bytes {
        buf.push(HEX[(b >> 4) as usize]);
        buf.push(HEX[(b & 0x0f) as usize]);
    }
    // Tous les octets viennent de HEX (ASCII 0-9, a-f), toujours du UTF-8 valide.
    String::from_utf8(buf).expect("hex table is ASCII")
}

C'est une optimisation bien connue pour l'encodage hexadécimal. Au lieu d'utiliser write!(hex, "{b:02x}") (qui invoque la machinerie de formatage par octet à ~30ns), la table de recherche convertit chaque octet en deux caractères hexadécimaux via décalage de bits à ~5ns par octet. Le Vec<u8> est pré-alloué et l'appel from_utf8 est infaillible puisque seuls des chiffres hexadécimaux ASCII sont insérés. Pas de unsafe nécessaire : le expect est une assertion à coût zéro sur une condition qui ne peut pas échouer.

Pour un trace_id de 16 octets + un span_id de 8 octets, cela économise ~600ns par conversion de span. À 100 000 événements/sec, c'est 60ms/sec de surcoût évité.

nanos_to_iso8601 : algorithme de Howard Hinnant

Note : cette fonction vit dans le module partagé time.rs et est réutilisée par les ingestions Jaeger et Zipkin via micros_to_iso8601.

La conversion de nanosecondes Unix vers YYYY-MM-DDTHH:MM:SS.mmmZ utilise l'algorithme de date civile de Howard Hinnant. Les étapes :

  1. Convertir les nanosecondes en jours depuis l'epoch + millisecondes restantes
  2. Décaler l'epoch au 1er mars, an 0 (en ajoutant 719 468 jours)
  3. Calculer l'ère (cycle de 400 ans) et le jour de l'ère
  4. Dériver l'année de l'ère, le jour de l'année, le mois et le jour en utilisant une formule sans table de recherche

Cela évite le crate chrono (~150 Ko de surcoût binaire) et son surcoût de parsing de ~200ns. L'algorithme artisanal gère correctement les années bissextiles (vérifié par un test avec 2024-02-29).

Priorité du type d'événement

Quand un span possède à la fois un attribut SQL (db.statement ou db.query.text) et un attribut HTTP (http.url ou url.full), SQL prend la priorité, car l'instrumentation de base de données est plus spécifique que l'instrumentation client HTTP. L'attribut SQL contient le texte réel de la requête nécessaire à la normalisation, tandis que l'attribut HTTP pourrait représenter la même opération au niveau transport.

La branche HTTP admet tout sauf SpanKind::Server. Les conventions sémantiques stables ne posent url.full que sur les spans CLIENT, mais les instrumentations legacy posent aussi http.url sur le span de traitement entrant, si bien qu'admettre un span SERVER compterait chaque saut deux fois et inventerait des arêtes auto-dirigées. La branche RPC ci-dessous est réservée aux spans CLIENT pour la même raison. La porte se trouve dans la branche HTTP, après le SQL, donc un span SERVER portant db.statement est toujours analysé comme SQL, et un span dont le kind n'est pas renseigné reste éligible au HTTP. Jaeger lit le tag span.kind (server) et Zipkin le champ kind (SERVER). Un span SERVER rejeté ici reste lu comme contexte entrant par la résolution d'endpoint ci-dessus, et il compte comme not_io plutôt que missing_http_url, puisqu'un traitement entrant n'est pas un appel sortant amputé.

Les spans RPC (rpc.system, par ex. gRPC ou Dubbo) sont examinés après SQL et HTTP. Ils ne portent ni statement ni URL, leur cible est donc rpc.service/rpc.method (avec repli sur le nom du span). Ils entrent dans le pipeline comme EventType::HttpOut pour que les détecteurs topologiques et d'occurrence les traitent comme des appels sortants. Seul SpanKind::Client est admis : les clés rpc.* sont posées aussi sur le span SERVER entrant (le handler), et admettre ceux-ci compterait chaque saut deux fois et inventerait des arêtes vers soi-même. Cette réutilisation au seul niveau de l'admission laisse inchangé le chemin de normalisation/sanitisation HTTP.

Les spans messaging (messaging.system, une seule convention pour Kafka, RabbitMQ, Pulsar, SQS, NATS et JMS) sont examinés en dernier, avec messaging.destination.name comme cible et le nom du span en repli. Contrairement au RPC, ils ont leur propre EventType::Messaging, pour deux raisons : la destination ne doit pas passer par le normaliseur de chemins HTTP, qui masquerait un identifiant de compte SQS en {id}, et une publication mérite ses propres types de findings plutôt qu'une étiquette teintée HTTP. Seul SpanKind::Producer est admis, sur la même logique de double comptage que le RPC, à quoi s'ajoute qu'un consommateur qui fait du polling noierait les détecteurs d'occurrences.

L'arête producteur vers consommateur est un span link OTel, pas une relation parent-enfant, et le consommateur démarre le plus souvent sa propre trace. resolve_producer_link trouve le span CONSUMER qui porte ce lien, lit sa première entrée et pose l'identifiant de la trace productrice sur l'événement en link_trace_id. Le filtre sur CONSUMER compte : les batch span processors et les relations follows-from émettent aussi des liens. Un lien qui revient vers la trace du span est écarté. Les deux traces ne sont jamais fusionnées, voir Limites pour les raisons.

Deux topologies, parce que les instrumentations OTel ne placent pas receive au même endroit. Certaines imbriquent le travail du handler sous le span receive, ce qu'une remontée d'ancêtres retrouve. L'instrumentation Kafka officielle OpenTelemetry Java et .NET ne fait pas cela : elle émet receive comme frère du travail qu'il déclenche, sous un parent commun.

order-consumed        INTERNAL   parent=""     links=[]
├─ orders receive     CONSUMER   parent=root   links=[<trace productrice>]
└─ postgresql         CLIENT     parent=root   links=[]        <- le span analysable

La chaîne d'ancêtres du span SQL est postgresql -> order-consumed, le CONSUMER porteur du lien n'y figure donc jamais. Une remontée d'ancêtres seule a manqué 100 % des liens sur deux corpus capturés pendant que tous les tests unitaires restaient verts, parce que les tests construisaient la forme ancêtre que le code attendait. La résolution essaie donc les frères d'abord, via un index des spans CONSUMER porteurs de lien indexés par parent, puis se rabat sur la chaîne d'ancêtres.

Cet index n'est construit que si la requête porte un span CONSUMER lié, et il sert aussi de garde par service : un service sans un tel span saute les deux recherches. L'indexation par parent impose une décision que la remontée d'ancêtres n'avait jamais eu à prendre, car plusieurs spans receive frères peuvent partager un parent. Le premier dans l'ordre du lot l'emporte, ce qui est déterministe mais arbitraire : rien dans les spans ne dit quel message a causé quelle requête.

Les conventions sémantiques OTel legacy (pré-1.21) et stables (1.21+) sont toutes deux prises en charge : db.statement et db.query.text pour le SQL, http.url et url.full pour le HTTP, http.method et http.request.method pour le verbe, http.status_code et http.response.status_code pour le statut. Cela couvre les anciens SDKs OTel comme les agents Java modernes (v2.x). Sur un span HTTP sortant, les tags Micrometer Observation method et status (RestClient, RestTemplate et WebClient de Spring Boot) sont lus en dernier, après les deux conventions.

Protection contre la dérive d'horloge

rust
if end_nanos < start_nanos {
    tracing::trace!("Span has end_time < start_time (clock skew?), duration forced to 0");
}
let duration_us = end_nanos.saturating_sub(start_nanos) / 1000;

saturating_sub retourne 0 pour les durées négatives au lieu de boucler. Un log de niveau trace aide les opérateurs à diagnostiquer les problèmes d'intégration OTLP sans inonder les logs.

Le trait MetricsSink (ingest/otlp/mod.rs)

ingest::otlp produit de la télémétrie d'exécution sur chaque chemin de rejet (type de média non pris en charge, échec de décodage, canal plein). Avant la 0.6.0, ces appels atteignaient directement report::metrics::MetricsState, ce qui faisait fuir l'implémentation des métriques aval vers l'amont et rendait ingest inutilisable sans payer le coût du registre Prometheus. Le trait MetricsSink est l'abstraction : MetricsState l'implémente (dans report::metrics) pour que les appelants du daemon gardent le même câblage, et les builds alternatifs (un fork à métriques OpenTelemetry, des tests avec un faux compteur) peuvent brancher leur propre implémentation sans toucher ingest. Les bornes Send + Sync existent parce que les chemins gRPC et HTTP partagent le sink entre tâches tokio via Arc<dyn MetricsSink>. L'impl sur MetricsState conserve le même dispatch sans branche du chemin chaud que les méthodes inhérentes pré-trait : les deux points d'entrée atteignent les IntCounter enfants mis en cache, sans recherche dans une hashmap de labels.

Ingestion JSON

rust
pub fn ingest(&self, raw: &[u8]) -> Result<Vec<SpanEvent>, Self::Error> {
    if raw.len() > self.max_size {
        return Err(JsonIngestError::PayloadTooLarge { ... });
    }
    serde_json::from_slice(raw)
}

La taille du payload est vérifiée avant la désérialisation. Cela empêche serde_json d'allouer de la mémoire pour un payload JSON de plusieurs gigaoctets avant de le rejeter.

Auto-détection du format

JsonIngest auto-détecte le format d'entrée à l'aide d'heuristiques légères au niveau des octets. Il examine les premiers 1-4 Ko du payload :

  • Commence par { et contient "resourceSpans" (ou "resource_spans") dans le premier Ko : OTLP/JSON (un ExportTraceServiceRequest unique ou le NDJSON de l'exporter file du Collector, décodé via un désérialiseur serde en flux et converti par le même convert_otlp_request que celui des listeners du daemon)
  • Commence par { et contient "data" + "spans" dans les 4 premiers Ko : Jaeger
  • Commence par [ et contient "traceId" + "localEndpoint" dans le premier Ko : Zipkin
  • Sinon : format natif Perf Sentinel

Cela évite de parser tout le payload en serde_json::Value pour la détection, ce qui supprime un coût de parsing doublé. L'heuristique travaille sur les octets bruts (std::str::from_utf8 sur un préfixe borné), ce qui la rend O(1) quelle que soit la taille du payload.

Sanitisation à la frontière. Après le parsing, le chemin d'ingestion JSON valide cloud_region via is_valid_region_id et exécute sanitize_span_event sur chaque événement, appliquant les mêmes limites de champs et troncatures UTF-8 que le chemin OTLP. Le code en aval reçoit donc les mêmes données sanitisées, quel que soit le format d'ingestion.

Ingestion Jaeger JSON

ingest/jaeger.rs parse le format d'export Jaeger JSON ({ "data": [{ "traceID": "...", "spans": [...], "processes": {...} }] }). Correspondances :

  • startTime (microsecondes) est converti via micros_to_iso8601 du module partagé time.rs
  • parent_span_id est extrait depuis les references où refType = "CHILD_OF"
  • Les conventions sémantiques OTel legacy et stables sont toutes deux prises en charge dans les tags

Ingestion Zipkin JSON v2

ingest/zipkin.rs parse le format Zipkin JSON v2 (tableau plat d'objets span). Différences avec Jaeger :

  • parentId est un champ direct (pas dans un tableau de références)
  • Les tags sont un HashMap<String, String> (pas un tableau d'objets clé-valeur)
  • localEndpoint.serviceName fournit le nom du service

Boucle événementielle du daemon

Architecture

OTLP gRPC (port 4317)   ─┐                                          ┌─ worker d'analyse ─┐
OTLP HTTP (port 4318)   ─┤─→ mpsc(1024) ─→ TraceWindow ─→ éviction ─→ mpsc(1024) ─→ detect ─→ score ─→ NDJSON
Socket unix JSON        ─┘     (boucle select!)                      └────────────────────┘

La boucle événementielle utilise tokio::select! pour multiplexer :

  • Réception d'événements depuis le canal : normaliser, pousser dans la fenêtre, puis enfiler les évictions
  • Ticker toutes les TTL/2 ms : évincer les traces expirées, puis les enfiler
  • Ctrl+C : vider toutes les traces, les remettre au worker, le joindre, puis arrêter

detect+score ne tournent pas sur la boucle select!. Ils s'exécutent sur un unique worker d'analyse dédié alimenté par un canal borné (voir Worker d'analyse), de sorte qu'une passe d'analyse longue ne peut pas bloquer l'ingestion ni l'éviction.

Normalisation en dehors du verrou

rust
// Normaliser EN DEHORS du verrou :
let normalized: Vec<_> = events.into_iter().map(normalize::normalize).collect();
// Puis acquérir le verrou et pousser :
let mut w = window.lock().await;
for event in normalized { w.push(event, now_ms); }

La normalisation est un travail lié au CPU (regex, manipulation de chaînes). La déplacer en dehors du verrou Mutex minimise le temps de détention du verrou aux seules opérations HashMap. Sous contention (ticker et réception s'exécutant concurremment), cela empêche le ticker d'éviction de bloquer sur la normalisation.

Échantillonnage au niveau des traces

rust
fn should_sample(trace_id: &str, rate: f64) -> bool {
    let mut hash: u64 = 0xcbf2_9ce4_8422_2325; // base de décalage FNV-1a
    for b in trace_id.as_bytes() {
        hash ^= u64::from(*b);
        hash = hash.wrapping_mul(0x0100_0000_01b3); // nombre premier FNV-1a
    }
    (hash as f64 / u64::MAX as f64) < rate
}

Le hash FNV-1a est un hash rapide, non cryptographique qui produit une sortie bien distribuée. La base de décalage et le nombre premier sont les constantes standard FNV-1a 64 bits.

Pourquoi FNV-1a ? Plus simple et plus rapide (~2ns pour un trace_id typique) que std::hash::DefaultHasher (SipHash, ~10ns). La qualité cryptographique n'est pas nécessaire pour l'échantillonnage : seule la distribution uniforme compte.

Déterministe : le même trace_id produit toujours la même décision d'échantillonnage, si bien que tous les événements d'une trace sont conservés ou supprimés ensemble.

Cache par lot : la fonction apply_sampling() filtre un lot d'événements en utilisant un cache HashMap<String, bool>. Au sein d'un seul lot, plusieurs événements peuvent partager un trace_id. Le cache utilise get() avant insert() de sorte que trace_id n'est cloné que pour le premier événement de chaque trace, pas à chaque hit de cache. Extraire cette logique dans une fonction dédiée garde la boucle tokio::select! lisible.

Canal borné

rust
let (tx, mut rx) = mpsc::channel::<Vec<SpanEvent>>(1024);

Le canal borné fournit une contre-pression : si la boucle événementielle prend du retard et que le buffer se remplit à 1024 lots, les émetteurs d'ingestion attendront jusqu'à ce qu'un espace soit disponible. Cela empêche la croissance mémoire non bornée par des producteurs rapides.

Worker d'analyse

detect+score sont liés au CPU. Les exécuter en ligne sur la tâche select! faisait qu'une passe d'analyse longue bloquait la boucle, l'empêchant d'interroger rx.recv() et le ticker d'éviction : la liveness d'ingestion et d'éviction dépendait alors de la latence d'analyse. À la place, les lots évincés (LRU), expirés (TTL) et vidés (shutdown) sont remis à un unique worker d'analyse via un second canal borné :

rust
let (work_tx, work_rx) = mpsc::channel::<AnalysisBatch>(1024);
let worker = tokio::spawn(run_analysis_worker(work_rx, ctx));
  • Un worker, un canal, FIFO. L'analyse reste mono-thread et les lots atteignent le worker dans l'ordre où ils ont quitté la fenêtre, donc le corrélateur cross-trace (avec état) observe une séquence déterministe. Pas de pool de threads, pas de spawn_blocking par lot.
  • Enfilement non bloquant avec délestage compté. La boucle enfile en try_reserve (synchrone, n'attend jamais l'analyse), construisant le CarbonContext possédé seulement une fois un emplacement réservé, pour qu'un délestage ne paie jamais un clone jeté. Quand la file est pleine (ou le worker arrêté), le lot entier est délesté et compté via perf_sentinel_analysis_shed_batches_total et perf_sentinel_analysis_shed_traces_total. perf_sentinel_analysis_queue_depth suit le backlog. Sous surcharge soutenue, on perd des lots entiers plutôt que de bloquer l'ingestion (un compromis de liveness/contre-pression, et non de débit).
  • Contrôle d'admission par pression mémoire (opt-in). Le délestage par profondeur de file est un proxy de la mémoire. Quand l'analyse suit, la file reste vide alors que la TraceWindow (bornée par un nombre de traces et un TTL, pas par des octets) peut quand même faire croître la RSS au-delà de la limite cgroup et faire tuer le pod par l'OOM killer avant tout délestage. Quand [daemon] memory_high_water_pct > 0, un watcher à 1 Hz (daemon/mem_pressure.rs) lit memory.current / memory.max du cgroup v2 et bascule un flag partagé (avec hystérésis) dès que l'usage franchit le seuil. Les handlers OTLP lisent ce flag et rejettent l'ingestion avec un statut qui invite à réessayer (perf_sentinel_otlp_rejected_total{reason="memory_pressure"}), stoppant la croissance à la source pour que l'éviction TTL vide la fenêtre et que la RSS reflue. Un working set qui reste 60 secondes dans la bande de 5 points, ou un trace_ttl_ms s'il est plus long, efface quand même le flag, car l'état retenu et le page cache actif peuvent ne jamais refluer, et le franchissement suivant le repositionne. Au-dessus du seuil, le flag reste positionné. Les trois portes d'ingestion respectent ce flag (OTLP gRPC via un intercepteur avant décodage, OTLP HTTP via un middleware avant mise en tampon, le socket JSON Unix en abandonnant les lots). cgroup v2 / Linux uniquement, inerte et sans surcoût quand désactivé ou non pris en charge.
  • CarbonContext échantillonné à l'éviction. Le CarbonContext par lot (snapshots des scrapers d'énergie + intensité réseau) est construit côté boucle au moment de l'éviction et voyage avec le lot, ce qui préserve l'instant d'échantillonnage précédent.
  • Shutdown : drain puis join. Sur Ctrl+C / SIGTERM la boucle vide la fenêtre et remet le reste au worker via un send bloquant (livraison garantie, sans délestage). Elle ferme ensuite le canal et attend le worker, afin que chaque lot en tampon ou en cours soit entièrement analysé avant le retour.
  • Fail-loud si le worker meurt. Une quatrième branche select! surveille le JoinHandle du worker. S'il s'arrête avant le shutdown (un détecteur panique), run_event_loop retourne DaemonError::AnalysisWorkerStopped : le processus se termine et un superviseur le redémarre, plutôt que de rester en vie à ne plus rien analyser en silence. Cela restaure la sémantique fail-loud de la détection en ligne (une panique faisait planter le daemon).

Renforcement de la sécurité

Permissions du socket Unix :

rust
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600));

Le mode 0o600 restreint la lecture/écriture au propriétaire du socket uniquement, empêchant d'autres utilisateurs locaux d'injecter des événements. Si set_permissions échoue, le fichier socket est supprimé et le listener ne démarre pas (erreur fatale, pas un simple avertissement).

Sémaphore de connexion :

rust
let semaphore = Arc::new(tokio::sync::Semaphore::new(128));

Limite les connexions concurrentes du socket JSON à 128. Sans cela, un attaquant local pourrait ouvrir des milliers de connexions, chacune consommant une tâche tokio et de la mémoire tampon.

Limite d'octets par connexion :

rust
const CONNECTION_LIMIT_FACTOR: u64 = 16;
let limited = stream.take(max_payload_size as u64 * CONNECTION_LIMIT_FACTOR);

Chaque connexion est limitée à 16 × max_payload_size octets au total (défaut 16 Mo). Cela empêche une seule connexion de consommer une mémoire non bornée avec un flux de données qui ne contient jamais de saut de ligne.

Timeouts des requêtes :

  • gRPC : tonic::transport::Server::builder().timeout(Duration::from_secs(60))
  • HTTP : tower::timeout::TimeoutLayer::new(Duration::from_secs(60)) via le HandleErrorLayer d'axum

Ceux-ci empêchent les connexions lentes/bloquées de retenir des ressources indéfiniment. Le handler de timeout HTTP émet un log tracing::debug! avant de retourner 408 REQUEST_TIMEOUT, aidant les opérateurs à diagnostiquer les clients lents ou bloqués.

Sortie NDJSON

Les findings sont émis en JSON délimité par des sauts de ligne sur stdout en utilisant serde_json::to_writer avec un handle stdout verrouillé pour éviter les allocations String intermédiaires et réduire la contention sur le verrou :

rust
let stdout = std::io::stdout();
let mut lock = stdout.lock();
for finding in &findings {
    if serde_json::to_writer(&mut lock, finding).is_ok() {
        let _ = writeln!(lock);
    }
}

Ce format est compatible avec les outils d'agrégation de logs (Loki, ELK) qui consomment du JSON délimité par des lignes. Chaque ligne est un objet JSON complet qui peut être parsé indépendamment.

[daemon] findings_stdout = false saute cette écriture, et le verrou de stdout avec elle. Les métriques, le buffer circulaire, l'archive et l'export vers le Hub reçoivent toujours chaque finding.

Ratio de gaspillage cumulatif

La jauge Prometheus io_waste_ratio est calculée à partir de compteurs cumulatifs :

rust
let cumulative_total = metrics.total_io_ops.get();
if cumulative_total > 0.0 {
    metrics.io_waste_ratio.set(metrics.avoidable_io_ops.get() / cumulative_total);
}

C'est une moyenne sur toute la durée, pas une métrique fenêtrée. Les utilisateurs qui ont besoin d'un taux récent peuvent utiliser rate() de Prometheus sur les compteurs bruts (total_io_ops, avoidable_io_ops).

Exemplars Grafana

Le crate prometheus 0.14.0 ne prend pas en charge nativement les exemplars OpenMetrics. Plutôt que d'ajouter une dépendance, les annotations exemplars sont injectées par post-traitement du texte Prometheus rendu.

Suivi des trace_id worst-case :

MetricsState stocke les données d'exemplars dans des champs protégés par RwLock :

  • worst_finding_trace: HashMap<(&'static str, &'static str, String, String), ExemplarData> : indexé par (finding_type, severity, label service effectif, label grouping effectif), dernier écrivain par clé, mis à jour à chaque lot analysé par le daemon (record_exemplars_labeled) et à chaque appel record_batch() sur le chemin bibliothèque
  • worst_waste_trace: Option<ExemplarData> : le trace_id du finding avec le plus d'I/O évitables

Les deux expirent 15 minutes après le batch qui les a enregistrés (EXEMPLAR_TTL). La map est bornée par le nombre de séries de findings, 12 types × 3 sévérités × (le plafond de paires d'analyse plus deux regroupements, _other et la valeur vide, par service admis), soit près de 28k entrées. L'expiration ne répond donc pas à un problème de mémoire : sans elle, un service devenu silencieux annoterait sa série indéfiniment avec un trace_id déjà sorti de la rétention du backend de traces, et le clic depuis Grafana tomberait sur un 404. Le chemin de scrape ignore les entrées périmées sous son verrou de lecture, et le chemin d'écriture les purge sous celui qu'il détient déjà.

RwLock est utilisé plutôt que Mutex car render() (chemin de lecture) est appelé fréquemment par les scrapes Prometheus, alors que record_batch() (chemin d'écriture) est appelé moins souvent. Plusieurs scrapes concurrents ne doivent pas se bloquer entre eux. L'empoisonnement de verrou est géré proprement via unwrap_or_else(PoisonError::into_inner), de sorte qu'une panique dans un thread ne provoque pas de plantages en cascade sur les acquisitions de verrou suivantes.

Injection d'exemplars :

inject_exemplars() itère sur le texte rendu ligne par ligne. Pour les lignes perf_sentinel_findings_total{...}, il parse les labels type, severity, service et grouping pour trouver l'exemplar correspondant. Pour les lignes perf_sentinel_io_waste_ratio, il ajoute l'exemplar de gaspillage.

Le format suit la spécification OpenMetrics : metric{labels} value # {trace_id="abc123"}. Quand des exemplars sont présents, le header Content-Type passe de text/plain; version=0.0.4 (Prometheus) à application/openmetrics-text; version=1.0.0 (OpenMetrics) pour que la source de données Prometheus de Grafana puisse reconnaître et afficher les liens d'exemplars.

Intégration Grafana : avec les exemplars activés, les utilisateurs peuvent cliquer depuis un pic de métrique dans Grafana directement vers la trace du pire cas dans Tempo ou Jaeger. Il faut pour cela que la source de données Prometheus ait l'option "Exemplars" activée et qu'une source de données Tempo/Jaeger soit configurée comme backend de traces.

Ingestion pg_stat_statements

ingest/pg_stat.rs fournit un chemin d'analyse autonome pour les exports pg_stat_statements de PostgreSQL. Contrairement à l'ingestion basée sur les traces, ces données n'ont pas de trace_id ni de span_id, elles ne peuvent donc pas alimenter le pipeline de détection N+1/redondant. Elles fournissent un classement de hotspots et une référence croisée avec les findings de traces.

Décisions de conception

Séparé de IngestSource : le trait IngestSource retourne Vec<SpanEvent>, mais les données pg_stat_statements ne correspondent pas à SpanEvent (pas de trace_id, span_id, ni horodatage). Elles produisent leur propre type PgStatReport avec des classements.

Auto-détection du format : suit le même principe d'heuristique au niveau des octets que json.rs. Si le premier octet non-espace est [ ou {, parse en JSON, sinon en CSV. Pas de crate csv externe, le parseur CSV gère manuellement les guillemets RFC 4180 (champs entre guillemets doubles, "" échappé).

Réutilisation de la normalisation SQL : chaque requête passe par normalize::sql::normalize_sql_for(query, "postgresql") pour produire un template comparable avec les findings basés sur les traces. PostgreSQL normalise les requêtes côté serveur (par ex. les paramètres positionnels $1), mais Perf Sentinel les renormalise pour rester cohérent avec son propre format de template.

Sortie à quatre classements

rank_pg_stat(entries, top_n) retourne un PgStatReport avec quatre classements dans un ordre stable, indexé par position : [by_total_time, by_calls, by_mean_time, by_io_blocks]. Chaque classement contient les mêmes top-N entrées, réordonnées selon son propre critère :

  • by_total_time : total_exec_time_ms décroissant. Requêtes qui dominent le temps DB wall-clock. Signal hotspot principal.
  • by_calls : calls décroissant. Requêtes à fort volume, candidates N+1 typiques.
  • by_mean_time : mean_exec_time_ms décroissant. Requêtes individuellement lentes indépendamment du volume.
  • by_io_blocks : shared_blks_hit + shared_blks_read décroissant. Signal de pression cache : requêtes qui touchent le plus de pages du buffer partagé, peu importe si elles étaient chaudes ou froides. Complémentaire de by_total_time quand le CPU est inactif mais que le cache s'agite.

Le sélecteur secondaire de l'onglet pg_stat du dashboard HTML consomme ces quatre classements par position, donc les nouveaux classements s'ajoutent en fin de liste (jamais réordonnés, jamais insérés au milieu) pour préserver la stabilité des indices côté consommateurs.

Référence croisée

cross_reference() accepte &mut [PgStatEntry] et &[Finding]. Il construit un HashSet des templates de findings et marque les entrées dont le normalized_template correspond. Complexité O(n + m) où n = entrées, m = findings. Le flag seen_in_traces permet à la CLI de mettre en évidence les requêtes présentes dans les deux sources de données, ce qui sert à valider la fidélité de la capture de traces OTLP face à la vérité terrain native de la base.

Scrape Prometheus automatisé pour pg_stat

fetch_from_prometheus(endpoint, top_n, auth_header, opts) interroge l'API HTTP d'un Prometheus pour obtenir les métriques pg_stat_statements, ce qui supprime le besoin d'un export CSV manuel. opts (PrometheusPgStat) nomme la série classée, le label qui porte le texte SQL, la série du compteur d'appels et l'unité de temps, avec des valeurs par défaut calquées sur la requête intégrée de postgres_exporter.

Requête et conversion

La fonction construit une requête instantanée PromQL topk(N, sum by (queryid, instance, job, query) (pg_stat_statements_seconds_total)) et l'envoie à l'endpoint /api/v1/query de Prometheus via ingest::prometheus_scrape::fetch_instant_query, qui appelle le helper partagé http_client::fetch_get avec un délai d'expiration de 30 s. Une seconde requête instantanée lit le compteur d'appels (pg_stat_statements_calls_total par défaut), joint sur queryid aux requêtes classées. La réponse est une enveloppe JSON Prometheus standard :

json
{
  "data": {
    "result": [
      {
        "metric": { "query": "SELECT ...", "queryid": "-4215498811249381917", "instance": "db:9187", "job": "postgres" },
        "value": [1234567890, "1.234"]
      }
    ]
  }
}

parse_prometheus_response extrait le label query (ou queryid) comme texte SQL brut et la valeur comme temps d'exécution total, convertie en millisecondes par PgStatTimeUnit::to_ms (la série par défaut compte en secondes). Les lignes sont identifiées par queryid, instance et job. datname est écarté pour qu'une requête donne une seule ligne classée, quelle que soit la base sur laquelle elle a tourné. Chaque résultat est converti en PgStatEntry, avec son SQL normalisé via normalize::sql::normalize_sql_for(query, "postgresql") pour rester cohérent avec les findings basés sur les traces.

Intégration CLI

Le flag --prometheus de perf-sentinel pg-stat active ce chemin :

perf-sentinel pg-stat --prometheus http://prometheus:9090 --top-n 20

Ce flag est gardé derrière la feature daemon de la CLI, car il a besoin de la pile client HTTP hyper (la fonction fetch_from_prometheus du cœur se compile sous daemon ou tempo). La CLI relève top_n à PROMETHEUS_SCRAPE_FLOOR (200) pour le scrape, parce qu'un seul des quatre classements repose sur la série du topk, puis ramène le classement à --top-n. Le reste du pipeline pg-stat (classement, référence croisée, affichage) est identique, que les données viennent d'un fichier ou de Prometheus.

La sous-commande report expose la même capacité via --pg-stat-prometheus URL, mutuellement exclusif avec le flag fichier --pg-stat FILE (imposé au niveau clap via conflicts_with). Quand l'un des deux est passé, le PgStatReport résultant est embarqué dans l'onglet pg_stat du dashboard HTML avec les quatre classements décrits ci-dessus. Le chemin de scrape est partagé avec pg-stat --prometheus, donc aucun code de récupération n'est dupliqué.

Ingestion Tempo

ingest/tempo.rs fournit le chemin de replay post-mortem : la sous-commande interroge l'API HTTP d'un Grafana Tempo en cours d'exécution, récupère les corps de traces en protobuf OTLP, les décode via le helper existant convert_otlp_request et renvoie un Vec<SpanEvent> au pipeline d'analyse standard. Deux modes : trace unique par ID (--trace-id, un seul GET /api/traces/{id}) ou recherche puis récupération (--service --lookback, un GET /api/search suivi d'une récupération trace par trace). Gardée derrière la feature cargo tempo.

Fetch parallèle avec cap de concurrence

La boucle de récupération par trace est parallélisée via tokio::task::JoinSet, protégée par un Arc<Semaphore> plafonné à fetch_concurrency jetons (DEFAULT_FETCH_CONCURRENCY = 16, --fetch-concurrency ou [tempo] fetch_concurrency de 1 à 64). Chaque tâche lancée acquiert un jeton via acquire_owned avant l'appel HTTP et le libère à sa destruction (RAII). Le plafond a été choisi empiriquement pour saturer une connexion Tempo distante sur lien WAN (observé ~10-20s pour 100 traces contre ~2m30s avec la boucle séquentielle précédente) sans mettre à genoux une seule réplique de query-frontend. Un querier avec une petite limite mémoire peut malgré tout en manquer à 16, puisque le query-frontend découpe chaque lecture en nombreux morceaux, d'où ce réglage. Ce procédé reprend celui de score::cloud_energy::scraper, qui parallélise de la même façon les requêtes CPU Prometheus par service.

Séparation des timeouts

La recherche et la récupération de trace utilisent deux constantes dédiées plutôt qu'une valeur unique. SEARCH_TIMEOUT = 5s s'applique à /api/search : la réponse est une petite liste de trace IDs, et un timeout serré fait échouer vite la requête sur un endpoint cassé. FETCH_TRACE_TIMEOUT = 30s s'applique à /api/traces/{id} : les corps de traces peuvent légitimement faire plusieurs MiB sur une requête à fanout large, et la query-frontend doit assembler les spans depuis les ingesters + le stockage long terme. Un plafond unique à 5 s faisait empiriquement perdre des dizaines de traces par lot de 100 sur les fenêtres de lookback longues. La valeur de 30 s correspond au défaut de la source de données Tempo côté Grafana. Les deux timeouts sont passés en paramètre au helper partagé fetch_raw plutôt que stockés dans une constante unique au niveau module, pour que les chemins de recherche et de récupération de trace ne puissent jamais diverger. Chacun borne les en-têtes et le corps ensemble : un querier qui se bloque au milieu du corps fait échouer sa récupération au lieu de suspendre toute l'exécution.

Ctrl-C et agrégation d'erreurs

La boucle de vidage est pilotée par un tokio::select! avec ordre biased : tokio::signal::ctrl_c() est interrogé avant set.join_next() pour qu'une interruption en attente ne soit pas mise en famine par une rafale de tâches terminées. Sur signal, set.abort_all() marque toutes les tâches en cours pour annulation. Les traces déjà terminées sont conservées. Les tâches annulées se résolvent en JoinError::is_cancelled() et sont ignorées silencieusement. La variante dédiée TempoError::Interrupted n'est renvoyée que si aucune trace n'a eu le temps de se terminer avant le signal, pour que les quality gates CI puissent faire la différence entre une interruption par l'opérateur et une exécution qui n'a trouvé aucune trace (NoTracesFound).

Les échecs par trace sont journalisés au niveau debug, pas error. Une seule ligne de résumé classifiée (emit_fetch_summary) est émise à la fin de la boucle, regroupée par type d'erreur (timeout, transport, http_status, protobuf_decode, body_read, json_parse, task_panic). L'outillage en aval (Loki, CloudWatch) peut ainsi alerter sur le bon signal sans parser 50 lignes ERROR individuelles sur un Tempo dégradé. La sévérité du résumé suit la pire classe observée : warn si seules des traces TraceNotFound ont été ignorées (condition occasionnelle attendue, par ex. une trace sortie de rétention entre la recherche et la récupération), error sinon. Un test unitaire (classify_fetch_error_buckets_every_hard_failure_variant) sert de garde-fou contre la dérive : une variante ajoutée plus tard à TempoError ne tombe pas silencieusement dans "other".

Une exécution qui ne collecte aucun événement renvoie TempoError::FetchesFailed quand ses échecs sont plus nombreux que les traces qui ont répondu, hors TraceNotFound : la recherche a bien trouvé des traces, et l'élargir ne ferait qu'ajouter de la charge. En dessous, ce que disent les traces qui ont répondu décide : NoConvertibleSpans si elles portaient des spans, NoTracesFound sinon. Une exécution qui collecte des événements signale ses traces non lues par TempoIngest::partial_warning, que la CLI ajoute à warning_details sous le kind partial_fetch, car la ligne de résumé n'atteint que les logs.

Ingestion Jaeger Query API

ingest/jaeger_query.rs est l'autre source de replay en mode pull, gardée derrière la feature cargo jaeger-query. Elle interroge n'importe quel backend qui parle l'API HTTP de requête Jaeger (Jaeger upstream et Victoria Traces, qui implémente la même surface). Contrairement au parcours en deux étapes de Tempo (recherche puis récupération), le /api/traces de Jaeger retourne les traces complètes dans la réponse de recherche, donc une seule requête de recherche couvre toute l'ingestion (deux sur Jaeger 2.21, voir plus bas). Le payload décodé réutilise le parser jaeger du mode fichier (convert_jaeger_export), donc une trace Jaeger-Query et un fichier JSON Jaeger passent par une normalisation identique. Sur Jaeger 2.21 et suivantes, qui ont retiré la recherche v1, une recherche qui répond 404 est relancée par /api/v3/traces : chaque document OTLP JSON est extrait de son result puis parsé par le chemin OTLP JSON du mode fichier (JsonIngest::ingest_otlp). Elle partage l'unique http_client.rs et le helper auth_header.rs avec le chemin Tempo. Le validateur d'endpoint accepte toute URL http(s) et ne bloque pas les cibles RFC 1918 ou link-local, donc la sous-commande ne doit être invoquée qu'avec des valeurs d'endpoint de confiance (voir Limites).

API de requête du daemon

Le daemon expose son état interne via des endpoints HTTP aux côtés des routes existantes /v1/traces, /metrics et /health sur le port 4318.

L'endpoint /health est une sonde de liveness sans état pour Kubernetes, les répartiteurs de charge et systemd. Il retourne 200 OK avec {"status":"ok","version":"<pkg_version>"}, ne prend aucun verrou et ne peut pas donner de faux négatif sous charge. Il est toujours exposé, indépendamment de [daemon] api_enabled, qui ne contrôle que la surface /api/* plus riche décrite ci-dessous.

Buffer circulaire FindingsStore

FindingsStore est un buffer circulaire thread-safe adossé à un tokio::sync::RwLock<VecDeque<StoredFinding>>. Chaque StoredFinding enveloppe un Finding avec un horodatage monotone stored_at_ms.

  • push_batch(findings, now_ms) : construit les nouvelles entrées StoredFinding hors du verrou, puis prend brièvement le verrou d'écriture pour faire un extend du buffer et un drain de l'excédent. Évince les entrées les plus anciennes quand le buffer dépasse max_size (10 000 par défaut, depuis la clé de configuration max_retained_findings). La capacité initiale vaut min(max_size, INITIAL_CAPACITY_CEILING) avec un plafond de 4096, pour amortir les réallocations sans hausse surprenante de la RSS au démarrage.
  • Court-circuit max_size == 0 : à 0, push_batch retourne immédiatement sans allouer. Cela permet aux opérateurs qui désactivent l'API de requête (api_enabled = false) de récupérer la mémoire du store en mettant aussi max_retained_findings = 0.
  • query(filter) : prend un verrou de lecture, parcourt le buffer en ordre inverse (plus récent d'abord), applique les filtres optionnels service, finding_type et severity et retourne jusqu'à limit résultats (100 par défaut, plafonné à MAX_FINDINGS_LIMIT = 1000).
  • by_trace_id(trace_id) : prend un verrou de lecture et retourne tous les findings d'une trace donnée.

RwLock est préféré à Mutex parce que process_traces (l'écrivain) tourne une fois par tick, alors que les handlers de l'API (les lecteurs) peuvent servir des requêtes concurrentes. Plusieurs verrous de lecture ne se bloquent pas entre eux. Les clones ont lieu hors du verrou d'écriture, donc les lecteurs ne sont pas bloqués par les allocations Finding::clone().

État partagé QueryApiState

rust
pub struct QueryApiState {
    pub findings_store: Arc<FindingsStore>,
    pub window: Arc<tokio::sync::Mutex<TraceWindow>>,
    pub detect_config: DetectConfig,
    pub start_time: std::time::Instant,
    pub correlator: Option<Arc<tokio::sync::Mutex<CrossTraceCorrelator>>>,
}

Cette struct est enveloppée dans un Arc et passée comme State axum à tous les handlers de routes. Elle donne accès au buffer circulaire des findings, à la fenêtre de traces (pour explain), à la configuration de détection (pour relancer les détecteurs sur les requêtes explain) et au corrélateur cross-trace facultatif (pour /api/correlations).

Endpoints de l'API

Dix routes sont montées via query_api_router(). Le routeur n'est fusionné dans la pile HTTP que si [daemon] api_enabled = true (défaut true). Mettre api_enabled = false désactive toutes les routes /api/* tout en conservant l'ingestion OTLP, /metrics et /health.

EndpointMéthodePlafondDescription
/api/findingsGET?limit= plafonné à MAX_FINDINGS_LIMIT = 1000Findings récents, avec filtres ?service=, ?type=, ?severity=, ?limit=
/api/findings/{trace_id}GETaucunFindings pour un trace_id spécifique
/api/explain/{trace_id}GETaucunArbre de trace avec findings en ligne (depuis la mémoire du daemon)
/api/correlationsGETtronqué à MAX_CORRELATIONS_LIMIT = 1000 (trié par confiance décroissante)Corrélations cross-trace actives. Vide quand correlator est None
/api/statusGETaucunSanté du daemon : version, uptime, traces actives, findings stockés
/api/configGETaucunConfiguration [daemon] effective, lecture seule, secrets résumés (depuis 0.8.8)
/api/energyGETaucunSanté live des backends énergie/intensité (depuis 0.8.8)
/api/export/reportGEThérite des plafonds /api/findings et /api/correlationsSnapshot Report JSON complet, prêt à piper dans perf-sentinel report --input -
/api/findings/{signature}/ackPOST/DELETEaucunAcquitte (POST) ou révoque (DELETE) un finding au runtime (depuis 0.5.20)
/api/acksGETplafonné à MAX_ACKS_RESPONSE = 1000Liste les acks runtime actifs, plus la baseline TOML CI avec ?include_toml=true

Sémantique du snapshot /api/export/report

L'endpoint retourne un Report de forme identique à la sortie de analyze --format json, donc la réponse peut être redirigée directement vers perf-sentinel report --input - pour matérialiser un dashboard HTML depuis un daemon vivant. Les champs sont remplis depuis l'état live du daemon : findings depuis FindingsStore::query, correlations depuis CrossTraceCorrelator::active_correlations, analysis.events_processed et traces_analyzed depuis les compteurs metrics (valeurs cumulées sur la vie du daemon, pour le contexte).

green_summary est rafraîchi par la boucle événementielle après chaque batch terminé. Vue par batch : chaque champ numérique en dessous (total_io_ops, avoidable_io_ops, io_waste_ratio, co2.*, regions, top_offenders, transport_gco2) reflète uniquement le batch le plus récent, pas un agrégat sur la vie du daemon. Les opérateurs qui veulent des nombres GreenOps cumulés doivent scraper les compteurs Prometheus de /metrics à la place. L'onglet GreenOps du dashboard HTML ne s'affiche que quand green_summary.co2 est non nul, donc un daemon configuré avec Electricity Maps fait apparaître la bannière une fois le premier batch traité. analysis.duration_ms vaut 0, pas l'uptime du daemon : la valeur du pipeline batch chronomètre une seule analyse, et un snapshot de daemon n'a pas de telle exécution.

Gestion du cold-start : l'endpoint retourne 200 OK avec une enveloppe Report vide (findings: [], green_summary: GreenSummary::disabled(0), warnings: ["daemon has not yet processed any events"]). Avant la 0.5.16, ce chemin retournait 503 Service Unavailable, ce qui déclenchait les probes Kubernetes et perturbait les scripts CI traitant les 5xx comme un problème de santé du daemon. L'enveloppe vide permet aux clients de détecter le cold-start sans incohérence de code de statut. La vérification du cold-start s'appuie sur un double compteur (events_processed_total > 0 ET traces_analyzed_total > 0) : des événements peuvent être ingérés quelques secondes avant le premier tick d'éviction (trace_ttl_ms / 2, 15 s par défaut), donc se contenter de events_processed > 0 exposerait une fenêtre où la cellule est encore disabled(0). Le compteur export_report_requests_total est incrémenté avant la vérification de cold-start, donc les réponses de cold-start sont comptées aussi, conformément aux conventions des access logs HTTP.

Taille de réponse : bornée par MAX_FINDINGS_LIMIT + MAX_CORRELATIONS_LIMIT (1000 + 1000 entrées), un green_summary borné (top_offenders plafonné, regions limité par la cardinalité des régions cloud), et embedded_traces sous son propre EMBEDDED_TRACES_BYTE_BUDGET (4 Mio de spans masqués), corps d'environ 7,5 Mo au pire. C'est acceptable sur un bind loopback (la posture documentée). Le plafond mérite une revue si le daemon est un jour exposé hors loopback.

Atomicité du snapshot : le handler acquiert le verrou de lecture du FindingsStore puis le mutex du corrélateur en séquence, pas atomiquement. Les deux collections peuvent donc être décalées d'un batch (findings de la génération N, corrélations de N+1), ce qui est acceptable pour un dashboard post-mortem mais pas pour un contrat de snapshot strict.

Explain sans éviction via peek_clone

Le handler /api/explain/{trace_id} doit lire les spans d'une trace dans la TraceWindow sans la promouvoir dans le cache LRU ni l'évincer. TraceWindow::peek_clone(trace_id) utilise la méthode sous-jacente LruCache::peek() (lecture seule, sans promotion) et clone les spans dans un nouveau Vec<NormalizedEvent>. Le handler reconstruit ensuite une Trace, exécute les détecteurs par trace et construit l'arbre explain via explain::build_tree et explain::format_tree_json.

Si la trace a déjà été évincée de la fenêtre (TTL expiré ou déplacement LRU), le handler retourne {"error": "trace not found in daemon memory"}.

Intégration du corrélateur cross-trace

Création conditionnelle

Dans daemon::run(), le corrélateur n'est créé que si config.correlation_enabled vaut true (false par défaut, activation via [daemon.correlation] enabled = true). Une fois créé, il est enveloppé dans un Arc<Mutex<CrossTraceCorrelator>> :

rust
let correlator = if config.correlation_enabled {
    Some(Arc::new(Mutex::new(
        CrossTraceCorrelator::new(config.correlation_config.clone()),
    )))
} else {
    None
};

Appel dans process_traces

La référence au corrélateur (Option<&Mutex<CrossTraceCorrelator>>) est passée à process_traces. Une fois les findings produits, passés au scoring et poussés dans le FindingsStore, la méthode ingest() du corrélateur est appelée :

rust
if let Some(correlator) = correlator {
    let evicted = correlator.lock().await.ingest(&findings, now_ms);
    // evicted > 0 incrémente perf_sentinel_correlator_pairs_evicted_total
    // et journalise un warn borné (éviction amortie par tranche de 10 % du plafond).
}

Avec cet ordre, le FindingsStore dispose toujours des findings avant que le corrélateur ne les traite.

La contention sur le mutex partagé ne pose pas de problème à l'échelle visée : l'unique worker d'analyse est le seul écrivain, et les lecteurs de /api/correlations s'exécutent au rythme d'un dashboard sur une structure bornée (paires plafonnées, réservoirs de délais de 256 échantillons). enforce_pair_cap retourne immédiatement tant que le plafond n'est pas atteint et utilise un quickselect en O(n) une fois le plafond atteint. Le mutex tokio cède la main plutôt que de bloquer le runtime.

Sortie NDJSON

Les corrélations actives ne sont pas émises sur la sortie NDJSON stdout avec les findings. Elles sont exposées via l'endpoint HTTP /api/correlations et la sous-commande CLI perf-sentinel query correlations. Cette séparation évite de mêler dans le même flux de sortie des findings (par trace, par tick) et des corrélations (agrégées, cross-trace).

Store ack daemon : JSONL + concurrence

Le store ack côté daemon (crates/sentinel-core/src/daemon/ack.rs) complète les acknowledgments TOML côté CI (crate::acknowledgments) avec une API runtime pour les cas d'astreinte SRE. Les deux sources sont réunies au moment de la requête, le TOML l'emportant en cas de conflit (baseline immuable livrée via revue de PR).

Format de fichier

JSONL append-only à ~/.local/share/perf-sentinel/acks.jsonl par défaut. Chaque ligne est un événement :

jsonl
{"action":"ack","signature":"<sig>","by":"alice","reason":"...","at":"2026-05-04T13:30:00Z","expires_at":null}
{"action":"unack","signature":"<sig>","by":"alice","at":"2026-05-04T14:00:00Z"}

Compaction au démarrage

Le daemon rejoue le JSONL dans une HashMap<Signature, AckEntry> (application sur Ack, suppression sur Unack, abandon à l'expiration), puis réécrit atomiquement le fichier via tmp + rename avec uniquement les entrées actives. Une boucle ack/unack qui s'emballe ne peut donc pas accumuler indéfiniment, puisque le fichier se réinitialise à chaque redémarrage.

Modèle de concurrence

La map en mémoire est derrière un RwLock pour des lectures snapshot bon marché. Les écritures disque passent par un Mutex<File> pour que des appels ack/unack concurrents produisent chacun une ligne JSONL bien formée. Le mutex est tenu pendant toute l'écriture et la mise à jour de la map, donc une écriture disque qui échoue ne laisse jamais la map en avance sur l'état persisté.

Parsing du header d'autorisation

Le helper auth-header vit dans crates/sentinel-core/src/ingest/auth_header.rs. Il parse une ligne --auth-header "Name: Value" fournie par l'utilisateur en une paire (HeaderName, HeaderValue) sûre pour hyper, partagée entre les sous-commandes Tempo et Jaeger-Query.

La valeur parsée est marquée sensitive pour qu'hyper l'omette de sa propre sortie de débogage et des tables de compression HPACK HTTP/2. La struct implémente aussi un Debug manuel qui n'imprime jamais la valeur, donc un AuthHeader journalisé ne divulgue jamais le secret.

Règles de validation

Le parsing est strict. Au-delà des vérifications au niveau hyper (nom token-only, valeur VCHAR + SP + HTAB, donc les tabulations et espaces internes dans la valeur sont préservés tels quels et seuls CR/LF + ASCII non-visible sont rejetés), le parseur rejette aussi :

  • Les entrées brutes plus longues que 8 KiB, pour borner le clone par tâche dans le fanout parallèle Tempo et stopper tôt un --auth-header "X: $(cat /dev/urandom | head -c 50M | base64)" pathologique. Un JWT typique fait 2 à 4 KiB, donc 8 KiB laisse de la marge pour des tokens multi-claims longs sans admettre des blobs arbitraires.
  • Les valeurs vides une fois les espaces retirés, qui enverraient un Authorization: inutile au backend et produiraient un 401 confus.
  • Les noms de header qui activeraient du request smuggling ou une substitution de l'autorité s'ils étaient fournis par l'utilisateur : Host, Content-Length, Transfer-Encoding, Connection, Upgrade, TE, Proxy-Connection. Les utilisateurs voulant modifier ceux-là devraient passer par un proxy local, pas par ce flag.

Ingestion des digests performance_schema MySQL

ingest/mysql_stat.rs est le pendant MySQL de pg_stat_statements : il lit un export CSV ou JSON de events_statements_summary_by_digest et classe les points chauds SQL. Comme la voie Postgres, il n'a pas de trace_id, c'est donc une vue complémentaire côté base et non quelque chose que le corrélateur peut joindre.

Deux détails mécaniques faciles à rater. Les colonnes de temps (SUM_TIMER_WAIT, AVG_TIMER_WAIT) arrivent en picosecondes et sont converties au parsing. Les classements sortent dans un ordre fixe (temps total, nombre d'appels, temps moyen, lignes examinées) et les consommateurs indexent dans ce tableau, un nouveau classement s'ajoute donc à la fin et les positions existantes ne bougent jamais.

Marquer un digest comme "vu dans les traces" (le pont digest vers trace) demande de comparer le DIGEST_TEXT de MySQL à un template normalisé depuis du SQL applicatif, et une comparaison littérale ne correspond jamais : MySQL espace chaque token (`c` . `name`), met les mots-clés en majuscules et force les backticks. La normalisation côté application ne conserve rien de tout cela. Les deux côtés sont donc canonicalisés d'abord, en retirant les backticks, en supprimant les espaces autour de la ponctuation, en réduisant les suites d'espaces et en passant en minuscules.

Cette dernière étape a un plafond assumé. La mise en minuscules replie les identifiants en même temps que les mots-clés, donc sur un serveur sensible à la casse (lower_case_table_names=0) deux tables qui ne diffèrent que par la casse partagent une clé et le marqueur peut sur-correspondre. Ne replier que les mots-clés demanderait une table complète des mots-clés MySQL. Pour un marqueur informatif, la sur-correspondance est l'erreur la moins chère.

Gestion des signaux d'arrêt

shutdown.rs se résout sur SIGINT partout, et en plus sur SIGTERM sous Unix, ce que Kubernetes envoie à la terminaison d'un pod, ce que kill envoie par défaut et ce que systemd utilise pour arrêter une unité. Les deux déclenchent le même nettoyage propre. Construisez le future une seule fois et faites tokio::pin! avant une boucle select!, sinon les listeners se réenregistrent à chaque itération.

Une réserve s'applique quand on réutilise ceci hors du daemon : sous Unix, l'enregistrement du handler SIGTERM est global au processus et définitif. Tokio ne restaure jamais la disposition par défaut, donc une fois ce future attendu, le processus ne meurt plus par défaut sur SIGTERM, pour le reste de sa vie, même après que le future a été détruit. Un daemon de longue durée veut ce comportement, mais pas une commande ponctuelle.

Helpers d'ingestion partagés

ingest/lookback.rs et ingest/url_enc.rs existent parce que tempo et jaeger_query avaient besoin des deux mêmes choses et commençaient chacun à faire pousser les siennes. Les deux gardent leur type d'erreur au site d'appel et ne partagent que la logique.

Le parseur de lookback accepte les suffixes d, h, m, s et somme les formes composées (2h30m vaut 9000 s), avec de l'arithmétique vérifiée partout pour que 999999999h remonte en erreur de débordement au lieu de boucler en release.

Les helpers d'URL écrivent à la main un encodeur pour-cent minimal plutôt que de tirer percent-encoding pour douze lignes. Le validateur d'endpoint est étroit : il rejette un schéma autre que http(s) et les identifiants dans l'autorité, et accepte en revanche un @ littéral dans le chemin ou la chaîne de requête, pour que /api/traces?owner=foo%40example.com fonctionne.