Привет! Меня зовут Даниил Ткаченко, я веб‑разработчик в ИТ‑компании «Активика». В статье я поделюсь опытом развёртывания 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} * @private */ static _RESTRICTED_HEADERS = [ 'set-cookie', 'set-cookie2', 'cookie2', 'cookie', 'authorization', 'proxy-authorization', 'sec-', 'proxy-' ]; /** * Настройки для фильтрации HTTP-запросов, по которым будут отправляться события. * @type {Object} * @property {Array|number>} failedRequestStatusCodes - Коды HTTP-статусов, которые считаются ошибками * @property {Array} failedRequestTargets - URL-шаблоны для отслеживания ошибок * @private */ _options = { failedRequestStatusCodes: [[500, 599]], failedRequestTargets: [/.*/], }; /** * Конструктор класса. * @param {Object} options - Настройки интеграции * @param {Array|number>} [options.failedRequestStatusCodes] - Коды HTTP-статусов для отслеживания * @param {Array} [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('\r\n').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
Проблема 3. Быстрое заполнение диска Основная проблема — быстрое заполнение диска. 40 ГБ заполнялись за часы при включённом трейсинге и метриках. Sentry накапливает данные в ClickHouse, Kafka и Postgres. Если всё-таки Sentry заполнил всё пространство, но повышать объём хранилища вы не хотите, то можно удалить некоторые docker-volume. Например, sentry-kafka, sentry-seaweedfs, возможно даже sentry-data. После их удаления запустите скрипт установки Sentry, и он заново создаст нужные docker volume. Решения для снижения нагрузки:
Снижение срока хранения событий. Установили срок хранения событий в системе до 2-4 дней (в файле .env: SENTRY_EVENT_RETENTION_DAYS). Для хранения 14 дней потребуется 200+ ГБ.
Отключение ненужных трейсингов:
браузерные спаны
ошибки от сторонних 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
Вручную чистили ClickHouse: TRUNCATE на старых таблицах вроде spans_local, transactions_local.
Настроили cron для ежедневной очистки файлов, которые хранятся больше нашего срока в системе: find /var/lib/docker/volumes/sentry-data/_data -type f -mtime +3 -delete
Заключение Развёртывание Sentry self‑hosted — задача нетривиальная, особенно для высоконагруженных проектов. В ходе работы мы: