Skip to content
Назад к блогу
Руководства

Как игнорировать timestamp и ID при JSON diff (без jq)

Diff API-регрессий на 80% состоит из шума — timestamp, request ID, UUID, меняющиеся каждый запрос. Используйте Extended JSON Pointer-паттерны, чтобы видеть только значимые изменения.

12 мин чтения

Как игнорировать timestamp и ID при JSON diff (без jq)

Вы добавили новое поле в REST-эндпоинт. Snapshot-тесты падают — но не потому, что поле изменилось, а потому что createdAt, requestId и traceId поменялись между записанным снимком и пере-запуском. Десять минут уходят на подтверждение, что diff — это шум, после чего тест заглушают и идут дальше. И так у каждого участника команды, на каждой сборке, каждую неделю.

Проблема не в фреймворке тестов. Проблема в том, что сырой JSON diff обращается со всеми полями одинаково. Меняющийся UUID структурно неотличим от поля, несущего смысловое значение. Без способа сказать «игнорируй этот путь», поверхность diff включает все timestamp-ы, которые сервер вкладывает в ответ, все автоинкрементные ID, все per-request корреляционные токены — и ничего из этого не отражает, изменился ли контракт API.

Это руководство покрывает инструменты и паттерны, которые позволяют гасить шум у источника: синтаксис JSON Pointer, wildcard-паттерны для массивов, TypeScript-хелперы для сопоставления по ключу, интеграцию со snapshot-ами Vitest и небольшой CI-скрипт, который выходит с ненулевым кодом только когда в diff есть нечто реально важное.

Проблема «шумного diff» — почему CI падает на нерелевантных изменениях

Рассмотрим типичный workflow API-регрессий. Вы записываете ответ от 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"
}

Снимок сделан. Через две недели коллега добавляет поле shippingCarrier и запускает набор тестов. Diff показывает шесть изменённых строк — четыре из них это timestamp-ы и ID, обновлённые на стороне сервера. Единственное значимое изменение (новое поле) теряется в шуме. Коллега подтверждает, что ничего не сломано, обновляет snapshot, и review проходит без того, чтобы кто-то реально вчитался в функциональный diff.

Теперь умножьте это на сетку микросервисов с сотней эндпоинтов. На каждой сборке CI делает diff snapshot-файлов, в которых живут requestId, traceId, version, etag, x-request-start, serverTime. Ревьюверы привыкают к шуму и одобряют snapshot-diff не глядя. Регрессионный сигнал коллапсирует.

Решение не в отказе от snapshot-тестирования. Решение — отрезать шум до того, как diff будет посчитан.

Что считать шумом: timestamp, UUID, авто-ID, хеши

Не каждое меняющееся поле — это шум. Поле status, изменившееся с processing на shipped, — это сигнал. Вопрос в том, какие поля структурно неспособны нести регрессионную информацию, потому что меняются по дизайну.

КатегорияПримеры имён полейПочему меняется
TimestampcreatedAt, updatedAt, deletedAt, lastSeen, expiresAtЧасы сервера, время запроса
Корреляция запросовrequestId, traceId, spanId, correlationIdГенерируется на каждый запрос
Автогенерируемые IDid, uuid, nonce, idempotencyKeyUUID v4 / ULID / KSUID при каждом insert
ETag и кеш-токеныetag, eTag, cacheKey, version (когда автоинкремент)Хеш контента или последовательность
Метаданные сборкиbuildHash, deployId, serverVersion, hostnameЗадаётся при деплое
Курсоры пагинацииnextCursor, prevCursor, pageTokenКодированное состояние, непрозрачно

Поля из этой таблицы — кандидаты на ignore-список. Поля, кодирующие реальное доменное состояние — status, amount, userId, items — не должны там оказаться никогда.

Цель — ignore-список, который живёт в коде, проходит ревью в PR и аудируется при изменении требований безопасности. А не одноразовый jq-пайплайн, который кто-то написал локально и забыл закоммитить.

Праймер по синтаксису JSON Pointer (RFC 6901)

JSON Pointer, описанный в RFC 6901, — это схема адресации, используемая JSON Patch (RFC 6902) и большинством инструментов структурного diff-а. Выглядит как путь в файловой системе и адресует конкретное место в JSON-документе.

/               → корневой документ
/foo            → ключ объекта "foo"
/foo/bar        → вложенный ключ "bar" внутри "foo"
/items/0        → первый элемент массива "items"
/items/0/name   → ключ "name" внутри первого элемента

Две escape-последовательности обрабатывают спецсимволы:

  • ~0 означает литеральный ~
  • ~1 означает литеральный /

Так ключ a/b адресуется как /a~1b, а ключ a~b — как /a~0b.

JSON Pointer отличается от JSONPath (используется в jq, OpenAPI) в одном важном моменте: он адресует ровно одно место — никаких wildcard, никакого рекурсивного спуска, никаких filter-выражений. RFC 6901 строг и прост. Цена: чтобы игнорировать поле во всех элементах массива, нужен либо инструмент, расширяющий спецификацию синтаксисом wildcard, либо программный обход pointer-путей.

