Мой опыт установки Sentry self-hosted

в 5:40, , рубрики: Sentry, системное администрирование

Привет! Меня зовут Даниил Ткаченко, я веб‑разработчик в ИТ‑компании «Активика». В статье я поделюсь опытом развёртывания Sentry self‑hosted для высоконагруженного проекта. Несмотря на обилие материалов по SaaS‑версии, актуальных гайдов по self‑hosted‑установке почти нет — особенно с учётом современных требований к производительности и отказоустойчивости.

Мы столкнулись с рядом проблем: нестабильностью на базовом хостинге, отсутствием перехвата HTTP‑ошибок и быстрым заполнением диска. Под катом разберу каждую проблему, покажу код решений и дам рекомендации для тех, кто планирует развернуть Sentry самостоятельно.

Статья будет полезна разработчикам и DevOps‑инженерам без опыта работы с self‑hosted Sentry.

Проблема 1. Нестабильность на базовом хостинге

Всё началось с того, что на нашем базовом хостинге возникли ошибки: Sentry стабильно не запускался из‑за особенностей виртуализации. Нельзя было даже развернуть инструмент. Поэтому мы переехали на другой хостинг — и тогда всё получилось.

Но тут меня тоже поджидали проблемы: память быстро забивалась, и не просто логами в том же ClickHouse или файлами реплеев, а логами в Kafka.

За время тестовых подключений к демоверсии проекта (а ей пользуется ограниченное число пользователей для тестирования новых функций) Sentry падал два раза, а демо-версия проекта грузилась очень долго. 

Когда Sentry падал, сайт грузился очень долго — около 30–40 секунд. Это происходило потому, что клиент пытался не только загрузить JS‑скрипты с внешних CDN, но и отправить события на сервер Sentry. Запросы зависали на таймаутах, блокируя загрузку страницы.

Конфигурация сервера, которую использовали: 32 ГБ RAM, 4 ядра CPU, 40 ГБ SSD. Установили до ажиотажа с ценами на память — потом пришлось уменьшить RAM до 16-24 ГБ, увеличить SSD до 100ГБ и добавить swap-файл, чтобы не переплачивать. 

Лайфхаки для ускорения загрузки сайта, когда сентри не доступен

Даже при self‑hosted решении возможны сбои: DDoS‑атаки, перезагрузка кластера, ошибки ПО. Делюсь проверенными мной способами минимизировать их влияние, чтобы избежать длительной загрузки и работы сайта, даже при полном отказе Sentry.

Хак 1. Локальная урезанная версия JS‑библиотеки

Я скачал JS-библиотеку Sentry и хранил её локально в урезанной версии. Теперь никаких внешних запросов — загрузка мгновенная и без зависаний.

Хак 2.  Таймауты на соединения

Добавляем код с таймаутами на соединения поверх SDK, который прерывает запросы к Sentry, если они длятся дольше 300 миллисекунд.

Это предотвращает переполнение очереди запросов в браузере и сохраняет отзывчивость сайта.

     const SENTRY_TIMEOUT_MS = 300;

        function makeCustomTimeoutTransport(options) {
            // The 'makeRequest' function is the core where you control the fetch call.
            function makeRequest(request) {
                const controller = new AbortController();
                const timeoutId = setTimeout(() => controller.abort(), SENTRY_TIMEOUT_MS); // 3-second timeout

                const requestOptions = {
                    body: request.body,
                    method: 'POST',
                    referrerPolicy: 'origin',
                    headers: options.headers,
                    signal: controller.signal, // <-- Connect the AbortController
                    ...options.fetchOptions,
                };

                // Return the fetch promise. It will be rejected if it times out.
                return fetch(options.url, requestOptions)
                    .then(response => {
                        clearTimeout(timeoutId); // Clear timer on success
                        return {
                            statusCode: response.status,
                            headers: {
                                'x-sentry-rate-limits': response.headers.get('X-Sentry-Rate-Limits'),
                                'retry-after': response.headers.get('Retry-After'),
                            },
                        };
                    })
                    .catch(error => {
                        clearTimeout(timeoutId);
                        if (error.name === 'AbortError') {
                            console.warn('Sentry event dropped due to send timeout.');
                            error.message = 'Sentry event dropped due to send timeout.';
                        }
                        throw error; // Re-throw to let the SDK handle the failure
                    });
            }

            // Use the official helper to build a compliant transport.
            return Sentry.createTransport(options, makeRequest);
        }

        Sentry.init({
            transport: makeCustomTimeoutTransport,
…
        });

