Accueil / Articles

À l’intérieur des intercepteurs NestJS : comment corriger une régression de latence de 96 % à grande échelle.

Découvrez comment le pipeline d’exécution AOP de NestJS et les pièges liés à la fermeture des opérations RxJS ont provoqué une augmentation brutale de la latence P99, ainsi que comment créer un intercepteur d’audit sans allocation mémoire pour y remédier.

7 min de lecture1594 mots

Un examen approfondi du pipeline d’exécution AOP, du changement de contexte polymorphe, et de la manière dont les flux RxJS sans allocation mémoire ont permis de réduire la latence P99 de 96 pour cent.

1. L’appel à l’action : lorsque un intercepteur « standard » a perturbé la production

Dans les architectures de microservices à grande échelle, il est tentant de considérer les intercepteurs NestJS comme une sorte de middleware noir dans lequel on insère simplement des éléments sans y réfléchir. Cependant, ignorer la façon dont l’AOP (programmation orientée aspects) gère réellement le contexte d’exécution peut entraîner des pannes douloureuses et embarrassantes dès que le trafic réel arrive.

Imaginez une plateforme de fintech fonctionnant à environ 8 000 requêtes par seconde. Une équipe d’ingénierie a ajouté un intercepteur d’audit apparemment inoffensif destiné à enregistrer les corps des requêtes et les temps de réponse.

Les conséquences sont apparues presque immédiatement après le déploiement :

  • La latence P99 est passée de 18 ms à 420 ms, et les utilisateurs ont immédiatement remarqué ce ralentissement.
  • Le heap de V8 a connu des fluctuations violentes, ce qui a déclenché des cycles complets de collecte de déchets Mark-Sweep, figeant le boucle d’événements pendant jusqu’à 150 ms à la fois.
  • La mémoire a commencé à fuir parce que les Observables de RxJS n’étaient jamais correctement fermés lorsque la connexion client expirait.

Qu’est-ce qui a mal tourné ? L’équipe avait essentiellement écrit l’intercepteur comme s’il s’agissait d’un middleware Express synchrone. Ils exécutaient JSON.parse(JSON.stringify(req.body)) pour chaque requête, se fiaient à new Date() pour mesurer le temps écoulé, et ignoraient complètement le cycle de vie de désinstallation requis par les Observables.

Cette étude approfondie explore le pipeline interne des requêtes de NestJS, examine la source ExecutionContext, et construit un mécanisme d’audit en quelques millisecondes à partir de zéro. Toute personne développant des services Node.js à fort débit devrait considérer ceci comme une liste de pièges à éviter.

2. Décortiquer l’oignon AOP (l’ordre d’exécution exact)

Placer de la logique dans la mauvaise couche de cet « oignon » est la cause la plus fréquente de dégradations de performance :

[ Demande HTTP / RPC entrante ]
 │
 ▼
┌─────────────────────────────────────────────────────────────┐
│ 1. Middleware (Express/Fastify natif - Global/Module) │
└──────────────────────────────┬──────────────────────────────┘
 ▼
┌─────────────────────────────────────────────────────────────┐
│ 2. Gardeurs (CanActivate - Authentification/Autorisation) │
└──────────────────────────────┬──────────────────────────────┘
 ▼
┌─────────────────────────────────────────────────────────────┐
│ 3. Intercepteurs (Pré-controlleur - Enveloppement de la demande) │
└──────────────────────────────┬──────────────────────────────┘
 ▼
┌─────────────────────────────────────────────────────────────┐
│ 4. Tuyaux (ValidationPipe - Transformation/Mutation des données) │
└──────────────────────────────┬──────────────────────────────┘
 ▼
┌─────────────────────────────────────────────────────────────┐
│ 5. Gestionnaire de sortie (votre logique métier de contrôleur) │
└──────────────────────────────┬──────────────────────────────┘
 ▼