Когда вы видите поля path в операциях JSON Patch ([{"op":"replace","path":"/status","value":"shipped"}]), это и есть pointer-ы RFC 6901. Инструменты вроде fast-json-patch и rfc6902 используют эту нотацию нативно.

Wildcard-паттерны: /users/*/lastSeen для массивов

RFC 6901 не определяет wildcard. Но большинство практически полезных инструментов diff расширяют спецификацию однопозиционным wildcard: * сопоставляется с любым одним сегментом пути, не пересекая /.

Правила Extended JSON Pointer Pattern (как реализовано в Go Tools JSON Diff):

  • * сопоставляется с любым одиночным сегментом пути (индекс массива или ключ объекта)
  • * не пересекает / — это не рекурсивный glob
  • Чтобы сопоставить литеральную звёздочку в имени ключа, экранируйте её как \*
  • ** (двойная звезда, рекурсивный спуск) не поддерживается — используйте явные пути

Примеры:

/users/*/lastSeen        → сопоставляется с /users/0/lastSeen, /users/1/lastSeen и т. д.
/orders/*/updatedAt      → сопоставляется с updatedAt в каждом элементе массива orders
/*/requestId             → сопоставляется с requestId в каждом ключе верхнего уровня
/data/items/*/id         → сопоставляется с id в каждом элементе вложенного массива items

Ограничение в один уровень важно. /users/*/profile/*/tag валиден и сопоставляется ровно с двумя уровнями wildcard-спуска. Он не сопоставится ни с /users/0/tag (слишком мелко), ни с /users/0/profile/0/meta/tag (слишком глубоко).

Практичный ignore-список для типового REST-API:

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

Это покрывает типовые случаи, не задевая ни одного семантического поля.

Живой пример: игнорирование createdAt в payload Stripe webhook

Stripe webhook — конкретный пример. Каждый event-конверт содержит created (Unix timestamp), id (event ID), request.id и request.idempotency_key — всё это меняется на каждый вызов и бесполезно для регрессионного тестирования.

Сырой payload webhook:

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

Ignore-список для этой формы:

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

После применения этих ignore-ов поверхность diff содержит только type, data/object/amount, data/object/currency и data/object/status — поля, реально кодирующие, изменилось ли поведение платежа.

Вставьте оба payload-а (до и после) в JSON Diff, укажите ignore-пути, и в выводе JSON Patch останутся только значимые операции. Никакого jq-пайплайна. Никакого предобработочного скрипта.

Регулярки для неизвестных ключей

JSON Pointer-пути работают, когда вы заранее знаете структуру. Они проваливаются в двух сценариях: динамические ключи объектов (мапы с UUID-ключами, per-user блобы данных) и паттерны вида «любое поле, чьё имя оканчивается на Id».

Для них нужно сопоставление по регулярке имени ключа. Ниже TypeScript-хелпер, обходящий JSON-дерево и собирающий все pointer-пути, чьё имя ключа сопоставляется с шаблоном:

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

// Использование: собрать все пути, чей ключ оканчивается на "Id" или "At"
const noisePattern = /(?:Id|At|Stamp|Hash|Token|Cursor)$/;
const ignorePaths = findPathsByKeyPattern(responseJson, noisePattern);
// → ["/requestId", "/data/0/createdAt", "/data/0/updatedAt", ...]

Так получается динамический ignore-список, выводимый из реальной формы ответа, а не из вручную поддерживаемого набора строк. Оговорка: если легитимное поле оканчивается на Id (например, userId или orderId), нужно либо уточнять шаблон, либо вычитать такие пути из результата. Шаблоны имён не заменяют явные списки путей — они дополняют их для крупных API, где вручную поддерживать каждый путь непрактично.

Вывод JSON Patch с вырезанными игнорируемыми путями

JSON-diff, выраженный как JSON Patch (RFC 6902), — это список операций:

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

Фильтрация игнорируемых путей означает удаление операций, чей path сопоставляется с любой записью ignore-списка (с учётом wildcard). Отфильтрованный вывод:

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

Две важные оговорки:

  1. Предупреждение: round-trip ломается. Отфильтрованный patch нельзя применить, чтобы воспроизвести целевой документ. Если применить только не-шумные операции к источнику, получится документ без обновлений timestamp. Храните полный patch для реконструкции; отфильтрованный — только для diff и алертинга.

  2. Чувствительность к порядку. Операции JSON Patch упорядочены. Удаление операций из середины patch, содержащего move или copy с ссылками на более ранние позиции, может дать невалидный patch. Если нужно применить отфильтрованный patch, сначала примените полный, затем сделайте повторный diff результата с ожидаемым состоянием.

Инструмент JSON Diff выдаёт и полный patch, и отфильтрованный взгляд — можно посмотреть сырой diff, прежде чем решать, какие операции гасить.

Рецепты для snapshot-тестов (Jest / Vitest)

Snapshot-система Vitest сериализует значения в строки и сравнивает их при пере-запуске. Дефолтный сериализатор захватывает каждое поле, включая шумные. Решение — кастомный сериализатор, вырезающий игнорируемые пути перед снятием 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)),
});

Зарегистрируйте его в vitest.config.ts:

import { defineConfig } from "vitest/config";

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

Теперь snapshot-ы выглядят так:

it("returns order with shipping carrier", async () => {
  const result = await getOrder("ord_42");
  expect(result).toMatchSnapshot(); // createdAt, requestId и пр. вырезаны
});

Файл snapshot содержит только стабильные поля. Перезапуск набора после изменения timestamp даёт нулевой diff. Когда добавляется поле shippingCarrier, diff snapshot показывает ровно это одно изменение. Для ручной верификации diff между двумя записанными ответами перед обновлением snapshot можно также пользоваться JSON Diff.

Интеграция с CI: падение сборки только на значимых diff

Конечная цель — шаг CI, который скачивает эталонный ответ, обращается к живому эндпоинту, делает diff между ними, обрезает шум и выходит с ненулевым кодом, только если значимые операции остались.

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

Шаг GitHub Actions:

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

Функция compare из fast-json-patch возвращает операции RFC 6902. Фильтр работает по строкам path тем же regex-подходом, что и сериализатор Vitest. Скрипт выходит с 0, если изменился только шум, и с 1, если отличается хоть одно значимое поле.

Для wildcard-сопоставления (покрытия /data/*/updatedAt вместо /data/\d+\/updatedAt) замените список регулярок конвертером glob → regex или используйте формат ignore-списка, принимаемый JSON Diff, и интегрируйте через его экспорт в свой пайплайн.