Эти изменения мы протестировали на проде. 

Результат: после внедрения перестало забиваться лог бесконечными попытками достучаться до Sentry.

Проблема 2. Отсутствие перехвата HTTP‑ошибок

Оказалось, что Sentry из коробки не ловит HTTP-ошибки на асинхронных запросах — картинки 404, сломанные API-вызовы или страницы проходят мимо. Я написал небольшое расширение, примерно на 100 строк JS, которое:

  • перехватывает запросы через fetch и XMLHttpRequest

  • отслеживает ошибки (коды 4xx, 5xx)

  • отправляет информацию в Sentry с деталями запроса и ответа

Ключевые возможности расширения:

  • фильтрация по URL и кодам статусов

  • ограничение размера данных (MAX_CAPTURE_LENGTH = 1000 байт)

  • санитизация небезопасных заголовков (cookie, authorization и т. д.)

  • поддержка PII (персональных данных) при необходимости

// sentry-http-integration.js

/**
 * Расширенная интеграция для перехвата HTTP-запросов и отправки информации об ошибках в Sentry.
 * Основана на официальной реализации httpClientIntegration из Sentry, но с дополнительными возможностями
 * для более детального анализа запросов и ответов.
 *
 * @see Официальная документация Sentry: https://docs.sentry.io/platforms/javascript/
 */
class SentryHttpDataIntegration {
    /**
     * Уникальный идентификатор интеграции.
     * @type {string}
     */
    static id = 'SentryHttpDataIntegration';

    /**
     * Имя интеграции.
     * @type {string}
     */
    name = 'SentryHttpDataIntegration';

    /**
     * Максимальная длина захватываемых данных в байтах.
     * Используется для предотвращения отправки слишком больших данных в Sentry.
     * @type {number}
     */
    static MAX_CAPTURE_LENGTH = 1000;

    /**
     * Список "небезопасных" заголовков, доступ к которым ограничен в браузерах.
     * @type {Array<string>}
     * @private
     */
    static _RESTRICTED_HEADERS = [
        'set-cookie',
        'set-cookie2',
        'cookie2',
        'cookie',
        'authorization',
        'proxy-authorization',
        'sec-',
        'proxy-'
    ];

    /**
     * Настройки для фильтрации HTTP-запросов, по которым будут отправляться события.
     * @type {Object}
     * @property {Array<Array<number>|number>} failedRequestStatusCodes - Коды HTTP-статусов, которые считаются ошибками
     * @property {Array<string|RegExp>} failedRequestTargets - URL-шаблоны для отслеживания ошибок
     * @private
     */
    _options = {
        failedRequestStatusCodes: [[500, 599]],
        failedRequestTargets: [/.*/],
    };

    /**
     * Конструктор класса.
     * @param {Object} options - Настройки интеграции
     * @param {Array<Array<number>|number>} [options.failedRequestStatusCodes] - Коды HTTP-статусов для отслеживания
     * @param {Array<string|RegExp>} [options.failedRequestTargets] - URL-шаблоны для отслеживания
     */
    constructor(options = {}) {
        this._options = {
            ...this._options,
            ...options,
        };
    }

    /**
     * Устанавливает перехватчики для fetch и XMLHttpRequest.
     * Этот метод вызывается Sentry при инициализации интеграции.
     */
    setupOnce() {
        this._wrapFetch();
        this._wrapXHR();
    }

    /**
     * Проверяет, должен ли запрос быть обработан на основе его URL и статуса ответа.
     * @param {number} status - Код HTTP-статуса
     * @param {string} url - URL запроса
     * @returns {boolean} - true, если запрос соответствует критериям для отправки в Sentry
     * @private
     */
    _shouldCaptureResponse(status, url) {
        // Не обрабатываем запросы к самому Sentry
        if (this._isSentryRequest(url)) {
            return false;
        }

        return (
            this._isInStatusCodeRange(status) &&
            this._isUrlInTargets(url)
        );
    }

