Логирование кеша

Логирование кеша в библиотеке TanStack Query представляет собой процесс отслеживания всех операций, связанных с хранением, обновлением, инвалидированием и удалением данных из клиентского кеша. В сложных приложениях кеш становится полноценным слоем управления состоянием, поэтому без наблюдаемости невозможно эффективно диагностировать проблемы производительности, гонки запросов, лишние перерендеры и ошибки синхронизации данных.

Логирование позволяет:

  • отслеживать жизненный цикл query и mutation;
  • анализировать причины повторных запросов;
  • выявлять избыточные refetch-операции;
  • понимать, почему данные считаются stale;
  • диагностировать утечки памяти;
  • контролировать размер кеша;
  • отслеживать последовательность обновлений;
  • анализировать сетевую активность;
  • интегрировать клиентские события с системами мониторинга.

Архитектура кеша TanStack Query

Основой кеширования является объект QueryClient, внутри которого располагаются:

  • QueryCache
  • MutationCache

Каждый query хранит:

  • ключ;
  • состояние;
  • таймстемпы;
  • ошибки;
  • статус загрузки;
  • подписчиков;
  • данные;
  • информацию о retry.

Упрощённая схема:

const queryClient = new QueryClient({
    queryCache: new QueryCache(),
    mutationCache: new MutationCache()
});

Логирование обычно строится вокруг:

  • событий QueryCache;
  • событий MutationCache;
  • глобального logger;
  • middleware-интерцепторов;
  • devtools;
  • пользовательских подписок.

Встроенный logger

TanStack Query поддерживает кастомный logger.

Пример настройки:

import { QueryClient } from '@tanstack/react-query';

const queryClient = new QueryClient({
    logger: {
        log: (...args) => {
            console.log('[LOG]', ...args);
        },

        warn: (...args) => {
            console.warn('[WARN]', ...args);
        },

        error: (...args) => {
            console.error('[ERROR]', ...args);
        }
    }
});

Logger применяется библиотекой для:

  • внутренних предупреждений;
  • ошибок retry;
  • ошибок query functions;
  • сообщений devtools.

Подписка на QueryCache

Главный механизм логирования кеша — подписка на изменения QueryCache.

import {
    QueryClient,
    QueryCache
} from '@tanstack/react-query';

const queryCache = new QueryCache();

queryCache.subscribe((event) => {
    console.log(event);
});

const queryClient = new QueryClient({
    queryCache
});

Событие содержит:

{
    type,
    query
}

Типы событий:

  • added
  • removed
  • updated
  • observerAdded
  • observerRemoved
  • observerResultsUpdated
  • observerOptionsUpdated

Логирование создания query

queryCache.subscribe((event) => {
    if (event.type === 'added') {
        console.log('Query создан');

        console.log({
            queryKey: event.query.queryKey,
            state: event.query.state
        });
    }
});

Результат:

{
    queryKey: ['posts'],
    state: {
        status: 'pending',
        fetchStatus: 'fetching'
    }
}

Такое логирование помогает анализировать:

  • частоту создания query;
  • дублирование ключей;
  • динамические query key;
  • утечки подписчиков.

Логирование удаления query

Удаление query особенно важно при диагностике очистки памяти.

queryCache.subscribe((event) => {
    if (event.type === 'removed') {
        console.log('Query удалён');

        console.log(event.query.queryKey);
    }
});

Это позволяет:

  • контролировать garbage collection;
  • анализировать cacheTime;
  • выявлять query, которые никогда не удаляются;
  • диагностировать утечки кеша.

Логирование обновления состояния query

Самый полезный тип событий — updated.

queryCache.subscribe((event) => {
    if (event.type === 'updated') {
        const query = event.query;

        console.log({
            queryKey: query.queryKey,
            status: query.state.status,
            fetchStatus: query.state.fetchStatus,
            dataUpdatedAt: query.state.dataUpdatedAt,
            errorUpdatedAt: query.state.errorUpdatedAt
        });
    }
});

Это позволяет видеть:

  • успешные запросы;
  • ошибки;
  • refetch;
  • background updates;
  • stale transitions.

Анализ статусов query

Статус выполнения

query.state.status

Возможные значения:

  • pending
  • success
  • error

Сетевой статус

query.state.fetchStatus

Возможные значения:

  • idle
  • fetching
  • paused

Логирование stale-состояния

Определение stale-состояния критично для анализа повторных запросов.

queryCache.subscribe((event) => {
    if (event.type !== 'updated') {
        return;
    }

    const query = event.query;

    console.log({
        key: query.queryKey,
        isStale: query.isStale()
    });
});

Проблемы stale-логики:

  • слишком маленький staleTime;
  • постоянные refetch;
  • лишние запросы при focus;
  • агрессивная синхронизация.