┌─────────────────────────────────────────────────────────────┐
│ 6. Intercepteurs (après le contrôleur – enrobage de la réponse RxJS) │
└──────────────────────────────┬──────────────────────────────┘
 ▼
┌─────────────────────────────────────────────────────────────┐
│ 7. Filtres d’exception (formatage global des erreurs) │
└──────────────────────────────┬──────────────────────────────┘
 │
 ▼
 [ Réponse à envoyer ]

Point clé : toute opération coûteuse — y compris la clonage profond — exécutée dans les couches 3 ou 6 bloquera directement le cycle d’événements. Les pipelines existent pour transformer les données ; les intercepteurs existent pour enrober le comportement, et non pour effectuer des tâches synchrones lourdes.

3. Dissection du code source : À l’intérieur de ExecutionContextHost et de la hiérarchie Reflector

Les ingénieurs qui souhaitent comprendre le fonctionnement interne apprécieront de voir comment NestJS change les types de contexte en arrière-plan.

Le secret de l’indexation des tableaux

Dans le fichier @nestjs/core/helpers/execution-context-host.ts, l’objet ExecutionContext n’est en réalité qu’un enveloppe légère autour d’un tableau simple d’arguments. C’est précisément cette conception qui permet à NestJS de prendre en charge uniformément HTTP, gRPC, GraphQL et les transports de microservices sans avoir à traiter chaque cas spécifiquement :

// Logique de base simplifiée
export class ExecutionContextHost implements ExecutionContext {
 constructor(private readonly args: any[]) {}

 switchToHttp(): HttpArgumentsHost {
 return new HttpArgumentsHostImpl(this.args);
 }
 // HTTP : args[0] = Request, args[1] = Response, args[2] = Next
 // RPC : args[0] = Data, args[1] = Context
 // GraphQL : args[0] = Root, args[1] = Args, args[2] = Context, args[3] = Info
}

La chaîne de recherche Reflector

