From ebe74aac060e6fdc8172898b18c2724e972fbb5e Mon Sep 17 00:00:00 2001 From: Piotr Gawron <p.gawron@atcomp.pl> Date: Fri, 17 May 2024 09:31:16 +0200 Subject: [PATCH] provide stacktrace in report and remove cyclic store dependency --- pages/_app.tsx | 3 - .../utils/getBasePublications.ts | 6 +- .../utils/fetchElementData.ts | 6 +- .../hooks/useLoadPluginFromUrl.ts | 3 +- src/models/fixtures/javaStacktraceFixture.ts | 8 +++ src/models/javaStacktraceSchema.ts | 7 +++ src/redux/apiPath.ts | 1 + .../middlewares/error.middleware.test.ts | 37 ++++++++--- src/redux/middlewares/error.middleware.ts | 9 ++- src/types/models.ts | 2 + src/utils/error-report/errorReporting.test.ts | 42 ++++++++----- src/utils/error-report/errorReporting.ts | 61 +++++++++++++------ src/utils/error-report/getErrorCode.ts | 15 ++++- 13 files changed, 147 insertions(+), 53 deletions(-) create mode 100644 src/models/fixtures/javaStacktraceFixture.ts create mode 100644 src/models/javaStacktraceSchema.ts diff --git a/pages/_app.tsx b/pages/_app.tsx index da35a0c3..570dbf6e 100644 --- a/pages/_app.tsx +++ b/pages/_app.tsx @@ -3,9 +3,6 @@ import type { AppProps } from 'next/app'; import '@/styles/index.css'; import { useEffect } from 'react'; import { store } from '@/redux/store'; -import { initializeErrorReporting } from '@/utils/error-report/errorReporting'; - -initializeErrorReporting(); const MyApp = ({ Component, pageProps }: AppProps): JSX.Element => { const project = store.getState().project.data; diff --git a/src/components/FunctionalArea/Modal/PublicationsModal/PublicationsModalLayout/utils/getBasePublications.ts b/src/components/FunctionalArea/Modal/PublicationsModal/PublicationsModalLayout/utils/getBasePublications.ts index 8b3039aa..a927bb14 100644 --- a/src/components/FunctionalArea/Modal/PublicationsModal/PublicationsModalLayout/utils/getBasePublications.ts +++ b/src/components/FunctionalArea/Modal/PublicationsModal/PublicationsModalLayout/utils/getBasePublications.ts @@ -6,6 +6,7 @@ import { Publication, PublicationsResponse } from '@/types/models'; import { validateDataUsingZodSchema } from '@/utils/validateDataUsingZodSchema'; import { getError } from '@/utils/error-report/getError'; import { handleError } from '@/utils/error-report/errorReporting'; +import { store } from '@/redux/store'; interface Args { length: number; @@ -21,7 +22,10 @@ export const getBasePublications = async ({ length }: Args): Promise<Publication return isDataValid ? response.data.data : []; } catch (error) { - handleError(getError({ error, prefix: PUBLICATIONS_FETCHING_ERROR_PREFIX })); + await handleError( + getError({ error, prefix: PUBLICATIONS_FETCHING_ERROR_PREFIX }), + store.getState(), + ); return []; } }; diff --git a/src/components/FunctionalArea/Modal/PublicationsModal/utils/fetchElementData.ts b/src/components/FunctionalArea/Modal/PublicationsModal/utils/fetchElementData.ts index 3fea9bf7..3df91509 100644 --- a/src/components/FunctionalArea/Modal/PublicationsModal/utils/fetchElementData.ts +++ b/src/components/FunctionalArea/Modal/PublicationsModal/utils/fetchElementData.ts @@ -7,6 +7,7 @@ import { BioEntityContent, BioEntityResponse } from '@/types/models'; import { validateDataUsingZodSchema } from '@/utils/validateDataUsingZodSchema'; import { getError } from '@/utils/error-report/getError'; import { handleError } from '@/utils/error-report/errorReporting'; +import { store } from '@/redux/store'; export const fetchElementData = async ( searchQuery: string, @@ -25,7 +26,10 @@ export const fetchElementData = async ( return response.data.content[FIRST_ARRAY_ELEMENT]; } } catch (error) { - handleError(getError({ error, prefix: BIO_ENTITY_FETCHING_ERROR_PREFIX })); + await handleError( + getError({ error, prefix: BIO_ENTITY_FETCHING_ERROR_PREFIX }), + store.getState(), + ); } return undefined; diff --git a/src/components/Map/Drawer/AvailablePluginsDrawer/LoadPluginFromUrl/hooks/useLoadPluginFromUrl.ts b/src/components/Map/Drawer/AvailablePluginsDrawer/LoadPluginFromUrl/hooks/useLoadPluginFromUrl.ts index 5753d723..23f6c19e 100644 --- a/src/components/Map/Drawer/AvailablePluginsDrawer/LoadPluginFromUrl/hooks/useLoadPluginFromUrl.ts +++ b/src/components/Map/Drawer/AvailablePluginsDrawer/LoadPluginFromUrl/hooks/useLoadPluginFromUrl.ts @@ -4,6 +4,7 @@ import { ChangeEvent, useMemo, useState, KeyboardEvent } from 'react'; import { ENTER_KEY_CODE } from '@/constants/common'; import { getError } from '@/utils/error-report/getError'; import { handleError } from '@/utils/error-report/errorReporting'; +import { store } from '@/redux/store'; import { PLUGIN_LOADING_ERROR_PREFIX } from '../../AvailablePluginsDrawer.constants'; type UseLoadPluginReturnType = { @@ -43,7 +44,7 @@ export const useLoadPluginFromUrl = (): UseLoadPluginReturnType => { setPluginUrl(''); } catch (error) { - handleError(getError({ error, prefix: PLUGIN_LOADING_ERROR_PREFIX })); + await handleError(getError({ error, prefix: PLUGIN_LOADING_ERROR_PREFIX }), store.getState()); } finally { setIsLoading(false); } diff --git a/src/models/fixtures/javaStacktraceFixture.ts b/src/models/fixtures/javaStacktraceFixture.ts new file mode 100644 index 00000000..71ed655e --- /dev/null +++ b/src/models/fixtures/javaStacktraceFixture.ts @@ -0,0 +1,8 @@ +import { ZOD_SEED } from '@/constants'; +// eslint-disable-next-line import/no-extraneous-dependencies +import { createFixture } from 'zod-fixture'; +import { javaStacktraceSchema } from '@/models/javaStacktraceSchema'; + +export const javaStacktraceFixture = createFixture(javaStacktraceSchema, { + seed: ZOD_SEED, +}); diff --git a/src/models/javaStacktraceSchema.ts b/src/models/javaStacktraceSchema.ts new file mode 100644 index 00000000..4f9bb8c4 --- /dev/null +++ b/src/models/javaStacktraceSchema.ts @@ -0,0 +1,7 @@ +import { z } from 'zod'; + +export const javaStacktraceSchema = z.object({ + id: z.string(), + content: z.string(), + createdAt: z.string(), +}); diff --git a/src/redux/apiPath.ts b/src/redux/apiPath.ts index 7a46759f..0604e565 100644 --- a/src/redux/apiPath.ts +++ b/src/redux/apiPath.ts @@ -94,4 +94,5 @@ export const apiPath = { getSubmapConnections: (): string => `projects/${PROJECT_ID}/submapConnections/`, logout: (): string => `doLogout`, user: (login: string): string => `users/${login}`, + getStacktrace: (code: string): string => `stacktrace/${code}`, }; diff --git a/src/redux/middlewares/error.middleware.test.ts b/src/redux/middlewares/error.middleware.test.ts index a49e29f3..76362550 100644 --- a/src/redux/middlewares/error.middleware.test.ts +++ b/src/redux/middlewares/error.middleware.test.ts @@ -1,4 +1,5 @@ import { handleError } from '@/utils/error-report/errorReporting'; +import { store } from '@/redux/store'; import { errorMiddlewareListener } from './error.middleware'; jest.mock('../../utils/error-report/errorReporting', () => ({ @@ -25,8 +26,11 @@ describe('errorMiddlewareListener', () => { code: 'Error 2', }, }; - await errorMiddlewareListener(action); - expect(handleError).toHaveBeenCalledWith({ code: 'Error 2' }); + const { getState } = store; + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-expect-error + await errorMiddlewareListener(action, { getState }); + expect(handleError).toHaveBeenCalledWith({ code: 'Error 2' }, getState()); }); it('should handle error without message when action is rejected without value', async () => { @@ -42,8 +46,11 @@ describe('errorMiddlewareListener', () => { code: 'Error 3', }, }; - await errorMiddlewareListener(action); - expect(handleError).toHaveBeenCalledWith({ code: 'Error 3' }); + const { getState } = store; + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-expect-error + await errorMiddlewareListener(action, { getState }); + expect(handleError).toHaveBeenCalledWith({ code: 'Error 3' }, getState()); }); it('should not handle error when action is not rejected', async () => { @@ -55,7 +62,10 @@ describe('errorMiddlewareListener', () => { requestStatus: 'fulfilled', }, }; - await errorMiddlewareListener(action); + const { getState } = store; + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-expect-error + await errorMiddlewareListener(action, { getState }); expect(handleError).not.toHaveBeenCalled(); }); @@ -73,8 +83,14 @@ describe('errorMiddlewareListener', () => { message: 'Error message', }, }; - await errorMiddlewareListener(action); - expect(handleError).toHaveBeenCalledWith({ code: 'ERROR', message: 'Error message' }); + const { getState } = store; + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-expect-error + await errorMiddlewareListener(action, { getState }); + expect(handleError).toHaveBeenCalledWith( + { code: 'ERROR', message: 'Error message' }, + getState(), + ); }); it('should handle error with custom message when action payload is a string', async () => { @@ -91,7 +107,10 @@ describe('errorMiddlewareListener', () => { message: 'xyz', }, }; - await errorMiddlewareListener(action); - expect(handleError).toHaveBeenCalledWith({ code: 'ERROR', message: 'xyz' }); + const { getState } = store; + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-expect-error + await errorMiddlewareListener(action, { getState }); + expect(handleError).toHaveBeenCalledWith({ code: 'ERROR', message: 'xyz' }, getState()); }); }); diff --git a/src/redux/middlewares/error.middleware.ts b/src/redux/middlewares/error.middleware.ts index 906ddfc0..3466c17c 100644 --- a/src/redux/middlewares/error.middleware.ts +++ b/src/redux/middlewares/error.middleware.ts @@ -1,4 +1,4 @@ -import type { AppStartListening } from '@/redux/store'; +import type { AppListenerEffectAPI, AppStartListening } from '@/redux/store'; import { Action, createListenerMiddleware, isRejected } from '@reduxjs/toolkit'; import { handleError } from '@/utils/error-report/errorReporting'; @@ -6,9 +6,12 @@ export const errorListenerMiddleware = createListenerMiddleware(); const startListening = errorListenerMiddleware.startListening as AppStartListening; -export const errorMiddlewareListener = async (action: Action): Promise<void> => { +export const errorMiddlewareListener = async ( + action: Action, + { getState }: AppListenerEffectAPI, +): Promise<void> => { if (isRejected(action)) { - handleError(action.error); + await handleError(action.error, getState()); } }; diff --git a/src/types/models.ts b/src/types/models.ts index 0b6b9373..57a80ea1 100644 --- a/src/types/models.ts +++ b/src/types/models.ts @@ -62,6 +62,7 @@ import { targetSearchNameResult } from '@/models/targetSearchNameResult'; import { userPrivilegeSchema } from '@/models/userPrivilegesSchema'; import { z } from 'zod'; import { userSchema } from '@/models/userSchema'; +import { javaStacktraceSchema } from '@/models/javaStacktraceSchema'; export type Project = z.infer<typeof projectSchema>; export type OverviewImageView = z.infer<typeof overviewImageView>; @@ -120,3 +121,4 @@ export type MarkerSurface = z.infer<typeof markerSurfaceSchema>; export type MarkerLine = z.infer<typeof markerLineSchema>; export type MarkerWithPosition = z.infer<typeof markerWithPositionSchema>; export type Marker = z.infer<typeof markerSchema>; +export type JavaStacktrace = z.infer<typeof javaStacktraceSchema>; diff --git a/src/utils/error-report/errorReporting.test.ts b/src/utils/error-report/errorReporting.test.ts index a49607f5..7534585e 100644 --- a/src/utils/error-report/errorReporting.test.ts +++ b/src/utils/error-report/errorReporting.test.ts @@ -8,6 +8,8 @@ import { store } from '@/redux/store'; import { getConfiguration } from '@/redux/configuration/configuration.thunks'; import { configurationFixture } from '@/models/fixtures/configurationFixture'; import { userFixture } from '@/models/fixtures/userFixture'; +import { SerializedError } from '@reduxjs/toolkit'; +import { javaStacktraceFixture } from '@/models/fixtures/javaStacktraceFixture'; const mockedAxiosClient = mockNetworkResponse(); @@ -17,23 +19,23 @@ const CREDENTIALS = { }; describe('createErrorData', () => { - it('should add stacktrace', () => { - const error = createErrorData(new Error('hello')); + it('should add stacktrace', async () => { + const error = await createErrorData(new Error('hello'), store.getState()); expect(error.stacktrace).not.toEqual(''); }); - it('should add url', () => { - const error = createErrorData(new Error('hello')); + it('should add url', async () => { + const error = await createErrorData(new Error('hello'), store.getState()); expect(error.url).not.toBeNull(); }); - it('should add browser', () => { - const error = createErrorData(new Error('hello')); + it('should add browser', async () => { + const error = await createErrorData(new Error('hello'), store.getState()); expect(error.browser).not.toBeNull(); }); - it('should add guest login when not logged', () => { - const error = createErrorData(new Error('hello')); + it('should add guest login when not logged', async () => { + const error = await createErrorData(new Error('hello'), store.getState()); expect(error.login).toBe('anonymous'); }); @@ -42,13 +44,13 @@ describe('createErrorData', () => { mockedAxiosClient.onGet(apiPath.user(loginFixture.login)).reply(HttpStatusCode.Ok, userFixture); await store.dispatch(login(CREDENTIALS)); - const error = createErrorData(new Error('hello')); + const error = await createErrorData(new Error('hello'), store.getState()); expect(error.login).not.toBe('anonymous'); expect(error.login).toBe(loginFixture.login); }); - it('should add timestamp', () => { - const error = createErrorData(new Error()); + it('should add timestamp', async () => { + const error = await createErrorData(new Error(), store.getState()); expect(error.timestamp).not.toBeNull(); }); @@ -58,7 +60,7 @@ describe('createErrorData', () => { .reply(HttpStatusCode.Ok, configurationFixture); await store.dispatch(getConfiguration()); - const error = createErrorData(new Error()); + const error = await createErrorData(new Error(), store.getState()); expect(error.version).not.toBeNull(); }); @@ -67,14 +69,26 @@ describe('createErrorData', () => { mockedAxiosClient.onGet(apiPath.user(loginFixture.login)).reply(HttpStatusCode.Ok, userFixture); await store.dispatch(login(CREDENTIALS)); - const error = createErrorData(new Error()); + const error = await createErrorData(new Error(), store.getState()); expect(error.email).toBe(userFixture.email); }); it('email should be empty when not logged', async () => { mockedAxiosClient.onPost(apiPath.logout()).reply(HttpStatusCode.Ok, {}); await store.dispatch(logout()); - const error = createErrorData(new Error()); + const error = await createErrorData(new Error(), store.getState()); expect(error.email).toBeNull(); }); + + it('java stacktrace should be attached if error report provides info', async () => { + mockedAxiosClient + .onGet(apiPath.getStacktrace('dab932be-1e2e-45d7-b57a-aff30e2629e6')) + .reply(HttpStatusCode.Ok, javaStacktraceFixture); + + const error: SerializedError = { + code: 'dab932be-1e2e-45d7-b57a-aff30e2629e6', + }; + const errorData = await createErrorData(error, store.getState()); + expect(errorData.javaStacktrace).not.toBeNull(); + }); }); diff --git a/src/utils/error-report/errorReporting.ts b/src/utils/error-report/errorReporting.ts index 46538a86..40488af9 100644 --- a/src/utils/error-report/errorReporting.ts +++ b/src/utils/error-report/errorReporting.ts @@ -1,52 +1,75 @@ import { ErrorData } from '@/utils/error-report/ErrorData'; import { SerializedError } from '@reduxjs/toolkit'; -// eslint-disable-next-line import/no-cycle -import { store } from '@/redux/store'; import { ONE_THOUSAND } from '@/constants/common'; +import { + UNKNOWN_AXIOS_ERROR_CODE, + UNKNOWN_ERROR, +} from '@/utils/getErrorMessage/getErrorMessage.constants'; +import { axiosInstance } from '@/services/api/utils/axiosInstance'; +import { JavaStacktrace } from '@/types/models'; +import { apiPath } from '@/redux/apiPath'; +import type { RootState } from '@/redux/store'; -export const createErrorData = (error: Error | SerializedError | undefined): ErrorData => { +export const createErrorData = async ( + error: Error | SerializedError | undefined, + state: RootState, +): Promise<ErrorData> => { let stacktrace = ''; if (error !== undefined) { stacktrace = error.stack !== undefined ? error.stack : ''; } - let { login } = store.getState().user; + let login = null; + let userData = null; + + if (state.user) { + login = state.user.login; + userData = state.user.userData; + } if (!login) { login = 'anonymous'; } - const { userData } = store.getState().user; let email = null; if (userData) { email = userData.email; } - const configuration = store.getState().configuration.main.data; + + const configuration = state?.configuration?.main?.data; const version = configuration ? configuration.version : null; + let javaStacktrace = null; + if (error !== undefined && 'code' in error) { + const { code } = error; + if (code && code !== UNKNOWN_ERROR && code !== UNKNOWN_AXIOS_ERROR_CODE) { + try { + javaStacktrace = (await axiosInstance.get<JavaStacktrace>(apiPath.getStacktrace(code))).data + .content; + } catch (e) { + // eslint-disable-next-line no-console + console.log('Problem with fetching javaStacktrace', e); + } + } + } + return { - url: window.location.href, + url: window?.location?.href, login, browser: navigator.userAgent, comment: null, email, - javaStacktrace: null, // TODO + javaStacktrace, stacktrace, timestamp: Math.floor(+new Date() / ONE_THOUSAND), version, }; }; -export const handleError = (error: Error | SerializedError | undefined): void => { - const errorData = createErrorData(error); +export const handleError = async ( + error: Error | SerializedError | undefined, + state: RootState, +): Promise<void> => { + const errorData = await createErrorData(error, state); // eslint-disable-next-line no-console console.log(errorData); }; - -export const initializeErrorReporting = (): void => { - if (typeof window !== 'undefined') { - window.onerror = (msg, url, lineNo, columnNo, error): boolean => { - handleError(error); - return true; - }; - } -}; diff --git a/src/utils/error-report/getErrorCode.ts b/src/utils/error-report/getErrorCode.ts index 30f63f62..8a453688 100644 --- a/src/utils/error-report/getErrorCode.ts +++ b/src/utils/error-report/getErrorCode.ts @@ -6,8 +6,19 @@ import { export const getErrorCode = (error: unknown): string => { if (axios.isAxiosError(error)) { - const { code } = error; - return code || UNKNOWN_AXIOS_ERROR_CODE; + let code = UNKNOWN_AXIOS_ERROR_CODE; + try { + if (error.response) { + if (typeof error.response.data === 'object') { + code = error.response.data['error-id']; + } else if (typeof error.response.data === 'string') { + code = JSON.parse(error.response.data)['error-id']; + } + } + } catch (e) { + code = UNKNOWN_AXIOS_ERROR_CODE; + } + return code; } return UNKNOWN_ERROR; }; -- GitLab