Логирование инвалидирования кеша

Инвалидирование часто становится источником неожиданных refetch-операций.

const invalidate = async () => {
    console.log('Инвалидирование posts');

    await queryClient.invalidateQueries({
        queryKey: ['posts']
    });
};

Для более детального контроля:

queryCache.subscribe((event) => {
    if (event.type !== 'updated') {
        return;
    }

    const query = event.query;

    if (query.state.isInvalidated) {
        console.log('Query инвалидирован');

        console.log(query.queryKey);
    }
});

Логирование данных кеша

Иногда требуется анализировать содержимое кеша.

const query = queryClient.getQueryCache().find({
    queryKey: ['posts']
});

console.log(query.state.data);

Для сериализации:

console.log(
    JSON.stringify(query.state.data, null, 2)
);

Снимки всего кеша

Полезно при диагностике крупных приложений.

const queries = queryClient
    .getQueryCache()
    .getAll();

console.log(
    queries.map((query) => ({
        key: query.queryKey,
        status: query.state.status,
        observers: query.getObserversCount()
    }))
);

Такой снимок помогает:

  • анализировать активность кеша;
  • находить неиспользуемые query;
  • отслеживать рост памяти.

Логирование времени жизни кеша

TanStack Query хранит временные метки.

const query = queryClient
    .getQueryCache()
    .find({
        queryKey: ['posts']
    });

console.log({
    dataUpdatedAt: query.state.dataUpdatedAt,
    errorUpdatedAt: query.state.errorUpdatedAt
});

На основе этих данных можно:

  • вычислять возраст кеша;
  • анализировать устаревание;
  • строить метрики freshness.

Логирование observer-подписок

Каждый компонент создаёт observer.

queryCache.subscribe((event) => {
    if (event.type === 'observerAdded') {
        console.log('Подписчик добавлен');

        console.log(event.query.queryKey);
    }

    if (event.type === 'observerRemoved') {
        console.log('Подписчик удалён');

        console.log(event.query.queryKey);
    }
});

Это помогает выявлять:

  • лишние подписки;
  • дублирование useQuery;
  • проблемы размонтирования компонентов.

Логирование количества observers

const query = queryClient
    .getQueryCache()
    .find({
        queryKey: ['posts']
    });

console.log(
    query.getObserversCount()
);

Большое число observers может означать:

  • чрезмерное использование query;
  • неоптимальную архитектуру;
  • повторные рендеры.

Глобальное логирование fetch-запросов

Часто логирование кеша комбинируется с логированием сети.

const fetchPosts = async () => {
    console.log('HTTP запрос /posts');

    const response = await fetch('/api/posts');

    return response.json();
};

Комбинация сетевых и кеш-событий позволяет видеть:

  • cache hit;
  • cache miss;
  • refetch;
  • retry;
  • deduplication.

Логирование retry

useQuery({
    queryKey: ['posts'],
    queryFn: fetchPosts,
    retry: 3,
    retryDelay: 1000
});

Через logger:

const queryClient = new QueryClient({
    logger: {
        error: (...args) => {
            console.error('Retry ошибка', args);
        },

        log: console.log,
        warn: console.warn
    }
});

Логирование ошибок query

queryCache.subscribe((event) => {
    if (event.type !== 'updated') {
        return;
    }

    const query = event.query;

    if (query.state.status === 'error') {
        console.error({
            key: query.queryKey,
            error: query.state.error
        });
    }
});

Можно дополнительно логировать:

  • stack trace;
  • HTTP status;
  • response body;
  • retry count.

Логирование mutation-кеша

Для mutation используется MutationCache.

import {
    MutationCache
} from '@tanstack/react-query';

const mutationCache = new MutationCache({
    onError(error) {
        console.error(error);
    },

    onSuccess(data) {
        console.log(data);
    }
});

Подключение:

const queryClient = new QueryClient({
    mutationCache
});

Подписка на MutationCache

mutationCache.subscribe((event) => {
    console.log(event);
});

События mutation:

  • added
  • removed
  • updated
  • observerAdded
  • observerRemoved

Логирование optimistic updates

Optimistic update часто становится источником сложных ошибок.

useMutation({
    mutationFn: updatePost,

    onMutate: async (newPost) => {
        console.log('Optimistic update');

        const previous =
            queryClient.getQueryData(['posts']);

        queryClient.setQueryData(
            ['posts'],
            (old) => [...old, newPost]
        );

        return { previous };
    },

    onError: (error, variables, context) => {
        console.log('Rollback');

        queryClient.setQueryData(
            ['posts'],
            context.previous
        );
    }
});

Логирование изменений данных

Для глубокого анализа можно сравнивать старые и новые данные.

