Skip to content
Retour au blog
Tutoriels

Ignorer timestamps et IDs dans un JSON Diff (sans jq)

Les diffs de régression API sont bruyants à 80% — timestamps, request IDs, UUIDs qui changent à chaque requête. Utilisez les patterns JSON Pointer étendus pour ne voir que les vrais changements.

12 min de lecture

Comment ignorer les timestamps et IDs dans un diff JSON (sans écrire de jq)

Vous ajoutez un nouveau champ à un endpoint REST. Vos tests de snapshot passent au rouge — non pas parce que ce champ a changé, mais parce que createdAt, requestId et traceId ont muté entre le snapshot enregistré et la nouvelle exécution. Vous passez dix minutes à confirmer que le diff est du bruit, vous faites taire le test et vous passez à autre chose. Cela se répète pour chaque membre de l’équipe, à chaque build, chaque semaine.

Le problème n’est pas le framework de test. Le problème, c’est que le diff JSON brut traite chaque champ de manière égale. Un UUID rotatif est structurellement identique à un champ portant une signification sémantique. Sans moyen de dire « ignore ce chemin », la surface de diff inclut chaque timestamp embarqué par le serveur, chaque ID auto-incrémenté, chaque jeton de corrélation par requête — aucun d’eux ne reflète si le contrat API a changé.

Ce guide couvre les outils et patterns qui vous permettent de supprimer ce bruit à la source : la syntaxe JSON Pointer, les patterns wildcard pour les tableaux, les helpers TypeScript pour le matching par nom de clé, l’intégration des snapshots Vitest, et un petit script CI qui ne retourne un code non-zéro que lorsque le diff contient quelque chose qui importe vraiment.

Le problème du « diff bruyant » — Pourquoi la CI échoue sur des changements non pertinents

Considérons un workflow de régression API classique. Vous enregistrez la réponse de GET /orders/42 :

{
  "id": "ord_9f8e7d6c",
  "status": "shipped",
  "createdAt": "2026-04-01T10:00:00Z",
  "updatedAt": "2026-04-01T12:34:56Z",
  "requestId": "req_abc123",
  "traceId": "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01"
}

Vous snapshotez cette réponse. Deux semaines plus tard, un collègue ajoute un champ shippingCarrier et relance la suite. Le diff affiche six lignes modifiées — quatre sont des timestamps et des IDs qui ont tourné côté serveur. Le seul changement significatif (le nouveau champ) est noyé dans le bruit. Votre collègue confirme que rien n’est cassé, met à jour le snapshot, et la revue passe sans que personne n’ait vraiment lu le diff fonctionnel.

Maintenant multipliez cela sur un maillage de microservices avec une centaine d’endpoints. À chaque build, la CI diffe des fichiers snapshot contenant requestId, traceId, version, etag, x-request-start, serverTime. Les relecteurs deviennent insensibles aux diffs de snapshot et les approuvent sans les lire. Le signal de régression s’effondre.

La solution n’est pas d’arrêter les tests de snapshot. C’est de supprimer le bruit avant que le diff ne s’exécute.

Ce qui compte comme bruit : timestamps, UUIDs, auto-IDs, hashes

Tout champ qui mute n’est pas du bruit. Un status qui passe de processing à shipped est un signal. La question est : quels champs sont structurellement incapables de porter une information de régression parce qu’ils tournent par conception.

CatégorieExemples de noms de champsPourquoi il mute
TimestampscreatedAt, updatedAt, deletedAt, lastSeen, expiresAtHorloge serveur, heure de la requête
Corrélation de requêterequestId, traceId, spanId, correlationIdGénéré par requête
IDs auto-générésid, uuid, nonce, idempotencyKeyUUID v4 / ULID / KSUID à chaque insertion
ETags / jetons de cacheetag, eTag, cacheKey, version (quand auto-incrémenté)Hash de contenu ou séquence
Métadonnées de buildbuildHash, deployId, serverVersion, hostnameDéfini au déploiement
Curseurs de paginationnextCursor, prevCursor, pageTokenÉtat encodé, opaque