    /**
     * Проверяет, соответствует ли код статуса заданным диапазонам ошибок.
     * @param {number} status - Код HTTP-статуса
     * @returns {boolean} - true, если статус входит в диапазоны ошибок
     * @private
     */
    _isInStatusCodeRange(status) {
        return this._options.failedRequestStatusCodes.some(range => {
            if (typeof range === 'number') {
                return status === range;
            }
            return status >= range[0] && status <= range[1];
        });
    }

    /**
     * Проверяет, соответствует ли URL заданным шаблонам.
     * @param {string} url - URL запроса
     * @returns {boolean} - true, если URL соответствует хотя бы одному шаблону
     * @private
     */
    _isUrlInTargets(url) {
        return this._options.failedRequestTargets.some(target => {
            if (typeof target === 'string') {
                return url.includes(target);
            }
            return target.test(url);
        });
    }

    /**
     * Проверяет, является ли запрос запросом к Sentry.
     * @param {string} url - URL запроса
     * @returns {boolean} - true, если запрос направлен к Sentry
     * @private
     */
    _isSentryRequest(url) {
        // Простая проверка на запросы к Sentry
        // В реальной реализации может использоваться isSentryRequestUrl из @sentry/core
        return url.includes('sentry.io') || url.includes('ingest.sentry.io');
    }

    /**
     * Перехватывает нативный fetch API для отслеживания запросов.
     * Заменяет глобальную функцию fetch на свою обертку, которая:
     * - сохраняет информацию о запросе
     * - выполняет оригинальный запрос
     * - перехватывает ошибки и ответы с кодом ошибки (4xx, 5xx)
     * - отправляет информацию в Sentry в случае ошибки
     * @private
     */
    _wrapFetch() {
        if (typeof window.fetch !== 'function') {
            console.warn('Fetch API не поддерживается в этом окружении');
            return;
        }

        const originalFetch = window.fetch;
        const self = this;

        window.fetch = async function (input, init) {
            // Получаем метод запроса (GET по умолчанию)
            const method = init?.method ?? 'GET';
            // Создаем объект Request для унификации работы с разными форматами параметров
            const request = self._getRequest(input, init);
            // Захватываем данные запроса, если они есть
            const requestData = init?.body
                ? await self._captureRequestBody(init.body)
                : null;

            // Извлекаем заголовки запроса, если sendDefaultPii активен
            let requestHeaders;
            if (self._shouldSendDefaultPii()) {
                try {
                    requestHeaders = self._extractFetchHeaders(request.headers);
                    // Удаляем небезопасные заголовки
                    self._sanitizeHeaders(requestHeaders);
                } catch (e) {
                    // Игнорируем ошибки при извлечении заголовков
                }
            }

            try {
                // Выполняем оригинальный запрос
                const response = await originalFetch.apply(this, arguments);

                // Если ответ соответствует критериям ошибки
                if (self._shouldCaptureResponse(response.status, response.url)) {
                    // Извлекаем заголовки ответа, если активен sendDefaultPii
                    let responseHeaders;
                    if (self._shouldSendDefaultPii()) {
                        try {
                            responseHeaders = self._extractFetchHeaders(response.headers);
                            // Удаляем небезопасные заголовки
                            self._sanitizeHeaders(responseHeaders);
                        } catch (e) {
                            // Игнорируем ошибки при извлечении заголовков
                        }
                    }

                    // Захватываем тело ответа
                    const responseData = await self._captureResponseBody(response.clone());

                    // Отправляем информацию об ошибке в Sentry
                    self._captureError({
                        method,
                        url: response.url,
                        requestData,
                        requestHeaders,
                        responseData,
                        responseHeaders,
                        status: response.status
                    });
                }
                return response;
            } catch (error) {
                // В случае сетевой ошибки отправляем информацию в Sentry
                self._captureError({
                    method,
                    url: request.url,
                    requestData,
                    requestHeaders,
                    error: error.message,
                    status: ''
                });
                // Передаем ошибку дальше
                throw error;
            }
        };
    }

    /**
     * Создает объект Request из параметров fetch.
     * @param {RequestInfo} input - URL или объект Request
     * @param {RequestInit} [init] - Опции запроса
     * @returns {Request} - Объект Request
     * @private
     */
    _getRequest(input, init) {
        if (!init && input instanceof Request) {
            return input;
        }

        // Если тело оригинального Request уже использовано, просто возвращаем его
        if (input instanceof Request && input.bodyUsed) {
            return input;
        }

        return new Request(input, init);
    }