queryCache.subscribe((event) => {
    if (event.type !== 'updated') {
        return;
    }

    const query = event.query;

    console.log({
        oldData: query.state.dataUpdateCount - 1,
        updates: query.state.dataUpdateCount
    });
});

Создание собственного middleware логирования

const logQuery = (queryFn) => {
    return async (...args) => {
        const start = performance.now();

        try {
            const result = await queryFn(...args);

            console.log({
                duration: performance.now() - start,
                success: true
            });

            return result;
        } catch (error) {
            console.error({
                duration: performance.now() - start,
                success: false,
                error
            });

            throw error;
        }
    };
};

Использование:

useQuery({
    queryKey: ['posts'],
    queryFn: logQuery(fetchPosts)
});

Логирование времени выполнения запросов

const timedFetch = async () => {
    const started = Date.now();

    const response = await fetch('/api/posts');

    const data = await response.json();

    console.log({
        duration: Date.now() - started
    });

    return data;
};

Такие метрики помогают:

  • искать медленные API;
  • анализировать latency;
  • контролировать производительность.

Интеграция с Sentry

Популярный сценарий — отправка ошибок в Sentry.

import * as Sentry from '@sentry/react';

const queryClient = new QueryClient({
    logger: {
        log: console.log,

        warn: console.warn,

        error: (error) => {
            Sentry.captureException(error);
        }
    }
});

Интеграция с Datadog

Пример отправки логов:

const sendMetric = (payload) => {
    fetch('/metrics', {
        method: 'POST',
        body: JSON.stringify(payload)
    });
};

queryCache.subscribe((event) => {
    sendMetric({
        type: event.type,
        timestamp: Date.now()
    });
});

Devtools как средство логирования

Официальные devtools позволяют анализировать:

  • query state;
  • stale status;
  • observers;
  • cache contents;
  • refetch;
  • mutation state.

Подключение:

import {
    ReactQueryDevtools
} from '@tanstack/react-query-devtools';

<ReactQueryDevtools initialIsOpen={false} />

Логирование очистки кеша

queryClient.clear();

console.log('Кеш очищен');

Для частичной очистки:

queryClient.removeQueries({
    queryKey: ['posts']
});

Логирование hydration и dehydration

При SSR важно отслеживать перенос кеша.

import {
    dehydrate
} from '@tanstack/react-query';

const dehydratedState =
    dehydrate(queryClient);

console.log(dehydratedState);

Hydration:

import {
    Hydrate
} from '@tanstack/react-query';

<Hydrate state={pageProps.dehydratedState}>
    <App />
</Hydrate>

Логирование persist-кеша

При использовании persistent cache:

persistQueryClient({
    queryClient,
    persister
});

Можно логировать:

  • размер сохранённого кеша;
  • время восстановления;
  • частоту синхронизации;
  • ошибки сериализации.

Анализ утечек памяти

Признаки проблем:

  • query никогда не удаляются;
  • observers остаются после unmount;
  • размер кеша постоянно растёт;
  • cacheTime слишком большой.

Диагностика:

setInterval(() => {
    const queries =
        queryClient
            .getQueryCache()
            .getAll();

    console.log({
        count: queries.length
    });
}, 5000);

Производительность логирования

Чрезмерное логирование может:

  • замедлять приложение;
  • создавать лишние рендеры;
  • перегружать DevTools;
  • увеличивать расход памяти.

Плохой пример:

queryCache.subscribe((event) => {
    console.log(event);
});

Лучше:

queryCache.subscribe((event) => {
    if (event.type !== 'updated') {
        return;
    }

    if (event.query.queryKey[0] !== 'posts') {
        return;
    }

    console.log(event.query.state.status);
});

Фильтрация логов

const ignoredQueries = [
    'notifications',
    'metrics'
];

queryCache.subscribe((event) => {
    const key = event.query.queryKey[0];

    if (ignoredQueries.includes(key)) {
        return;
    }

    console.log(event);
});

Структурированное логирование

Хорошей практикой считается JSON-формат.

console.log(
    JSON.stringify({
        timestamp: Date.now(),
        type: event.type,
        queryKey: event.query.queryKey,
        status: event.query.state.status
    })
);

Преимущества:

  • совместимость с ELK;
  • интеграция с Datadog;
  • поиск по логам;
  • агрегация событий.

Централизованный сервис логирования

class QueryLogger {
    log(event) {
        console.log({
            type: event.type,
            key: event.query.queryKey
        });
    }

    error(error) {
        console.error(error);
    }
}

const logger = new QueryLogger();

queryCache.subscribe((event) => {
    logger.log(event);
});

Такой подход упрощает:

  • масштабирование;
  • интеграцию мониторинга;
  • тестирование;
  • переиспользование логики.