Skip to content
Volver al blog
Tutoriales

Cómo ignorar timestamps e IDs en JSON Diff (sin escribir jq)

Los diffs de regresión de API son 80% ruido: timestamps, IDs de solicitud, UUIDs que mutan por petición. Usa patrones Extended JSON Pointer para ver solo los cambios significativos.

12 min de lectura

Cómo ignorar timestamps e IDs en JSON Diff (sin escribir jq)

Añades un nuevo campo a un endpoint REST. Tus tests de snapshot fallan — no porque el campo haya cambiado, sino porque createdAt, requestId y traceId mutaron entre el snapshot grabado y la nueva ejecución. Pasas diez minutos confirmando que el diff es ruido, silencias el test y sigues adelante. Esto se repite para cada miembro del equipo, en cada build, cada semana.

El problema no es el framework de tests. El problema es que el diff JSON crudo trata todos los campos por igual. Un UUID rotativo es estructuralmente idéntico a un campo que lleva significado semántico. Sin una forma de decir “ignora esta ruta”, la superficie de diff incluye cada timestamp que el servidor embebe, cada ID autoincrementado, cada token de correlación por solicitud — ninguno de los cuales refleja si el contrato de la API cambió.

Esta guía cubre las herramientas y patrones que te permiten suprimir ese ruido en la fuente: sintaxis de rutas JSON Pointer, patrones con comodín para arrays, helpers de TypeScript para coincidencia por clave, integración con snapshots de Vitest, y un pequeño script de CI que sale con código distinto de cero solo cuando el diff contiene algo que realmente importa.

El problema del “diff ruidoso” — por qué CI falla en cambios irrelevantes

Considera un flujo típico de regresión de API. Grabas la respuesta 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"
}

Guardas ese snapshot. Dos semanas después, un compañero añade un campo shippingCarrier y ejecuta la suite. El diff muestra seis líneas cambiadas — cuatro son timestamps e IDs que rotaron en el servidor. El único cambio significativo (el nuevo campo) queda enterrado en el ruido. Tu compañero confirma que nada está roto, actualiza el snapshot y la revisión continúa sin que nadie lea realmente el diff funcional.

Multiplica esto por una malla de microservicios con cien endpoints. En cada build, CI compara archivos de snapshot que contienen requestId, traceId, version, etag, x-request-start, serverTime. Los revisores se vuelven insensibles a los diffs de snapshot y los aprueban sin leerlos. La señal de regresión colapsa.

La solución no es dejar de hacer snapshot testing. Es eliminar el ruido antes de que el diff se ejecute.

Qué cuenta como ruido: timestamps, UUIDs, auto-IDs, hashes

No todo campo que muta es ruido. Un status que cambia de processing a shipped es señal. La pregunta es qué campos son estructuralmente incapaces de llevar información de regresión porque rotan por diseño.

CategoríaNombres de campo de ejemploPor qué muta
TimestampscreatedAt, updatedAt, deletedAt, lastSeen, expiresAtReloj del servidor, tiempo de solicitud
Correlación de solicitudrequestId, traceId, spanId, correlationIdGenerado por solicitud
IDs autogeneradosid, uuid, nonce, idempotencyKeyUUID v4 / ULID / KSUID en cada inserción
ETags / tokens de cachéetag, eTag, cacheKey, version (cuando se autoincrementa)Hash de contenido o secuencia
Metadatos de buildbuildHash, deployId, serverVersion, hostnameEstablecido en tiempo de despliegue
Cursores de paginaciónnextCursor, prevCursor, pageTokenEstado codificado, opaco

Los campos de esta tabla son candidatos para la lista de ignorados. Los campos que codifican estado de dominio real — status, amount, userId, items — nunca deben estar en ella.

El objetivo es una lista de ignorados que viva en el código, se revise en PRs y se audite cuando cambien los requisitos de seguridad. No un pipeline de jq puntual que alguien escribió localmente y olvidó commitear.

Introducción a la sintaxis JSON Pointer (RFC 6901)

JSON Pointer, definido en RFC 6901, es el esquema de direccionamiento utilizado por JSON Patch (RFC 6902) y la mayoría de las herramientas de diff estructurado. Se parece a una ruta de sistema de archivos y direcciona una ubicación específica en un documento JSON.

/               → documento raíz
/foo            → clave de objeto "foo"
/foo/bar        → clave anidada "bar" dentro de "foo"
/items/0        → primer elemento del array "items"
/items/0/name   → clave "name" dentro del primer elemento

Dos secuencias de escape manejan caracteres especiales:

  • ~0 representa un ~ literal
  • ~1 representa un / literal

Por lo tanto, la clave a/b se direcciona como /a~1b, y la clave a~b se direcciona como /a~0b.

JSON Pointer difiere de JSONPath (usado en jq, OpenAPI) en un aspecto clave: direcciona exactamente una ubicación — sin comodines, sin descenso recursivo, sin expresiones de filtro. RFC 6901 es estricto y simple. La contrapartida es que ignorar un campo en todos los elementos de un array requiere o bien una herramienta que extienda la especificación con sintaxis de comodín, o bien iterar rutas de puntero de forma programática.