    /**
     * Перехватывает XMLHttpRequest для отслеживания запросов.
     * Модифицирует прототип XMLHttpRequest, заменяя методы open и send
     * для сбора информации о запросах и перехвата ошибок.
     * @private
     */
    _wrapXHR() {
        if (typeof XMLHttpRequest === 'undefined') {
            console.warn('XMLHttpRequest не поддерживается в этом окружении');
            return;
        }

        const originalSend = XMLHttpRequest.prototype.send;
        const originalOpen = XMLHttpRequest.prototype.open;
        const self = this;

        // Сохраняем метод и URL запроса через перехват open
        XMLHttpRequest.prototype.open = function (method, url) {
            this._method = method;
            this._url = url;
            this._requestHeaders = {};
            return originalOpen.apply(this, arguments);
        };

        // Перехватываем setRequestHeader для сохранения заголовков
        const originalSetRequestHeader = XMLHttpRequest.prototype.setRequestHeader;
        XMLHttpRequest.prototype.setRequestHeader = function (name, value) {
            if (!this._requestHeaders) {
                this._requestHeaders = {};
            }
            this._requestHeaders[name] = value;
            return originalSetRequestHeader.apply(this, arguments);
        };

        // Перехватываем метод send для отслеживания запросов
        XMLHttpRequest.prototype.send = function (body) {
            // Захватываем данные запроса
            const requestData = self._captureSyncData(body);

            // Санитизируем заголовки запроса
            let requestHeaders;
            if (self._shouldSendDefaultPii() && this._requestHeaders) {
                requestHeaders = {...this._requestHeaders};
                self._sanitizeHeaders(requestHeaders);
            }

            // Добавляем обработчик события завершения запроса
            this.addEventListener('loadend', () => {
                // Если статус ответа указывает на ошибку и URL соответствует критериям
                if (self._shouldCaptureResponse(this.status, this.responseURL ?? this._url)) {
                    // Безопасно получаем заголовки ответа
                    let responseHeaders;
                    if (self._shouldSendDefaultPii()) {
                        try {
                            // Используем безопасный метод получения заголовков ответа
                            responseHeaders = self._getXHRResponseHeaders(this);
                            // Дополнительно санитизируем заголовки
                            self._sanitizeHeaders(responseHeaders);
                        } catch (e) {
                            // Игнорируем ошибки при извлечении заголовков
                        }
                    }

                    // Захватываем тело ответа
                    const responseData = self._captureSyncXHRResponseData(this);

                    // Отправляем информацию об ошибке в Sentry
                    self._captureError({
                        method: this._method ?? 'GET',
                        url: this.responseURL ?? this._url,
                        requestData,
                        requestHeaders,
                        responseData,
                        responseHeaders,
                        status: this.status
                    });
                }
            });

            // Вызываем оригинальный метод send
            return originalSend.call(this, body);
        };
    }

    /**
     * Удаляет небезопасные заголовки из объекта заголовков.
     * @param {Object} headers - Объект с заголовками
     * @private
     */
    _sanitizeHeaders(headers) {
        if (!headers) return;

        // Удаляем небезопасные заголовки
        Object.keys(headers).forEach(key => {
            const lowerKey = key.toLowerCase();
            if (SentryHttpDataIntegration._RESTRICTED_HEADERS.some(
                restrictedHeader => lowerKey === restrictedHeader || lowerKey.startsWith(restrictedHeader)
            )) {
                delete headers[key];
            }
        });
    }

    /**
     * Безопасно захватывает тело ответа XHR.
     * @param {XMLHttpRequest} xhr - Объект XMLHttpRequest
     * @returns {string|Object|null} - Захваченные данные ответа
     * @private
     */
    _captureSyncXHRResponseData(xhr) {
        try {
            // Безопасно получаем тело ответа, обрабатывая разные типы ответов
            let responseData = xhr.response;

            // Проверяем тип ответа и при необходимости конвертируем
            if (responseData) {
                if (typeof responseData === 'object') {
                    try {
                        // Для объектов попробуем преобразовать их в строку JSON
                        responseData = JSON.stringify(responseData).slice(0, SentryHttpDataIntegration.MAX_CAPTURE_LENGTH);
                    } catch (e) {
                        // Если не удалось преобразовать, вернем тип объекта
                        responseData = `[${Object.prototype.toString.call(responseData)}]`;
                    }
                } else if (typeof responseData === 'string') {
                    // Ограничиваем длину строки
                    responseData = responseData.slice(0, SentryHttpDataIntegration.MAX_CAPTURE_LENGTH);
                } else {
                    // Для других типов данных преобразуем их в строку
                    responseData = String(responseData).slice(0, SentryHttpDataIntegration.MAX_CAPTURE_LENGTH);
                }
            }

            return responseData;
        } catch (e) {
            return '[unable to capture response data]';
        }
    }

