feat(TEAMMSBMOB-20426): подключение Sentry
This commit is contained in:
+2
-1
@@ -13,7 +13,8 @@ module.exports = {
|
||||
'react/destructuring-assignment': 'off',
|
||||
'@typescript-eslint/no-unsafe-call': 'off',
|
||||
'sonarjs/no-identical-functions': 'off',
|
||||
"prettier/prettier": ["error", { "endOfLine": "auto" }]
|
||||
'jsdoc/check-indentation': 'off',
|
||||
'prettier/prettier': ['error', { endOfLine: 'auto' }],
|
||||
},
|
||||
parser: '@typescript-eslint/parser',
|
||||
plugins: ['@typescript-eslint'],
|
||||
|
||||
Generated
+3877
-8332
File diff suppressed because it is too large
Load Diff
+1
-1
@@ -40,7 +40,7 @@
|
||||
"start:stage:fea": "lerna run start:stage --scope=msb-host --stream & lerna run start --scope=msb-fea --stream",
|
||||
"start:partner-check": "lerna run start --scope=msb-host --scope=msb-partner-check --stream",
|
||||
"start:stage:partner-check": "lerna run start:stage --scope=msb-host --stream & lerna run start --scope=msb-partner-check --stream",
|
||||
"build": "lerna run build --scope=msb-* --stream --concurrency=2",
|
||||
"build": "cross-env IB_MODULE_VERSION=$npm_package_version lerna run build --scope=msb-* --stream --concurrency=2",
|
||||
"build:webpack-config": "lerna run build --scope=@msb/mf-builder --stream",
|
||||
"start:prod": "npm run build && docker-compose up",
|
||||
"lint": "lerna run lint --scope=msb-* --stream",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/* eslint-disable @typescript-eslint/ban-ts-comment */
|
||||
// @ts-nocheck
|
||||
import { captureException } from '@msb/sentry';
|
||||
import type { AxiosError } from 'axios';
|
||||
import { captureException } from '@eco/monitoring';
|
||||
import { PLUGIN_NETWORK_ERROR_MESSAGE, VERSION_ERROR_MESSAGE } from '../stream-constants/crypto';
|
||||
|
||||
const CRYPTO_MODULE_ERROR_TYPE_TAG = 'crypto-module-error-type';
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
"i18next": "22.5.1",
|
||||
"@msb/http": "1.0.0",
|
||||
"@msb/shared": "1.0.0",
|
||||
"@msb/sentry": "1.0.0",
|
||||
"@msb/localization": "1.0.0",
|
||||
"axios": "1.12.2",
|
||||
"yup": "0.32.9",
|
||||
|
||||
@@ -4,6 +4,9 @@
|
||||
"private": true,
|
||||
"description": "",
|
||||
"main": "src/index.ts",
|
||||
"dependencies": {
|
||||
"@msb/sentry": "1.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"react": "17.0.2",
|
||||
"react-dom": "17.0.2",
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { ReactElement, ReactNode } from 'react';
|
||||
import type { ReactElement, ReactNode, ErrorInfo } from 'react';
|
||||
import { Component } from 'react';
|
||||
import { captureException } from '@msb/sentry';
|
||||
|
||||
export interface IErrorFallbackProps {
|
||||
// Ошибка, возникшая в одном из дочерних компонентов
|
||||
@@ -32,6 +33,14 @@ export class ErrorBoundary extends Component<IErrorBoundaryProps, IErrorBoundary
|
||||
status: 'ok',
|
||||
};
|
||||
|
||||
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
|
||||
captureException(error, hint => {
|
||||
hint.setExtra('errorInfo', errorInfo);
|
||||
|
||||
return hint;
|
||||
});
|
||||
}
|
||||
|
||||
reload(): void {
|
||||
this.setState({ status: 'ok' });
|
||||
this.props.onReload?.();
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
export { captureException, captureMessage, captureEvent, setContext, setExtra, setExtras, setTag, setTags, setUser } from '@sentry/react';
|
||||
export { TAGS, generateUniqueException } from './model';
|
||||
export { useSentry } from './ui';
|
||||
@@ -0,0 +1 @@
|
||||
export * from './use-token';
|
||||
@@ -0,0 +1,73 @@
|
||||
import { useCallback, useEffect, useRef } from 'react';
|
||||
import { useQuery } from '@msb/http';
|
||||
import axios from 'axios';
|
||||
import type { SentryState } from '../store/types';
|
||||
import { isExcludedPath } from '../utils';
|
||||
import { sentryConfig, tokenHeader } from '../utils/constants';
|
||||
|
||||
interface Props extends SentryState {
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
const TOKEN_REQUEST_KEY = 'tokenRequest';
|
||||
|
||||
/** Хук механизма генерации токена. */
|
||||
const useToken = ({ enabled, attempts, isSendingAllowed, setState }: Props) => {
|
||||
const reconnectTimer = useRef<NodeJS.Timeout>();
|
||||
const { enabledTokenGeneration, url, timeout, retry, retryDelay, attemptsBeforeDisable, reconnectionTime } = sentryConfig;
|
||||
// Признак активности механизма генерации токена для отправляемых данных
|
||||
const isTokenGenerationAvailable = Boolean(enabledTokenGeneration && url);
|
||||
|
||||
// GET-запрос токена для отправляемых данных
|
||||
const { refetch, isLoading } = useQuery([TOKEN_REQUEST_KEY], () => axios.get(url!, { timeout }), {
|
||||
enabled: enabled && isTokenGenerationAvailable && !isExcludedPath(),
|
||||
retry,
|
||||
retryDelay,
|
||||
refetchOnWindowFocus: false,
|
||||
onSuccess: response => {
|
||||
const token = response?.headers?.[tokenHeader];
|
||||
|
||||
if (token) {
|
||||
const newAttemptsCount = attempts + 1;
|
||||
|
||||
let newProps: Partial<SentryState> = {};
|
||||
|
||||
// Если количество перезапросов токена не превышает максимально допустимое,
|
||||
// то включаем логирование в Sentry, иначе выключаем
|
||||
newProps =
|
||||
newAttemptsCount < attemptsBeforeDisable ? { isSendingAllowed: true, token: Number(token) } : { isSendingAllowed: false };
|
||||
|
||||
setState?.({ ...newProps, attempts: newAttemptsCount });
|
||||
}
|
||||
},
|
||||
onError: () => {
|
||||
setState?.({ isSendingAllowed: false, token: null });
|
||||
},
|
||||
onSettled: () => {
|
||||
clearTimeout(reconnectTimer.current);
|
||||
|
||||
if (!isSendingAllowed) {
|
||||
reconnectTimer.current = setTimeout(async () => {
|
||||
setState?.({ attempts: 0 });
|
||||
|
||||
await refetch();
|
||||
}, reconnectionTime);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => () => clearTimeout(reconnectTimer.current), []);
|
||||
|
||||
/** Функция перезапроса токена. */
|
||||
const renewToken = useCallback(async () => {
|
||||
if (isLoading) return;
|
||||
|
||||
setState?.({ isSendingAllowed: false });
|
||||
|
||||
await refetch();
|
||||
}, [isLoading, setState, refetch]);
|
||||
|
||||
return isTokenGenerationAvailable ? renewToken : undefined;
|
||||
};
|
||||
|
||||
export { useToken };
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from './hooks';
|
||||
export * from './store';
|
||||
export * from './utils';
|
||||
@@ -0,0 +1,10 @@
|
||||
import type { SentryState } from './types';
|
||||
|
||||
/** Состояние стейта по умолчанию. */
|
||||
const initialSentryState: SentryState = {
|
||||
attempts: 0,
|
||||
token: null,
|
||||
isSendingAllowed: false,
|
||||
};
|
||||
|
||||
export { initialSentryState };
|
||||
@@ -0,0 +1,10 @@
|
||||
import create from 'zustand';
|
||||
import { initialSentryState } from './constants';
|
||||
import type { SentryState } from './types';
|
||||
|
||||
const useSentryStore = create<SentryState>(set => ({
|
||||
...initialSentryState,
|
||||
setState: (newState: Partial<SentryState>) => set(state => ({ ...state, ...newState })),
|
||||
}));
|
||||
|
||||
export { useSentryStore };
|
||||
@@ -0,0 +1,12 @@
|
||||
interface SentryState {
|
||||
// Признак разрешения отправки событий.
|
||||
isSendingAllowed: boolean;
|
||||
// Количество попыток перезапроса токена.
|
||||
attempts: number;
|
||||
// Токен для запросов.
|
||||
token: number | null;
|
||||
// Функция обновления cтора.
|
||||
setState?(newState: Partial<SentryState>): void;
|
||||
}
|
||||
|
||||
export type { SentryState };
|
||||
@@ -0,0 +1,106 @@
|
||||
import { getClient } from '@sentry/react';
|
||||
import type { ErrorEvent, Event, Breadcrumb, BreadcrumbHint } from '@sentry/types';
|
||||
|
||||
import { TAGS } from './constants';
|
||||
import { clearData, isExcludedPath } from './helpers';
|
||||
|
||||
/**
|
||||
* Функция вызывается перед добавлением breadcrumb в Sentry. Позволяет фильтровать и модифицировать breadcrumbs.
|
||||
*
|
||||
* @param breadcrumb - Объект breadcrumb, который будет отправлен в Sentry.
|
||||
* @param hint - Объект, содержащий дополнительную информацию о событии.
|
||||
* @returns Модифицированный breadcrumb, или null, если breadcrumb не должен быть отправлен.
|
||||
*/
|
||||
const beforeBreadcrumb = (breadcrumb: Breadcrumb, hint: BreadcrumbHint | undefined, isSendingAllowed: boolean) => {
|
||||
// Не отправляем breadcrumbs, если страница в исключениях или отправка запрещена.
|
||||
if (isExcludedPath() || !isSendingAllowed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const augmentedData = { ...breadcrumb.data };
|
||||
|
||||
// По 400+ кодам ответов в нетворке дополняем payload, response и traceId в объект Breadcrumb
|
||||
if (breadcrumb.category === 'xhr' && augmentedData?.status_code >= 400) {
|
||||
const payload = clearData(hint?.input);
|
||||
const traceId = hint?.xhr instanceof XMLHttpRequest ? hint.xhr.getResponseHeader(TAGS.traceIdHeader) : undefined;
|
||||
|
||||
if (traceId) {
|
||||
augmentedData[TAGS.traceIdHeader] = traceId;
|
||||
}
|
||||
|
||||
if (payload) {
|
||||
augmentedData.payload = payload;
|
||||
}
|
||||
|
||||
const response = clearData(hint?.xhr?.responseText);
|
||||
|
||||
if (response) {
|
||||
augmentedData.response = response;
|
||||
}
|
||||
|
||||
return { ...breadcrumb, data: augmentedData };
|
||||
} else if (breadcrumb.category === 'navigation') {
|
||||
// убираем хэш-часть урлов редиректов, т.к. в ней может быть токен
|
||||
if (typeof augmentedData.from === 'string') {
|
||||
augmentedData.from = augmentedData.from.replace(/#.*/, '');
|
||||
}
|
||||
|
||||
if (typeof augmentedData.to === 'string') {
|
||||
augmentedData.to = augmentedData.to.replace(/#.*/, '');
|
||||
}
|
||||
|
||||
return { ...breadcrumb, data: augmentedData };
|
||||
}
|
||||
|
||||
return breadcrumb;
|
||||
};
|
||||
|
||||
/**
|
||||
* Функция вызывается перед отправкой события в Sentry. Позволяет фильтровать и модифицировать события.
|
||||
*
|
||||
* @param event - Объект события, который будет отправлен в Sentry.
|
||||
* @returns Модифицированный event, или null, если событие не должно быть отправлено.
|
||||
*/
|
||||
const beforeSend = (event: ErrorEvent, isSendingAllowed: boolean): Event | PromiseLike<Event | null> | null => {
|
||||
const { breadcrumbs, contexts, exception: eventException, tags } = event;
|
||||
const exception = eventException?.values?.[0];
|
||||
|
||||
if (
|
||||
// Выключаем логирование для случаев:
|
||||
// страница из исключений
|
||||
isExcludedPath() ||
|
||||
// стоит флаг запрета отправки событий
|
||||
!isSendingAllowed
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const mechanismType = exception?.mechanism?.type;
|
||||
|
||||
if (mechanismType === 'http.client') {
|
||||
const errData = breadcrumbs?.[breadcrumbs.length - 1]?.data;
|
||||
|
||||
const traceId = contexts?.response?.headers?.[TAGS.traceIdHeader];
|
||||
|
||||
// Добавляем тег с traceId от бэкенда, если получили его в заголовках
|
||||
if (traceId) {
|
||||
return { ...event, tags: { ...tags, [TAGS.traceIdHeader]: traceId } };
|
||||
}
|
||||
|
||||
try {
|
||||
const traceIdResp = errData?.response ? JSON.parse(errData?.response)?.errorInfo?.traceId : null;
|
||||
|
||||
// Добавляем тег с traceId от бэкенда, если получили его в теле ответа
|
||||
return traceIdResp ? { ...event, tags: { ...tags, [TAGS.traceIdHeader]: traceIdResp } } : event;
|
||||
} catch {
|
||||
return event;
|
||||
}
|
||||
}
|
||||
|
||||
return event;
|
||||
};
|
||||
|
||||
/** Отключение клиента Sentry. */
|
||||
const clientShutdown = () => getClient()?.close(0);
|
||||
|
||||
export { beforeBreadcrumb, beforeSend, clientShutdown };
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { MODE, PADDING } from 'egoroof-blowfish';
|
||||
import { Blowfish } from 'egoroof-blowfish';
|
||||
|
||||
/** Шифрует данные по Blowfish.
|
||||
*
|
||||
* @param key - Ключ шифрования.
|
||||
* @param iv - Вектор инициализации.
|
||||
* @param data - Данные для шифрования.
|
||||
* @param mode - Режим шифрования.
|
||||
* @default MODE.CBC
|
||||
* @param padding - Режим дополнения.
|
||||
* @default PADDING.PKCS5
|
||||
*/
|
||||
const encryptData = (
|
||||
key: string,
|
||||
iv: string,
|
||||
data: Uint8Array | string,
|
||||
mode: MODE = Blowfish.MODE.CBC,
|
||||
padding: PADDING = Blowfish.PADDING.PKCS5
|
||||
): Uint8Array => {
|
||||
const bf = new Blowfish(key, mode, padding);
|
||||
|
||||
bf.setIv(iv);
|
||||
|
||||
return bf.encode(data);
|
||||
};
|
||||
|
||||
export { encryptData };
|
||||
@@ -0,0 +1,64 @@
|
||||
import type { SentryConfig } from './types';
|
||||
|
||||
/** Конфигурация Sentry. */
|
||||
const sentryConfig: SentryConfig = {
|
||||
enabledAdvancedLogging: true,
|
||||
enabledAuthoritiesCollecting: true,
|
||||
enabledCompressing: true,
|
||||
enabledCustomHeaders: true,
|
||||
enabledEncoding: true,
|
||||
enabledTokenGeneration: true,
|
||||
vector: '58741936',
|
||||
msg: 'KratosAresSiriusMSB',
|
||||
timeout: 30_000,
|
||||
retry: 1,
|
||||
retryDelay: 10_000,
|
||||
attemptsBeforeDisable: 3,
|
||||
reconnectionTime: 1_800_000,
|
||||
disabledForStreams: ['/web-dealing'],
|
||||
dsn: process.env.SENTRY_DSN,
|
||||
url: process.env.SENTRY_URI,
|
||||
};
|
||||
|
||||
/** Заголовок с токеном. */
|
||||
const tokenHeader = 'x-csrf-token';
|
||||
|
||||
/** Общие теги. */
|
||||
const TAGS = {
|
||||
/** Запрос. */
|
||||
request: 'request',
|
||||
/** Модуль. */
|
||||
module: 'module',
|
||||
/** Id апросd. */
|
||||
requestId: 'request-id',
|
||||
/** Тип криптоплагина. */
|
||||
cryptoPlugin: 'crypto-plugin',
|
||||
/** Версия криптоплагина. */
|
||||
cryptoPluginVersion: 'crypto-plugin-version',
|
||||
/** Код события. */
|
||||
eventCode: 'event-code',
|
||||
/** Заголовок с traceId от backend'а. */
|
||||
traceIdHeader: 'x-b3-traceid',
|
||||
} as const;
|
||||
|
||||
/** Исключаемые поля в payload и response запросов. Взят у экосистемы. */
|
||||
const excludedDataFields = [
|
||||
'authData',
|
||||
'deviceDigitalPrint',
|
||||
'digest',
|
||||
'mfa_certificate',
|
||||
'mfa_password',
|
||||
'mfa_signed_data',
|
||||
'mfa_token',
|
||||
'newPassword',
|
||||
'newPasswordConfirm',
|
||||
'oldPassword',
|
||||
'password',
|
||||
'redirect_uri',
|
||||
'signature',
|
||||
'token',
|
||||
'user_environment',
|
||||
'X509data',
|
||||
];
|
||||
|
||||
export { sentryConfig, excludedDataFields, tokenHeader, TAGS };
|
||||
@@ -0,0 +1,105 @@
|
||||
import { captureException } from '@sentry/react';
|
||||
import { gzip } from 'pako';
|
||||
import { encryptData } from './bf-crypto';
|
||||
import { excludedDataFields, sentryConfig } from './constants';
|
||||
import type { ConvertRequestBodyProps, GenerateUniqueExceptionProps, NetworkBody } from './types';
|
||||
|
||||
/**
|
||||
* Преобразует тело запроса Sentry: шифрует и сжимает gzip .
|
||||
*
|
||||
* @param params - Параметры преобразования.
|
||||
* @param params.body - Тело запроса, которое нужно преобразовать.
|
||||
* @param params.enabledCompressing - Флаг, указывающий, нужно ли сжимать тело запроса с использованием gzip.
|
||||
* @param params.enabledEncoding - Флаг, указывающий, нужно ли шифровать тело запроса.
|
||||
* @param params.msg - Сообщение (ключ) для шифрования.
|
||||
* @param params.vector - Вектор инициализации (IV) для шифрования.
|
||||
*
|
||||
* @returns Преобразованное тело запроса.
|
||||
*/
|
||||
const convertRequestBody = ({ body, enabledCompressing, enabledEncoding, msg, vector }: ConvertRequestBodyProps) => {
|
||||
let result = body;
|
||||
|
||||
if (enabledEncoding && msg && vector) {
|
||||
result = encryptData(msg, vector, body);
|
||||
}
|
||||
|
||||
if (enabledCompressing) {
|
||||
return gzip(result);
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
/** Генерирует новый токен для POST-запросов.
|
||||
*
|
||||
* @param token - Токен для преобразования.
|
||||
*/
|
||||
const generateToken = (token: number): number => {
|
||||
// Преобразуем токен в 32-битное беззнаковое целое число.
|
||||
const unsigned = token >>> 0;
|
||||
// Преобразуем беззнаковое целое число в двоичную строку, дополняя нулями до 32 символов.
|
||||
const binary = unsigned.toString(2).padStart(32, '0');
|
||||
// Сдвигаем беззнаковое целое число вправо на 1 бит (эквивалентно делению на 2).
|
||||
// Затем преобразуем результат в двоичную строку, дополняя нулями до 32 символов.
|
||||
const shifted = ((unsigned >> 1) >>> 0).toString(2).padStart(32, '0');
|
||||
|
||||
const res = `${binary.slice(-1)}${shifted.slice(1)}`;
|
||||
|
||||
return parseInt(res, 2);
|
||||
};
|
||||
|
||||
/** Исключает из объекта данных лишние поля.
|
||||
*
|
||||
* @param data - Объект данных.
|
||||
*/
|
||||
const clearData = (data: NetworkBody) => {
|
||||
try {
|
||||
const dataObj: NetworkBody = typeof data === 'string' ? JSON.parse(data) : data;
|
||||
|
||||
if (Array.isArray(dataObj)) {
|
||||
dataObj.forEach(subData => clearData(subData));
|
||||
} else if (dataObj && typeof dataObj === 'object') {
|
||||
Object.entries(dataObj).forEach(([key, value]) => {
|
||||
if (excludedDataFields.includes(key)) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-dynamic-delete
|
||||
delete dataObj[key];
|
||||
} else if (Array.isArray(value)) {
|
||||
value.forEach(subData => clearData(subData));
|
||||
} else if (value && typeof value === 'object') {
|
||||
clearData(value);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return typeof data === 'string' ? JSON.stringify(dataObj) : dataObj;
|
||||
} catch {
|
||||
return data;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Генерирует уникальное событие при каждом вызове, объединяя в один exception в Sentry.
|
||||
*
|
||||
* @param params - Параметры генерации исключения.
|
||||
* @param params.name - Имя исключения (используется для fingerprint).
|
||||
* @param params.tags - Объект с тегами, которые будут добавлены к событию в Sentry.
|
||||
*
|
||||
*/
|
||||
const generateUniqueException = ({ name, tags }: GenerateUniqueExceptionProps) => {
|
||||
const err = new Error(`${name}_${Date.now()}`);
|
||||
|
||||
err.name = name;
|
||||
|
||||
captureException(err, e => {
|
||||
tags && e.setTags(tags);
|
||||
// одинаковый fingerprint, чтобы группировать события в один issue
|
||||
e.setFingerprint([name]);
|
||||
|
||||
return e;
|
||||
});
|
||||
};
|
||||
|
||||
/** Проверяет, находится ли текущая страница в исключениях. */
|
||||
const isExcludedPath = (): boolean => sentryConfig.disabledForStreams.some(url => window.location.pathname.startsWith(url));
|
||||
|
||||
export { convertRequestBody, generateToken, generateUniqueException, isExcludedPath, clearData };
|
||||
@@ -0,0 +1,6 @@
|
||||
export * from './addons';
|
||||
export * from './bf-crypto';
|
||||
export * from './constants';
|
||||
export * from './helpers';
|
||||
export * from './transport';
|
||||
export * from './types';
|
||||
@@ -0,0 +1,84 @@
|
||||
import { createTransport } from '@sentry/core';
|
||||
import type {
|
||||
BaseTransportOptions,
|
||||
Transport,
|
||||
TransportMakeRequestResponse,
|
||||
TransportRequest,
|
||||
TransportRequestExecutor,
|
||||
} from '@sentry/types';
|
||||
import axios from 'axios';
|
||||
import type { SentryState } from '../store/types';
|
||||
import { sentryConfig, tokenHeader } from './constants';
|
||||
import { convertRequestBody, generateToken, isExcludedPath } from './helpers';
|
||||
import type { TransportProps } from './types';
|
||||
|
||||
const axiosInstance = axios.create();
|
||||
|
||||
axiosInstance.interceptors.request.use(config => {
|
||||
// Блокируем отправку запросов для страниц из исключений, или если стоит флаг запрета отправки событий
|
||||
if (isExcludedPath()) {
|
||||
return Promise.reject(new Error('Request blocked due to excluded path.'));
|
||||
}
|
||||
|
||||
return config;
|
||||
});
|
||||
|
||||
/** Кастомный транспорт для отправки запросов в Sentry. */
|
||||
const customTransport =
|
||||
({ token, attempts, setState, renewToken }: TransportProps) =>
|
||||
(options: BaseTransportOptions): Transport => {
|
||||
const { enabledCustomHeaders, customHeaders, url, attemptsBeforeDisable, enabledCompressing, enabledEncoding, vector, msg } =
|
||||
sentryConfig;
|
||||
const makeRequest: TransportRequestExecutor = ({ body: data }) => {
|
||||
const sendRequest = ({
|
||||
body,
|
||||
url: requestUrl,
|
||||
}: Pick<BaseTransportOptions, 'url'> & TransportRequest): PromiseLike<TransportMakeRequestResponse> =>
|
||||
new Promise((resolve, reject) => {
|
||||
let newToken: SentryState['token'] = null;
|
||||
|
||||
if (renewToken) {
|
||||
newToken = token ? generateToken(token) : null;
|
||||
|
||||
setState?.({ token: newToken });
|
||||
}
|
||||
|
||||
axiosInstance
|
||||
.post(requestUrl, convertRequestBody({ body, enabledCompressing, enabledEncoding, vector, msg }), {
|
||||
headers: {
|
||||
// Добавляем кастомные хедеры для запросов в Sentry
|
||||
...(enabledCustomHeaders
|
||||
? {
|
||||
...(newToken ? { [tokenHeader]: newToken } : {}),
|
||||
...customHeaders,
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
})
|
||||
.then(response => {
|
||||
// При успешной отправке сбрасываем счётчик неудачных попыток
|
||||
setState?.({ attempts: 0 });
|
||||
|
||||
resolve({
|
||||
...response,
|
||||
headers: response.headers as TransportMakeRequestResponse['headers'],
|
||||
});
|
||||
})
|
||||
.catch(error => {
|
||||
// Перезапрашиваем токен, если получили код 400 в ответ на отправку данных в Sentry
|
||||
// и ещё не превышено допустимое количество попыток
|
||||
if (error?.response?.status === 400 && attempts < attemptsBeforeDisable) {
|
||||
renewToken?.();
|
||||
}
|
||||
|
||||
reject(error);
|
||||
});
|
||||
});
|
||||
|
||||
return sendRequest({ body: data, url: url ?? options.url });
|
||||
};
|
||||
|
||||
return createTransport(options, makeRequest);
|
||||
};
|
||||
|
||||
export { customTransport };
|
||||
@@ -0,0 +1,62 @@
|
||||
import type { Scope, TransportRequest } from '@sentry/types';
|
||||
import type { SentryState } from '../store/types';
|
||||
|
||||
/** Интерфейс конфига Sentry. */
|
||||
interface SentryConfig {
|
||||
// Дополнительные заголовки.
|
||||
customHeaders?: Record<string, string>;
|
||||
// Источник данных Sentry.
|
||||
dsn?: string;
|
||||
// Включение механизма расширенного логирования.
|
||||
enabledAdvancedLogging?: boolean;
|
||||
// Включение отправки привилегий.
|
||||
enabledAuthoritiesCollecting: boolean;
|
||||
// Включение сжатия (gzip).
|
||||
enabledCompressing: boolean;
|
||||
// Включение пользовательских заголовков.
|
||||
enabledCustomHeaders: boolean;
|
||||
// Включение шифрования.
|
||||
enabledEncoding: boolean;
|
||||
// Включение механизма генерации токена.
|
||||
enabledTokenGeneration: boolean;
|
||||
// Ключ шифрования.
|
||||
msg?: string;
|
||||
// Вектор инициализации.
|
||||
vector: string;
|
||||
// Ссылка для отправки запросов.
|
||||
url?: string;
|
||||
// Таймаут для запроса токена.
|
||||
timeout: number;
|
||||
// Количество попыток перезапроса токена в случае ошибки.
|
||||
retry: number;
|
||||
// Задержка перезапроса токена.
|
||||
retryDelay: number;
|
||||
// Максимальное количество попыток запроса токена за сессию.
|
||||
attemptsBeforeDisable: number;
|
||||
// Интервал восстановления подключения.
|
||||
reconnectionTime?: number;
|
||||
// Список url'ов, для которых отключено логирование.
|
||||
disabledForStreams: string[];
|
||||
}
|
||||
|
||||
/** Свойства функции convertRequestBody. */
|
||||
type ConvertRequestBodyProps = Pick<SentryConfig, 'enabledCompressing' | 'enabledEncoding' | 'msg' | 'vector'> & TransportRequest;
|
||||
|
||||
/** Свойства функции ecoTransport. */
|
||||
interface TransportProps extends Omit<SentryState, 'isSendingAllowed'> {
|
||||
/** Функция перезапроса токена. */
|
||||
renewToken?(): void;
|
||||
}
|
||||
|
||||
/** Свойства функции generateUniqueException. */
|
||||
interface GenerateUniqueExceptionProps {
|
||||
// Название exception.
|
||||
name: string;
|
||||
// Теги для добавления к событию.
|
||||
tags?: Parameters<Scope['setTags']>[0];
|
||||
}
|
||||
|
||||
/** Payload запросов. */
|
||||
type NetworkBody = NetworkBody[] | string | { [key: string]: NetworkBody | number };
|
||||
|
||||
export type { SentryConfig, NetworkBody, GenerateUniqueExceptionProps, TransportProps, ConvertRequestBodyProps };
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"name": "@msb/sentry",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"main": "index.ts",
|
||||
"description": "",
|
||||
"dependencies": {
|
||||
"@msb/http": "1.0.0",
|
||||
"@sentry/integrations": "7.98.0",
|
||||
"@sentry/react": "7.98.0",
|
||||
"@sentry/replay": "7.98.0",
|
||||
"@sentry/tracing": "7.98.0",
|
||||
"egoroof-blowfish": "4.0.1",
|
||||
"nanoid": "3.3.11",
|
||||
"pako": "2.0.4",
|
||||
"zustand": "^3.5.10"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/pako": "^2.0.3"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from './useSentry';
|
||||
@@ -0,0 +1,67 @@
|
||||
import { useEffect } from 'react';
|
||||
import { httpClientIntegration } from '@sentry/integrations';
|
||||
import { init } from '@sentry/react';
|
||||
import { beforeBreadcrumb, beforeSend, clientShutdown, customTransport, useToken, useSentryStore, sentryConfig } from '../model';
|
||||
|
||||
/**
|
||||
* Инициализирует Sentry SDK и настраивает интеграции, transport и другие параметры.
|
||||
*
|
||||
* @param releaseVersion - Версия релиза приложения.
|
||||
*/
|
||||
const useSentry = (releaseVersion: string): void => {
|
||||
const { token, attempts, isSendingAllowed, setState } = useSentryStore(state => ({
|
||||
token: state.token,
|
||||
attempts: state.attempts,
|
||||
isSendingAllowed: state.isSendingAllowed,
|
||||
setState: state.setState,
|
||||
}));
|
||||
|
||||
const { dsn, enabledTokenGeneration, url } = sentryConfig;
|
||||
|
||||
// Признак активности механизма генерации токена для отправляемых данных
|
||||
const isTokenGenerationAvailable = Boolean(enabledTokenGeneration && url);
|
||||
|
||||
// Включаем Sentry только в production среде и при доступности генерации токена.
|
||||
const enabled = Boolean(process.env.NODE_ENV === 'production' && releaseVersion && isTokenGenerationAvailable);
|
||||
|
||||
const renewToken = useToken({ enabled, token, isSendingAllowed, attempts, setState });
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
|
||||
// Инициализация подключения к Sentry
|
||||
init({
|
||||
dsn,
|
||||
integrations: [
|
||||
httpClientIntegration({
|
||||
// Логируем ошибки запросов с кодами ответов 400-599
|
||||
failedRequestStatusCodes: [[400, 599]],
|
||||
// Не логируем ошибки запросов к Sentry, если указан URL для генерации токена
|
||||
failedRequestTargets: url ? [new RegExp(`^((?!${url}).)*$`)] : undefined,
|
||||
}),
|
||||
],
|
||||
sendDefaultPii: true,
|
||||
tracesSampleRate: 1.0,
|
||||
release: releaseVersion,
|
||||
environment: process.env.NODE_ENV,
|
||||
ignoreErrors: [/ResizeObserver/],
|
||||
transport: customTransport({
|
||||
token,
|
||||
attempts,
|
||||
setState,
|
||||
renewToken,
|
||||
}),
|
||||
// Коллбэк для обработки breadcrumbs перед отправкой в Sentry.
|
||||
beforeBreadcrumb: (breadcrumb, hint) => beforeBreadcrumb(breadcrumb, hint, isSendingAllowed),
|
||||
// Коллбэк для обработки событий перед отправкой в Sentry.
|
||||
beforeSend: event => beforeSend(event, isSendingAllowed),
|
||||
});
|
||||
|
||||
return () => {
|
||||
// Завершение работы Sentry
|
||||
clientShutdown();
|
||||
};
|
||||
}, [dsn, enabled, releaseVersion, renewToken, url]);
|
||||
};
|
||||
|
||||
export { useSentry };
|
||||
@@ -4,3 +4,5 @@ OMNI_FEATURE_ADAPTER_ENDPOINT="https://omni.psi.dmz.gazprombank.ru"
|
||||
AB_PAYMENTS_ENDPOINT="https://app.stage.p.ab-payments.ru"
|
||||
YM_ID=103631136
|
||||
CLIENT_ID=13c910a3-cda5-4dee-978d-08062604ef75
|
||||
SENTRY_DSN="https://139b953279d38cb6ce0a52b123abb8cf@dbo-msb-tst3.dmz.gazprombank.ru/32"
|
||||
SENTRY_URI="/api/32/envelope/"
|
||||
@@ -31,6 +31,7 @@
|
||||
"@fractal-ui/overlays": "30.15.0",
|
||||
"@fractal-ui/styling": "30.14.2",
|
||||
"@msb/crypto": "1.0.0",
|
||||
"@msb/sentry": "1.0.0",
|
||||
"@msb/fractal-ui-composites": "30.15.1",
|
||||
"@msb/http": "^1.0.0",
|
||||
"@msb/localization": "1.0.0",
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"dsn": "https://139b953279d38cb6ce0a52b123abb8cf@dbo-msb-tst3.dmz.gazprombank.ru/32",
|
||||
"msg": "KratosAresSiriusMSB",
|
||||
"customHeaders": {
|
||||
"Content-Type": "application/octet-stream"
|
||||
},
|
||||
"url": "/api/32/envelope/"
|
||||
}
|
||||
@@ -1,12 +1,16 @@
|
||||
import { type ReactElement } from 'react';
|
||||
import type { RemoteRouteConfigDto } from '@msb/http';
|
||||
import { useSentry } from '@msb/sentry';
|
||||
import { MEDIA, YM_GOALS, useMediaQuery, YaMetrikaReachGoal } from '@msb/shared';
|
||||
import { AppProvider } from './providers';
|
||||
import { Layout } from '@/pages/Layout';
|
||||
|
||||
const App = ({ modules }: { modules: RemoteRouteConfigDto[] }): ReactElement => {
|
||||
const releaseVersion = process.env.IB_MODULE_VERSION ?? '';
|
||||
const isDesktop = useMediaQuery(MEDIA.desktop);
|
||||
|
||||
useSentry(releaseVersion);
|
||||
|
||||
return (
|
||||
<AppProvider>
|
||||
<YaMetrikaReachGoal goalType={YM_GOALS.VISIT} params={{ [YM_GOALS.VISIT]: { ext_type: isDesktop ? 'desktop' : 'adaptive' } }} />
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useQuery, type OrganizationDto } from '@msb/http';
|
||||
import { captureException, TAGS } from '@msb/sentry';
|
||||
import { fetchClientOrganizations, QUERY_KEYS_ORGANIZATIONS } from '../../api';
|
||||
import { mapUniqClientOrganizations } from './mapUniqClientOrganizations';
|
||||
|
||||
@@ -11,6 +12,13 @@ const useOrganizations = (enabled = true) => {
|
||||
refetch,
|
||||
} = useQuery<OrganizationDto[], Error | undefined>({
|
||||
queryKey: [QUERY_KEYS_ORGANIZATIONS],
|
||||
onError: err => {
|
||||
captureException(err, hint => {
|
||||
hint.setTag(TAGS.request, QUERY_KEYS_ORGANIZATIONS);
|
||||
|
||||
return hint;
|
||||
});
|
||||
},
|
||||
queryFn: fetchClientOrganizations,
|
||||
enabled,
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { AuthoritiesResponseDto } from '@msb/http';
|
||||
import { useQuery } from '@msb/http';
|
||||
import { captureException, TAGS } from '@msb/sentry';
|
||||
import { fetchUserAuthorities, QUERY_KEY_FETCH_USER_AUTHORITIES } from '@/app/api';
|
||||
|
||||
const useUserAuthorities = () => {
|
||||
@@ -11,6 +12,13 @@ const useUserAuthorities = () => {
|
||||
} = useQuery<AuthoritiesResponseDto, Error | undefined>({
|
||||
queryKey: [QUERY_KEY_FETCH_USER_AUTHORITIES],
|
||||
queryFn: fetchUserAuthorities,
|
||||
onError: err => {
|
||||
captureException(err, hint => {
|
||||
hint.setTag(TAGS.request, QUERY_KEY_FETCH_USER_AUTHORITIES);
|
||||
|
||||
return hint;
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useRef } from 'react';
|
||||
import type { UserProfileDto } from '@msb/http';
|
||||
import { useQuery } from '@msb/http';
|
||||
import { captureException, TAGS } from '@msb/sentry';
|
||||
import { fetchUserProfile, QUERY_KEY_USER_PROFILE } from '@/app/api';
|
||||
|
||||
const useUserProfile = (enabled = true) => {
|
||||
@@ -16,6 +17,12 @@ const useUserProfile = (enabled = true) => {
|
||||
queryFn: fetchUserProfile,
|
||||
onSettled: (data, error) => {
|
||||
if (error) {
|
||||
captureException(error, hint => {
|
||||
hint.setTag(TAGS.request, QUERY_KEY_USER_PROFILE);
|
||||
|
||||
return hint;
|
||||
});
|
||||
|
||||
isLastErrorRef.current = true;
|
||||
} else if (data) {
|
||||
isLastErrorRef.current = false;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useRef } from 'react';
|
||||
import { useQuery, type AgreementsPageResponseDto } from '@msb/http';
|
||||
import { captureException, TAGS } from '@msb/sentry';
|
||||
import { fetchAgreementPage, QUERY_KEY_AGREEMENT_PAGE } from '../api';
|
||||
import { AGREEMENTS_MAX_FETCH_COUNT } from '../constant';
|
||||
|
||||
@@ -30,6 +31,12 @@ const useAgreementPage = (enabled = true) => {
|
||||
const isLastErrorRef = useRef<boolean>(false);
|
||||
|
||||
if (agreementsError) {
|
||||
captureException(agreementsError, hint => {
|
||||
hint.setTag(TAGS.request, QUERY_KEY_AGREEMENT_PAGE);
|
||||
|
||||
return hint;
|
||||
});
|
||||
|
||||
isLastErrorRef.current = true;
|
||||
} else if (agreements) {
|
||||
isLastErrorRef.current = false;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useRef } from 'react';
|
||||
import { useQuery, type UserProfileDto } from '@msb/http';
|
||||
import { captureException, TAGS } from '@msb/sentry';
|
||||
import { fetchUserProfile, QUERY_KEY_USER_PROFILE } from '../../api';
|
||||
|
||||
const useUserProfile = () => {
|
||||
@@ -14,6 +15,12 @@ const useUserProfile = () => {
|
||||
queryFn: fetchUserProfile,
|
||||
onSettled: (data, error) => {
|
||||
if (error) {
|
||||
captureException(error, hint => {
|
||||
hint.setTag(TAGS.request, QUERY_KEY_USER_PROFILE);
|
||||
|
||||
return hint;
|
||||
});
|
||||
|
||||
isLastErrorRef.current = true;
|
||||
} else if (data) {
|
||||
isLastErrorRef.current = false;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useQuery } from '@msb/http';
|
||||
import type { DigitalSignatureToolDto } from '@msb/http/signature';
|
||||
import { captureException, TAGS } from '@msb/sentry';
|
||||
import { fetchSignatureList, QUERY_KEY_SIGNATURES_LIST } from '../api';
|
||||
|
||||
const useSignatureList = (id: string) => {
|
||||
@@ -11,6 +12,13 @@ const useSignatureList = (id: string) => {
|
||||
} = useQuery<DigitalSignatureToolDto[], Error | undefined>({
|
||||
queryFn: () => fetchSignatureList(id),
|
||||
queryKey: [QUERY_KEY_SIGNATURES_LIST, id],
|
||||
onError: err => {
|
||||
captureException(err, hint => {
|
||||
hint.setTags({ [TAGS.request]: QUERY_KEY_SIGNATURES_LIST, [TAGS.requestId]: id });
|
||||
|
||||
return hint;
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { IWebpackAppConfig } from '@msb/mf-builder';
|
||||
import CopyPlugin from 'copy-webpack-plugin';
|
||||
import HtmlWebpackPlugin from 'html-webpack-plugin';
|
||||
import webpack from 'webpack';
|
||||
import path from 'node:path';
|
||||
import packageJson from './package.json';
|
||||
|
||||
@@ -75,6 +76,7 @@ const config: IWebpackAppConfig = {
|
||||
],
|
||||
},
|
||||
plugins: [
|
||||
new webpack.DefinePlugin({ 'process.env.IB_MODULE_VERSION': JSON.stringify(process.env.IB_MODULE_VERSION) }),
|
||||
new HtmlWebpackPlugin({
|
||||
template: path.resolve(publicPath, 'index.html'),
|
||||
filename: 'index.html',
|
||||
|
||||
Reference in New Issue
Block a user