Les champs de ce tableau sont candidats à la liste d’exclusion. Les champs qui encodent l’état réel du domaine — status, amount, userId, items — ne doivent jamais en faire partie.

L’objectif est une liste d’exclusion qui vit dans le code, est revue dans les PR, et est auditée lorsque les exigences de sécurité changent. Pas un pipeline jq ponctuel qu’un développeur a écrit localement et oublié de committer.

Introduction à la syntaxe JSON Pointer (RFC 6901)

JSON Pointer, défini dans RFC 6901, est le schéma d’adressage utilisé par JSON Patch (RFC 6902) et la plupart des outils de diff structuré. Il ressemble à un chemin de système de fichiers et adresse un emplacement précis dans un document JSON.

/               → document racine
/foo            → clé d'objet "foo"
/foo/bar        → clé imbriquée "bar" dans "foo"
/items/0        → premier élément du tableau "items"
/items/0/name   → clé "name" dans le premier élément

Deux séquences d’échappement gèrent les caractères spéciaux :

  • ~0 représente un ~ littéral
  • ~1 représente un / littéral

Ainsi, la clé a/b est adressée par /a~1b, et la clé a~b par /a~0b.

JSON Pointer diffère de JSONPath (utilisé dans jq, OpenAPI) sur un point clé : il adresse exactement un emplacement — pas de wildcards, pas de descente récursive, pas d’expressions de filtre. RFC 6901 est strict et simple. La contrepartie est que pour ignorer un champ dans tous les éléments d’un tableau, il faut soit un outil qui étend la spec avec une syntaxe wildcard, soit itérer les chemins de pointeur par programmation.

Lorsque vous voyez des champs path dans les opérations JSON Patch ([{"op":"replace","path":"/status","value":"shipped"}]), ce sont des pointeurs RFC 6901. Les outils comme fast-json-patch et rfc6902 utilisent cette notation nativement.

Patterns wildcard : /users/*/lastSeen pour les tableaux

RFC 6901 ne définit pas les wildcards. Mais la plupart des outils de diff utiles en pratique étendent la spec avec un wildcard à segment unique : * correspond à n’importe quel segment de chemin sans traverser /.

Les règles pour le pattern Extended JSON Pointer (tel qu’implémenté dans l’outil JSON Diff de Go Tools) :

  • * correspond à n’importe quel segment de chemin unique (index de tableau ou clé d’objet)
  • * ne traverse pas / — ce n’est pas un glob récursif
  • Pour correspondre à un astérisque littéral dans un nom de clé, échappez-le avec \*
  • ** (descente récursive double-étoile) n’est pas supporté — utilisez des chemins explicites

Exemples :

/users/*/lastSeen        → correspond à /users/0/lastSeen, /users/1/lastSeen, etc.
/orders/*/updatedAt      → correspond à updatedAt dans chaque élément du tableau orders
/*/requestId             → correspond à requestId dans chaque clé d'objet de premier niveau
/data/items/*/id         → correspond à id dans chaque élément d'un tableau items imbriqué

La contrainte à un seul niveau est importante. /users/*/profile/*/tag est valide et correspond exactement à deux niveaux de descente wildcard. Il ne correspond pas à /users/0/tag (trop peu profond) ni à /users/0/profile/0/meta/tag (trop profond).

Liste d’exclusion pratique pour une réponse REST API typique :

/requestId
/traceId
/createdAt
/updatedAt
/data/*/createdAt
/data/*/updatedAt
/data/*/id
/pagination/nextCursor

Cela couvre les cas courants sans toucher aucun champ sémantique.

Démo en direct : ignorer createdAt dans un payload webhook Stripe

Les webhooks Stripe sont un exemple concret. Chaque enveloppe d’événement contient created (timestamp Unix), id (ID d’événement), request.id et request.idempotency_key — tous rotatifs par invocation et inutiles pour les tests de régression.

Payload webhook brut :

{
  "id": "evt_1OqLmn2eZvKYlo2C9Pt1a4X7",
  "object": "event",
  "created": 1714819200,
  "type": "payment_intent.succeeded",
  "request": {
    "id": "req_Bc7dEfGhIj",
    "idempotency_key": null
  },
  "data": {
    "object": {
      "id": "pi_3OqLmn2eZvKYlo2C0Pt1a4X8",
      "amount": 2000,
      "currency": "usd",
      "status": "succeeded"
    }
  }
}