    /**
     * Проверяет, нужно ли отправлять персональные данные (PII).
     * @returns {boolean} - true, если нужно отправлять PII
     * @private
     */
    _shouldSendDefaultPii() {
        // В реальной реализации это зависит от настроек Sentry
        // Здесь просто возвращаем true для демонстрации
        return true;
    }

    /**
     * Извлекает заголовки из объекта Headers.
     * @param {Headers} headers - Объект Headers
     * @returns {Object} - Объект с заголовками
     * @private
     */
    _extractFetchHeaders(headers) {
        const result = {};

        if (headers instanceof Headers) {
            headers.forEach((value, key) => {
                // Проверяем, не является ли заголовок небезопасным
                if (!SentryHttpDataIntegration._RESTRICTED_HEADERS.some(
                    restrictedHeader => key.toLowerCase() === restrictedHeader ||
                        key.toLowerCase().startsWith(restrictedHeader)
                )) {
                    result[key] = value;
                }
            });
        }

        return result;
    }

    /**
     * Извлекает заголовки ответа из XMLHttpRequest.
     * @param {XMLHttpRequest} xhr - Объект XMLHttpRequest
     * @returns {Object} - Объект с заголовками
     * @private
     */
    _getXHRResponseHeaders(xhr) {
        const headers = xhr.getAllResponseHeaders();
        const result = {};

        if (!headers) {
            return result;
        }

        headers.split('rn').forEach(line => {
            if (!line) return;

            const separatorIndex = line.indexOf(': ');
            if (separatorIndex > 0) {
                const key = line.substring(0, separatorIndex);
                const value = line.substring(separatorIndex + 2);

                // Проверяем, не является ли заголовок небезопасным
                if (!SentryHttpDataIntegration._RESTRICTED_HEADERS.some(
                    restrictedHeader => key.toLowerCase() === restrictedHeader ||
                        key.toLowerCase().startsWith(restrictedHeader)
                )) {
                    result[key] = value;
                }
            }
        });

        return result;
    }

    /**
     * Вспомогательные методы для захвата данных
     */

    /**
     * Захватывает и обрабатывает тело запроса для последующей отправки в Sentry.
     * Поддерживает различные типы данных: FormData, Blob, Response и строки.
     * @param {*} body - Тело запроса
     * @returns {Promise<Object|string|null>} - Обработанные данные запроса
     * @private
     */
    async _captureRequestBody(body) {
        try {
            // Обработка данных FormData
            if (body instanceof FormData) {
                return Object.fromEntries(body.entries());
            }

            // Обработка объектов, поддерживающих метод text() (Request, Response, Blob)
            if (typeof body.text === 'function') {
                const text = await body.text();
                return text.slice(0, SentryHttpDataIntegration.MAX_CAPTURE_LENGTH);
            }

            // Обработка остальных типов данных
            return String(body).slice(0, SentryHttpDataIntegration.MAX_CAPTURE_LENGTH);
        } catch {
            // В случае ошибки возвращаем информативную строку
            return '[unable to capture request body]';
        }
    }

    /**
     * Захватывает и обрабатывает тело ответа для последующей отправки в Sentry.
     * @param {Response} response - Объект Response
     * @returns {Promise<string>} - Текстовое представление тела ответа
     * @private
     */
    async _captureResponseBody(response) {
        try {
            // Пытаемся получить тело как текст
            const text = await response.text();
            return text.slice(0, SentryHttpDataIntegration.MAX_CAPTURE_LENGTH);
        } catch (e) {
            try {
                // Если не удалось получить как текст, пробуем работать с типом ответа
                const contentType = response.headers.get('content-type');

                if (contentType && contentType.includes('application/json')) {
                    // Для JSON пытаемся получить и преобразовать данные
                    const clone = response.clone();
                    const json = await clone.json();
                    return JSON.stringify(json).slice(0, SentryHttpDataIntegration.MAX_CAPTURE_LENGTH);
                }

                if (contentType && contentType.includes('text/')) {
                    // Для текстовых форматов пытаемся еще раз получить текст
                    const clone = response.clone();
                    return (await clone.text()).slice(0, SentryHttpDataIntegration.MAX_CAPTURE_LENGTH);
                }

                // Для бинарных данных возвращаем информацию о типе
                return `[binary data: ${contentType ?? 'unknown type'}]`;
            } catch {
                return '[unable to capture response body]';
            }
        }
    }