Lorsque vous utilisez @SetMetadata(), le service Reflector résout les valeurs selon un ordre de priorité défini :

  1. Métadonnées au niveau de la méthode (par exemple, @Audit({ action: 'create' }) appliqué à createOrder()).
  • Métadonnées au niveau du contrôleur (par exemple, @Audit({ action: 'default' }) défini sur OrderController).
  • Valeur par défaut de secours.
  • Avertissement pour le environnement de production : parcourir manuellement la chaîne de prototypes à l’intérieur de intercept() est une erreur en termes de performance — cela contourne les tableaux de recherche mémorisés internement par le framework. Préférez Reflector.getAllAndOverride(), qui garantit une résolution en temps constant, O(1).

    4. En pratique : création d’un moteur d’audit sans allocation mémoire

    Pour supporter des charges supérieures à 8 000 requêtes par seconde, votre intercepteur doit respecter trois contraintes strictes :

    1. Temps d’exécution sans allocation mémoire : utilisez process.hrtime.bigint() plutôt que d’instancier new Date().
    2. Déploiement non bloquant : reportez le travail d’écriture des journaux hors du flux principal en utilisant setImmediate ou une file de messages dédiée.
    3. Sécurité contre les délais : enveloppez le flux avec timeout(5000) afin que la fermeture de RxJS soit garantie même en cas d’échec.

    Étape 1 : Décorateurs fortement typés

    import { SetMetadata, createParamDecorator, ExecutionContext } from '@nestjs/common';
    
    export const AUDIT_ACTION_KEY = 'AUDIT_ACTION_KEY';
    export interface AuditMetadata {
     action: string;
     resource: string;
    }
    export const Audit = (metadata: AuditMetadata) => SetMetadata(AUDIT_ACTION_KEY, metadata);
    // Extraction du contexte client sans propagation d’objets
    export const ClientContext = createParamDecorator(
     (data: unknown, ctx: ExecutionContext) => {
     const req = ctx.switchToHttp().getRequest();
     return {
     ip: req.ip || req.socket.remoteAddress
    import {
     Injectable,
     NestInterceptor,
     ExecutionContext,
     CallHandler,
     Logger,
    } from '@nestjs/common';
    import { Reflector } from '@nestjs/core';
    import { Observable, throwError, TimeoutError } from 'rxjs';
    import { tap, catchError, timeout } from 'rxjs/operators';
    import { AUDIT_ACTION_KEY, AuditMetadata } from './audit.decorator';
    
    @Injectable()
    export class PerformanceAuditInterceptor implements NestInterceptor {
     private readonly logger = new Logger('AuditEngine');
     constructor(private readonly reflector: Reflector) {}
     intercept(context: ExecutionContext, next: CallHandler): Observable<any>> {
     // Déviation par voie rapide pour les routes non auditées
     const auditConfig = this.reflector.getAllAndOverride<AuditMetadata>>(
     AUDIT_ACTION_KEY,
     [context.getHandler(), context.getClass()],
     );
     if (!auditConfig) {
     return next.handle();
     }
     const http 
    = context.switchToHttp(); const request = http.getRequest(); const traceId = request.headers['x-trace-id'] || 'trace-none'; const method = request.method; const url = request.url; // Précision au nanoseconde, aucune mémoire allouée const startTime = process.hrtime.bigint(); return next.handle().pipe( // Imposition d’une limite stricte de 5 secondes pour éviter les flux bloqués timeout(5000), tap({ next: () => { const durationNs = process.hrtime.bigint() - startTime; const durationMs = Number(durationNs) / 1_000_000; this.dispatchAuditLog({ traceIdig.action
  • Architecture B (l’approche optimisée) : utilisation de process.hrtime.bigint() pour mesurer le temps d’exécution et transfert des tâches via setImmediate.
  • Les résultats étaient éloquents. Le débit est passé de 4 120 requêtes par seconde à 9 840, soit une augmentation de 138 %. La latence médiane (P50) est descendue de 22 ms à 3,1 ms, ce qui représente une réduction de 85,9 %. La latence dans la tranche P99 est passée de 385 ms à 14,2 ms, soit une amélioration de 96,3 %. La consommation mémoire du heap V8 est tombée de 340 MB/seconde à 18 MB/seconde, soit une réduction de 94,7 %.

    Ces chiffres ne sont pas abstraits — ils marquent la frontière entre un service qui s’adapte correctement et un autre qui cède face à une augmentation soudaine du trafic.

    6. Pièges souvent négligés (à éviter)

    Piège 1 : Briser la chaîne RxJS

    Si vous oubliez d’appeler return next.handle(), la requête restera bloquée indéfiniment, car rien n’est abonné au flux sous-jacent.

    // ❌ Ne faites pas cela
    intercept(ctx, next) {
     this.validate(ctx);
     // Le retour est manquant
    }
    
    // ✅ Faites ceci
    intercept(ctx, next) {
     return next.handle().pipe(...);
    }
    

    Péril 2 : La fuite des « Observable à longue durée de vie »

    Péril 3 : Réflexion inutile des métadonnées

    Évitez de parcourir manuellement la chaîne de prototypes. Faites entièrement confiance au service intégré Reflector, qui gère un cache global des clés de métadonnées et les résout efficacement.

    7. Principes architecturaux pour une haute concurrence

    1. Gardez les singletons sans état : un intercepteur est créé une seule fois pendant toute la durée de vie de l’application, il ne doit donc jamais conserver de données spécifiques à une requête en tant que champs d’instance.
    2. Découplez de manière stricte : si votre journal d’audit doit être persisté dans une base de données, confiez cette tâche à un mécanisme comme une file BullMQ ou un buffer circulaire plutôt que d’effectuer directement l’écriture via await à l’intérieur de l’intercepteur.
    3. Respectez les limites des couches : utilisez Pipes pour transformer les données d’entrée et réservez les Interceptors à l’enveloppement du comportement d’exécution. Mélanger ces responsabilités génère un code difficile à maintenir et lent à exécuter.

    Lectures complémentaires