Liste d’exclusion pour cette structure :

/id
/created
/request/id
/request/idempotency_key
/data/object/id

Après application de ces exclusions, la surface de diff ne contient que type, data/object/amount, data/object/currency et data/object/status — les champs qui encodent réellement si le comportement de paiement a changé.

Collez les deux payloads avant et après dans l’outil JSON Diff, entrez les chemins d’exclusion, et seules les opérations significatives restent dans la sortie JSON Patch. Pas de pipeline jq. Pas de script de pré-traitement.

Matching de champs par regex pour les clés inconnues

Les chemins JSON Pointer fonctionnent quand vous connaissez la structure à l’avance. Ils s’effondrent dans deux scénarios : les clés d’objet dynamiques (maps avec des clés UUID, blobs de données par utilisateur) et les patterns « tout champ dont le nom se termine par Id ».

Pour ces cas, vous avez besoin d’un matching de clé par regex. Voici un helper TypeScript qui parcourt un arbre JSON et collecte tous les chemins de pointeur dont le nom de clé correspond à un pattern :

type JsonValue = string | number | boolean | null | JsonValue[] | { [k: string]: JsonValue };

function findPathsByKeyPattern(
  value: JsonValue,
  pattern: RegExp,
  currentPath = ""
): string[] {
  const paths: string[] = [];

  if (Array.isArray(value)) {
    value.forEach((item, i) => {
      paths.push(...findPathsByKeyPattern(item, pattern, `${currentPath}/${i}`));
    });
  } else if (value !== null && typeof value === "object") {
    for (const [key, child] of Object.entries(value)) {
      const encodedKey = key.replace(/~/g, "~0").replace(/\//g, "~1");
      const childPath = `${currentPath}/${encodedKey}`;
      if (pattern.test(key)) {
        paths.push(childPath);
      }
      paths.push(...findPathsByKeyPattern(child, pattern, childPath));
    }
  }

  return paths;
}

// Usage: collect all paths whose key name ends in "Id" or "At"
const noisePattern = /(?:Id|At|Stamp|Hash|Token|Cursor)$/;
const ignorePaths = findPathsByKeyPattern(responseJson, noisePattern);
// → ["/requestId", "/data/0/createdAt", "/data/0/updatedAt", ...]

Cela vous donne une liste d’exclusion dynamique dérivée de la structure réelle de la réponse plutôt qu’un ensemble de chaînes maintenu manuellement. La mise en garde : si un champ légitime se termine par Id (comme userId ou orderId), vous devrez soit affiner le pattern, soit soustraire ces chemins du résultat. Les patterns de noms ne remplacent pas les listes de chemins explicites — ils les complètent pour les grandes API où maintenir chaque chemin manuellement est impraticable.

Sortie JSON Patch avec les chemins ignorés filtrés

Un diff JSON exprimé en JSON Patch (RFC 6902) est une liste d’opérations :

[
  { "op": "replace", "path": "/updatedAt", "value": "2026-05-04T09:00:00Z" },
  { "op": "replace", "path": "/status", "value": "shipped" },
  { "op": "add", "path": "/shippingCarrier", "value": "FedEx" }
]

Filtrer les chemins ignorés signifie supprimer les opérations dont le path correspond à une entrée de votre liste d’exclusion (en tenant compte des wildcards). La sortie filtrée :

[
  { "op": "replace", "path": "/status", "value": "shipped" },
  { "op": "add", "path": "/shippingCarrier", "value": "FedEx" }
]

Deux mises en garde importantes :

  1. Avertissement de non-aller-retour. Un patch filtré ne peut pas être appliqué pour reconstruire le document cible. Si vous n’appliquez que les opérations non-bruit à la source, vous obtenez un document auquel manquent les mises à jour de timestamp. Conservez le patch complet pour la reconstruction de document ; utilisez le patch filtré uniquement pour le diff et les alertes.

  2. Sensibilité à l’ordre. Les opérations JSON Patch sont ordonnées. Filtrer des ops au milieu d’un patch qui contient des opérations move ou copy référençant des positions antérieures peut produire un patch invalide. Si vous devez appliquer le patch filtré, appliquez d’abord le patch complet, puis re-diffez le résultat par rapport à votre état attendu.

L’outil JSON Diff produit à la fois le patch complet et la vue filtrée, vous permettant d’inspecter le diff brut avant de décider quelles opérations supprimer.

Recettes de tests de snapshot (Jest / Vitest)

Le système de snapshot de Vitest sérialise les valeurs en chaînes et les diffe à la réexécution. Le sérialiseur par défaut capture chaque champ, y compris les bruyants. La solution est un sérialiseur personnalisé qui supprime les chemins ignorés avant le snapshot.

// tests/setup/json-diff-serializer.ts
import { expect } from "vitest";

const IGNORE_PATHS = new Set([
  "/requestId",
  "/traceId",
  "/createdAt",
  "/updatedAt",
  "/data/id",
]);

function stripPaths(obj: unknown, path = ""): unknown {
  if (Array.isArray(obj)) {
    return obj.map((item, i) => stripPaths(item, `${path}/${i}`));
  }
  if (obj !== null && typeof obj === "object") {
    const result: Record<string, unknown> = {};
    for (const [key, value] of Object.entries(obj as Record<string, unknown>)) {
      const childPath = `${path}/${key.replace(/~/g, "~0").replace(/\//g, "~1")}`;
      if (!IGNORE_PATHS.has(childPath)) {
        result[key] = stripPaths(value, childPath);
      }
    }
    return result;
  }
  return obj;
}

expect.addSnapshotSerializer({
  test: (val) => val !== null && typeof val === "object" && !Array.isArray(val),
  print: (val, serialize) => serialize(stripPaths(val)),
});

Enregistrez-le dans vitest.config.ts :

import { defineConfig } from "vitest/config";

export default defineConfig({
  test: {
    setupFiles: ["./tests/setup/json-diff-serializer.ts"],
  },
});

Vos snapshots ressemblent maintenant à :

it("returns order with shipping carrier", async () => {
  const result = await getOrder("ord_42");
  expect(result).toMatchSnapshot(); // createdAt, requestId etc. are stripped
});

Le fichier snapshot ne contient que les champs stables. Relancer la suite après un changement de timestamp produit un diff zéro. Quand le champ shippingCarrier est ajouté, le diff de snapshot affiche exactement ce seul changement. Vous pouvez aussi utiliser l’outil JSON Diff pour vérifier manuellement ce qui a changé entre deux réponses enregistrées avant de mettre à jour les snapshots.

Intégration CI : faire échouer les builds uniquement sur les diffs significatifs

L’objectif final : une étape CI qui télécharge une réponse de référence, appelle l’endpoint en direct, diffe les deux, supprime le bruit, et ne retourne un code non-zéro que si des opérations significatives subsistent.

// scripts/api-regression-check.ts
import { applyPatch, compare } from "fast-json-patch";
import { readFileSync } from "fs";

const IGNORE_PATTERNS = [
  /^\/requestId$/,
  /^\/traceId$/,
  /^\/createdAt$/,
  /^\/updatedAt$/,
  /^\/data\/\d+\/createdAt$/,
  /^\/data\/\d+\/updatedAt$/,
  /^\/data\/\d+\/id$/,
];

function isMeaningfulOp(op: { path: string }): boolean {
  return !IGNORE_PATTERNS.some((re) => re.test(op.path));
}

async function main() {
  const reference = JSON.parse(readFileSync("reference.json", "utf-8"));
  const response = await fetch(process.env.API_URL + "/orders").then((r) => r.json());

  const ops = compare(reference, response);
  const meaningfulOps = ops.filter(isMeaningfulOp);

  if (meaningfulOps.length > 0) {
    console.error("Meaningful diff detected:");
    console.error(JSON.stringify(meaningfulOps, null, 2));
    process.exit(1);
  }

  console.log(`Clean diff (${ops.length} noise ops suppressed).`);
}

main().catch((e) => { console.error(e); process.exit(1); });

Étape GitHub Actions :

- name: API regression check
  env:
    API_URL: ${{ secrets.STAGING_API_URL }}
  run: npx tsx scripts/api-regression-check.ts

La fonction compare de fast-json-patch retourne des opérations RFC 6902. Le filtre s’applique sur les chaînes path en utilisant la même approche regex que le sérialiseur Vitest. Le script retourne 0 si seul du bruit a changé, 1 si un champ significatif diffère.

Pour le matching wildcard (couvrir /data/*/updatedAt au lieu de /data/\d+\/updatedAt), remplacez la liste de regex par un convertisseur glob-vers-regex ou utilisez le format de liste d’exclusion accepté par l’outil JSON Diff et intégrez-le dans votre pipeline via son export.

Quand ne PAS ignorer : champs sensibles à la sécurité, pistes d’audit

La liste d’exclusion est puissante. Elle est aussi dangereuse lorsqu’elle est appliquée sans discipline. Certains champs ressemblent à du bruit mais portent des informations critiques pour la sécurité.

Ne pas ignorer :

  • createdAt dans les journaux d’audit ou les enregistrements financiers. Si un timestamp de transaction est erroné, c’est un bug — pas du bruit. Les tables d’audit existent précisément pour détecter la falsification via des anomalies de timestamp.
  • Les champs id dans les intentions de paiement, factures ou enregistrements réglementaires. Un ID rotatif dans un contexte de paiement peut indiquer des charges dupliquées ou des événements mal routés.
  • version ou etag dans les systèmes de concurrence optimiste. Si deux processus écrivent avec la même version, l’un échouera. Le champ version n’est pas décoratif.
  • traceId ou spanId lors du débogage de défaillances de systèmes distribués. Ce sont du bruit dans les tests de snapshot mais un signal dans l’analyse d’incidents en production.
  • Tout champ signalé par votre équipe de sécurité pour la surveillance — les règles SIEM, les exigences de conformité et les politiques de rétention des données peuvent dépendre de la visibilité de ces champs dans les diffs.

Le bon modèle est de traiter la liste d’exclusion comme une liste d’autorisation revue par le code : une constante nommée ou un fichier de configuration, versionné dans le dépôt, avec un commentaire expliquant pourquoi chaque chemin y figure. Les modifications de la liste d’exclusion doivent nécessiter la même revue que les modifications de la configuration de production.

// Explicit, auditable, requires PR review to change
export const SNAPSHOT_IGNORE_PATHS = [
  "/requestId",   // per-request UUID, no semantic content
  "/traceId",     // OpenTelemetry trace ID, rotates per request
  "/createdAt",   // server clock, stable in production fixtures
  // "/amount"    // NEVER ignore — financial field
] as const;

Si vous n’êtes pas sûr qu’un champ devrait figurer dans la liste d’exclusion, préférez le garder dans le diff. Un faux positif (du bruit dans le diff) coûte quelques secondes de relecture. Un faux négatif (une régression manquée parce que le chemin était ignoré) coûte à vos utilisateurs.

Récapitulatif

Les diffs JSON bruts sont bruyants parce que les réponses serveur embarquent des champs qui tournent par conception. La syntaxe JSON Pointer (RFC 6901) vous offre un schéma d’adressage précis. Les patterns wildcard (/data/*/createdAt) l’étendent aux tableaux sans écrire de code de traversal personnalisé. Le matching de clé par regex gère les structures dynamiques dont les chemins ne sont pas connus à l’avance. La sortie JSON Patch (RFC 6902) avec les chemins ignorés filtrés produit un signal propre pour la CI. Un sérialiseur personnalisé Vitest applique la même logique aux tests de snapshot. Et une liste d’exclusion disciplinée et revue par le code garantit que la suppression du bruit n’avale pas accidentellement une vraie régression.

Essayez-le en pratique dans l’outil JSON Diff — collez deux réponses JSON, entrez vos chemins d’exclusion, et ne voyez que les opérations significatives. Utilisez le Formateur JSON pour nettoyer les réponses minifiées avant de les differ.

Articles connexes

Voir tous les articles