    /**
     * Синхронно захватывает данные для XMLHttpRequest запросов.
     * @param {*} data - Данные для захвата
     * @returns {Object|string|null} - Обработанные данные
     * @private
     */
    _captureSyncData(data) {
        try {
            if (!data) return null;

            // Обработка данных FormData
            if (data instanceof FormData) {
                return Object.fromEntries(data.entries());
            }

            // Обработка данных Blob
            if (data instanceof Blob) {
                return `[Blob data: ${data.type ?? 'unknown type'}, size: ${data.size} bytes]`;
            }

            // Обработка данных ArrayBuffer
            if (data instanceof ArrayBuffer) {
                return `[ArrayBuffer data: size: ${data.byteLength} bytes]`;
            }

            // Если данные - объект, пытаемся преобразовать в JSON
            if (typeof data === 'object' && data !== null) {
                try {
                    return JSON.stringify(data).slice(0, SentryHttpDataIntegration.MAX_CAPTURE_LENGTH);
                } catch (e) {
                    return `[object: ${Object.prototype.toString.call(data)}]`;
                }
            }

            // Обработка остальных типов данных
            return String(data).slice(0, SentryHttpDataIntegration.MAX_CAPTURE_LENGTH);
        } catch (e) {
            return '[unable to capture data]';
        }
    }

    /**
     * Получает размер ответа из заголовка Content-Length.
     * @param {Object} headers - Заголовки ответа
     * @returns {number|undefined} - Размер ответа в байтах или undefined
     * @private
     */
    _getResponseSizeFromHeaders(headers) {
        if (!headers) return undefined;

        const contentLength = headers['Content-Length'] ?? headers['content-length'];
        if (contentLength) {
            return parseInt(contentLength, 10);
        }

        return undefined;
    }

    /**
     * Формирует и отправляет информацию об ошибке в Sentry.
     * @param {Object} details - Детали ошибки
     * @param {string} details.method - HTTP метод запроса
     * @param {string} details.url - URL запроса
     * @param {*} details.requestData - Данные запроса
     * @param {Object} [details.requestHeaders] - Заголовки запроса
     * @param {number} [details.status] - Статус ответа (если есть)
     * @param {*} [details.responseData] - Данные ответа (если есть)
     * @param {Object} [details.responseHeaders] - Заголовки ответа
     * @param {string} [details.error] - Сообщение об ошибке (если есть)
     * @private
     */
    _captureError(details) {
        // Создаем объект ошибки с сообщением из details.error или на основе статуса ответа
        const error = new Error(details.error ?? `HTTP Error ${details.status ?? ''}`);

        // Формируем сообщение об ошибке
        const message = `HTTP Client Error: ${details.method} ${details.url} ${details.status ?? ''}`;

        // Формируем событие для Sentry
        const event = {
            message,
            exception: {
                values: [
                    {
                        type: 'Error',
                        value: message,
                    },
                ],
            },
            request: {
                url: details.url,
                method: details.method,
                headers: details.requestHeaders,
                data: details.requestData,
            },
            contexts: {
                response: {
                    status_code: details.status,
                    headers: details.responseHeaders ?? {},
                    data: details.responseData ?? {},
                    body_size: this._getResponseSizeFromHeaders(details.responseHeaders),
                },
            },
        };

        // Добавляем механизм исключения
        this._addExceptionMechanism(event);

        // Отправляем событие в Sentry
        Sentry.captureException(error, {
            contexts: event.contexts,
            request: event.request,
            tags: {
                'http.status_code': details.status ?? '',
                'http.method': details.method,
            },
        });
    }

    /**
     * Добавляет механизм исключения к событию.
     * @param {Object} event - Событие Sentry
     * @private
     */
    _addExceptionMechanism(event) {
        if (event.exception && event.exception.values && event.exception.values[0]) {
            event.exception.values[0].mechanism = {
                type: 'http.client',
                handled: false,
            };
        }
    }
}