Cuando ves campos path en operaciones JSON Patch ([{"op":"replace","path":"/status","value":"shipped"}]), esos son punteros RFC 6901. Herramientas como fast-json-patch y rfc6902 usan esta notación de forma nativa.

Patrones con comodín: /users/*/lastSeen para arrays

RFC 6901 no define comodines. Pero la mayoría de las herramientas de diff útiles en la práctica extienden la especificación con un comodín de un solo segmento: * coincide con cualquier segmento de ruta sin cruzar /.

Las reglas para Extended JSON Pointer Pattern (tal como se implementa en la herramienta JSON Diff de Go Tools):

  • * coincide con cualquier segmento de ruta único (índice de array o clave de objeto)
  • * no cruza / — no es un glob recursivo
  • Para coincidir con un asterisco literal en un nombre de clave, escápalo como \*
  • ** (descenso recursivo de doble asterisco) no está soportado — usa rutas explícitas

Ejemplos:

/users/*/lastSeen        → coincide con /users/0/lastSeen, /users/1/lastSeen, etc.
/orders/*/updatedAt      → coincide con updatedAt en cada elemento del array orders
/*/requestId             → coincide con requestId en cada clave de objeto de nivel superior
/data/items/*/id         → coincide con id en cada elemento de un array items anidado

La restricción de un solo nivel importa. /users/*/profile/*/tag es válido y coincide exactamente con dos niveles de descenso con comodín. No coincide con /users/0/tag (demasiado superficial) ni con /users/0/profile/0/meta/tag (demasiado profundo).

Lista de ignorados práctica para una respuesta típica de API REST:

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

Esto cubre los casos comunes sin tocar ningún campo semántico.

Demo en vivo: ignorar createdAt en un payload de Stripe Webhook

Los webhooks de Stripe son un ejemplo concreto. Cada envelope de evento contiene created (timestamp Unix), id (ID de evento), request.id y request.idempotency_key — todos los cuales rotan por invocación y son inútiles para las pruebas de regresión.

Payload de webhook sin procesar:

{
  "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"
    }
  }
}

Lista de ignorados para esta forma:

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

Tras aplicar estos ignorados, la superficie de diff contiene solo type, data/object/amount, data/object/currency y data/object/status — los campos que realmente codifican si el comportamiento del pago cambió.

Pega ambos payloads (antes y después) en la herramienta JSON Diff, introduce las rutas de ignorado, y solo las operaciones significativas permanecerán en la salida JSON Patch. Sin pipeline de jq. Sin script de preprocesamiento.

Coincidencia de campos por regex para claves desconocidas

Las rutas JSON Pointer funcionan cuando conoces la estructura de antemano. Fallan en dos escenarios: claves de objeto dinámicas (mapas con claves UUID, blobs de datos por usuario) y patrones del tipo “cualquier campo cuyo nombre termine en Id”.

Para estos casos, necesitas coincidencia de clave por regex. Aquí tienes un helper de TypeScript que recorre un árbol JSON y recopila todas las rutas de puntero cuyo nombre de clave coincide con un patrón:

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

// Uso: recopila todas las rutas cuyo nombre de clave termine en "Id" o "At"
const noisePattern = /(?:Id|At|Stamp|Hash|Token|Cursor)$/;
const ignorePaths = findPathsByKeyPattern(responseJson, noisePattern);
// → ["/requestId", "/data/0/createdAt", "/data/0/updatedAt", ...]

Esto te da una lista de ignorados dinámica derivada de la forma real de la respuesta en lugar de un conjunto de cadenas mantenido manualmente. La advertencia: si un campo legítimo termina en Id (como userId u orderId), necesitas refinar el patrón o restar esas rutas del resultado. Los patrones de nombre no son un sustituto de las listas de rutas explícitas — son un complemento para APIs grandes donde mantener cada ruta manualmente es impracticable.

Salida JSON Patch con rutas ignoradas eliminadas

Un diff JSON expresado como JSON Patch (RFC 6902) es una lista de operaciones:

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

Filtrar las rutas ignoradas significa eliminar operaciones cuyo path coincida con alguna entrada de tu lista de ignorados (teniendo en cuenta los comodines). La salida filtrada:

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

Dos advertencias importantes:

  1. Advertencia de no ida y vuelta. Un patch filtrado no puede aplicarse para reconstruir el documento destino. Si aplicas solo las operaciones no ruidosas al documento fuente, obtienes un documento al que le faltan las actualizaciones de timestamp. Guarda el patch completo para la reconstrucción del documento; usa el patch filtrado solo para diffing y alertas.

  2. Sensibilidad al orden. Las operaciones JSON Patch están ordenadas. Filtrar operaciones del medio de un patch que tiene operaciones move o copy que referencian posiciones anteriores puede producir un patch inválido. Si necesitas aplicar el patch filtrado, aplica primero el patch completo y luego vuelve a hacer diff del resultado contra tu estado esperado.

La herramienta JSON Diff produce tanto el patch completo como la vista filtrada, para que puedas inspeccionar el diff crudo antes de decidir qué operaciones suprimir.

Recetas de snapshot testing (Jest / Vitest)

El sistema de snapshots de Vitest serializa valores a cadenas y los compara en cada ejecución. El serializador predeterminado captura todos los campos, incluidos los ruidosos. La solución es un serializador personalizado que elimina las rutas ignoradas antes de guardar el 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)),
});

Regístralo en vitest.config.ts:

import { defineConfig } from "vitest/config";

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

Ahora tus snapshots se ven así:

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

El archivo de snapshot contiene solo campos estables. Volver a ejecutar la suite después de un cambio de timestamp produce diff cero. Cuando se añade el campo shippingCarrier, el diff del snapshot muestra exactamente ese único cambio. También puedes usar la herramienta JSON Diff para verificar manualmente qué cambió entre dos respuestas grabadas antes de actualizar snapshots.

Integración con CI: builds que fallan solo en diffs significativos

El objetivo final: un paso de CI que descarga una respuesta de referencia, llama al endpoint en vivo, compara los dos, elimina el ruido y sale con código distinto de cero solo si quedan operaciones significativas.

// 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); });

Paso de GitHub Actions:

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

La función compare de fast-json-patch devuelve operaciones RFC 6902. El filtro actúa sobre cadenas path usando el mismo enfoque regex que el serializador de Vitest. El script sale con 0 si solo cambió ruido, con 1 si difiere algún campo significativo.

Para coincidencia con comodines (cubrir /data/*/updatedAt en lugar de /data/\d+\/updatedAt), sustituye la lista de regex por un conversor de glob a regex o usa el formato de lista de ignorados que acepta la herramienta JSON Diff e intégralo en tu pipeline mediante su exportación.