Когда НЕ стоит игнорировать: критичные для безопасности поля и audit trail

Ignore-список мощный инструмент. И опасный без дисциплины. Некоторые поля выглядят как шум, но несут критичную для безопасности информацию.

Не игнорируйте:

  • createdAt в аудит-логах или финансовых записях. Если timestamp транзакции неверен — это баг, а не шум. Аудит-таблицы и существуют именно для того, чтобы детектировать подмены через аномалии timestamp-ов.
  • Поля id в payment intent, инвойсах или регуляторных записях. Меняющийся ID в платёжном контексте может означать дубль списания или ошибку маршрутизации event-а.
  • version или etag в системах оптимистичной конкурентности. Если два процесса пишут с одинаковой version, один обязан упасть. Поле version — не декорация.
  • traceId или spanId при отладке отказов в распределённых системах. В snapshot-тестах это шум, в production-инцидентах — сигнал.
  • Любое поле, которое ваша security-команда пометила для мониторинга — SIEM-правила, требования compliance и политики хранения данных могут зависеть от того, что эти поля видны в diff.

Правильная модель — относиться к ignore-списку как к ревьюируемому allowlist: именованная константа или конфиг-файл в репозитории, с комментарием, объясняющим, почему каждый путь там присутствует. Изменения ignore-списка должны проходить такое же ревью, как изменения production-конфигурации.

// Явно, аудируемо, изменения требуют PR-ревью
export const SNAPSHOT_IGNORE_PATHS = [
  "/requestId",   // per-request UUID, без семантики
  "/traceId",     // OpenTelemetry trace ID, меняется при каждом запросе
  "/createdAt",   // часы сервера, стабильны в production-фикстурах
  // "/amount"    // НИКОГДА не игнорировать — финансовое поле
] as const;

Если есть сомнение, должно ли поле быть в ignore-списке, лучше оставьте его в diff. Ложноположительное срабатывание (шум в diff) стоит нескольких секунд ревью. Ложноотрицательное (пропущенная регрессия из-за того, что путь был проигнорирован) стоит вашим пользователям.

Резюме

Сырые JSON-diff шумные, потому что серверные ответы вкладывают поля, меняющиеся по дизайну. Синтаксис JSON Pointer (RFC 6901) даёт точную схему адресации. Wildcard-паттерны (/data/*/createdAt) расширяют её на массивы, не требуя кастомного обхода. Сопоставление ключей по регулярке покрывает динамические структуры, где пути неизвестны заранее. Вывод JSON Patch (RFC 6902) с вырезанными игнорируемыми путями даёт чистый сигнал для CI. Кастомный сериализатор Vitest применяет ту же логику к snapshot-тестам. И дисциплинированный, ревьюируемый ignore-список гарантирует, что подавление шума не съест случайно реальную регрессию.

Попробуйте на практике в JSON Diff — вставьте два JSON-ответа, укажите ignore-пути и увидите только значимые операции. Используйте JSON Formatter, чтобы причесать минифицированные ответы перед diff-ом.

Похожие статьи

Все статьи