/**
 * Универсальный экспорт класса интеграции.
 * Поддерживает как CommonJS модули (Node.js), так и браузерную среду.
 */
if (typeof module !== 'undefined' && module.exports) {
    module.exports = SentryHttpDataIntegration;
} else {
    window.SentryHttpDataIntegration = SentryHttpDataIntegration;
}

Проблема 3. Быстрое заполнение диска

Основная проблема — быстрое заполнение диска. 40 ГБ заполнялись за часы при включённом трейсинге и метриках. Sentry накапливает данные в ClickHouse, Kafka и Postgres.

Если всё-таки Sentry заполнил всё пространство, но повышать объём хранилища вы не хотите, то можно удалить некоторые docker-volume. Например, sentry-kafka, sentry-seaweedfs, возможно даже sentry-data. После их удаления запустите скрипт установки Sentry, и он заново создаст нужные docker volume. 

Решения для снижения нагрузки:

  1. Снижение срока хранения событий. Установили срок хранения событий в системе до 2-4 дней (в файле .env: SENTRY_EVENT_RETENTION_DAYS). Для хранения 14 дней потребуется 200+ ГБ.

  2. Отключение ненужных трейсингов:

    • браузерные спаны

    • ошибки от сторонних CDN (например, таймауты Google Fonts)

    • фоновые задачи (вкладка Performance в настройках проекта)

3. Оптимизация Kafka:

  • уменьшили retention событий до минимума — данные обрабатываются и удаляются.

Такие у нас настройки для контейнера Kafka: 

KAFKA_LOG_RETENTION_HOURS: "1"

KAFKA_MESSAGE_MAX_BYTES: "10000000" #10MB or bust

KAFKA_MAX_REQUEST_SIZE: "10000000" #10MB on requests apparently too

CONFLUENT_SUPPORT_METRICS_ENABLE: "false"

KAFKA_LOG_RETENTION_BYTES: "10737418240" # 10 GiB (server default; помните про "на партицию")

KAFKA_LOG_SEGMENT_BYTES: "268435456" # 256 MiB сегмент, чтобы быстрее закрывались и удалялись

KAFKA_LOG_RETENTION_CHECK_INTERVAL_MS: "300000" # каждые 5 минут проверять old segments

KAFKA_LOG_SEGMENT_DELETE_DELAY_MS: "60000" # задержка удаления сегмента

KAFKA_LOG_CLEANUP_POLICY: "delete"

KAFKA_LOG_CLEANER_ENABLE: true

Вот ещё ссылка на доку: https://develop.sentry.dev/self-hosted/troubleshooting/kafka/#reducing-disk-usage

  1. Вручную чистили ClickHouse: TRUNCATE на старых таблицах вроде spans_local, transactions_local. 

  2. Настроили cron для ежедневной очистки файлов, которые хранятся больше нашего срока в системе: find /var/lib/docker/volumes/sentry-data/_data -type f -mtime +3 -delete

Заключение 

Развёртывание Sentry self‑hosted — задача нетривиальная, особенно для высоконагруженных проектов. В ходе работы мы:

  • устранили зависания сайта из‑за недоступности Sentry (локальная JS‑библиотека + таймауты);

  • расширили мониторинг ошибок (интеграция для перехвата HTTP‑ошибок);

  • снизили нагрузку на диск (оптимизация Kafka, ClickHouse, настройка retention).

Рекомендации для тех, кто планирует развернуть Sentry self‑hosted:

  1. Начинайте с конфигурации, которая соответствует нагрузке (минимум 16 ГБ RAM, 4 ядра CPU).

  2. Сразу локализуйте JS‑библиотеку Sentry — это предотвратит зависания сайта.

  3. Настройте таймауты для запросов к Sentry — 300–500 мс достаточно для большинства сценариев.

  4. Отключайте ненужные трейсинги и метрики — это сэкономит место и ресурсы.

  5. Регулярно проверяйте использование диска и настраивайте retention данных.

  6. Тестируйте отказоустойчивость — симулируйте сбои Sentry и проверяйте поведение приложения.

Если у вас остались вопросы или есть идеи для улучшения — пишите в комментариях! 

Автор: mostok

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js