Cuándo NO ignorar: campos sensibles a la seguridad, rastros de auditoría

La lista de ignorados es poderosa. También es peligrosa cuando se aplica sin disciplina. Algunos campos parecen ruido pero llevan información crítica para la seguridad.

No ignores:

  • createdAt en registros de auditoría o registros financieros. Si el timestamp de una transacción es incorrecto, eso es un bug — no ruido. Las tablas de auditoría existen precisamente para detectar manipulación mediante anomalías de timestamp.
  • Campos id en intents de pago, facturas o registros regulatorios. Un ID rotativo en un contexto de pago podría indicar cargos duplicados o eventos mal enrutados.
  • version o etag en sistemas de concurrencia optimista. Si dos procesos escriben ambos con la misma versión, uno fallará. El campo de versión no es decorativo.
  • traceId o spanId cuando depuras fallos en sistemas distribuidos. Son ruido en tests de snapshot pero señal en el análisis de incidentes en producción.
  • Cualquier campo que tu equipo de seguridad haya marcado para monitoreo — las reglas de SIEM, los requisitos de cumplimiento y las políticas de retención de datos pueden depender de que estos campos sean visibles en el diff.

El modelo correcto es tratar la lista de ignorados como una allowlist revisada en código: una constante nombrada o archivo de configuración, incluido en el repositorio, con un comentario que explique por qué cada ruta está en la lista. Los cambios en la lista de ignorados deben requerir la misma revisión que los cambios en la configuración de producción.

// Explícito, auditable, requiere revisión en PR para cambiar
export const SNAPSHOT_IGNORE_PATHS = [
  "/requestId",   // UUID por solicitud, sin contenido semántico
  "/traceId",     // ID de traza OpenTelemetry, rota por solicitud
  "/createdAt",   // reloj del servidor, estable en fixtures de producción
  // "/amount"    // NUNCA ignorar — campo financiero
] as const;

Si no estás seguro de si un campo debe estar en la lista de ignorados, pecar por exceso y dejarlo en el diff. Un falso positivo (ruido en el diff) cuesta unos segundos de revisión. Un falso negativo (una regresión no detectada porque la ruta fue ignorada) le cuesta a tus usuarios.

Resumen

Los diffs JSON crudos son ruidosos porque las respuestas del servidor embeben campos que rotan por diseño. La sintaxis JSON Pointer (RFC 6901) te da un esquema de direccionamiento preciso. Los patrones con comodín (/data/*/createdAt) extienden eso a los arrays sin escribir código de traversal personalizado. La coincidencia de clave por regex maneja estructuras dinámicas donde las rutas no se conocen de antemano. La salida JSON Patch (RFC 6902) con rutas ignoradas eliminadas produce una señal limpia para CI. Un serializador personalizado de Vitest aplica la misma lógica al snapshot testing. Y una lista de ignorados disciplinada y revisada en código asegura que la supresión de ruido no engulla accidentalmente una regresión real.

Pruébalo en la herramienta JSON Diff — pega dos respuestas JSON, introduce tus rutas de ignorado y ve solo las operaciones significativas. Usa el Formateador JSON para limpiar respuestas minificadas antes de hacer el diff.

Artículos relacionados

Ver todos los artículos