1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-06-04 01:18:20 +02:00

Feat/UI error observability (#6169)

This PR adds an endpoint to Unleash that accepts an error message and
option error stack and logs it as an error. This allows us to leverage
errors in logs observability to catch UI errors consistently.

Considered a test, but this endpoint only accepts and logs input, so I'm
not sure how useful it would be.
This commit is contained in:
Fredrik Strand Oseberg 2024-02-09 13:07:44 +01:00 committed by GitHub
parent 4972b9686c
commit 260ef70309
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 226 additions and 88 deletions

View File

@ -1,7 +1,5 @@
import { Suspense, useEffect } from 'react'; import { Suspense, useEffect } from 'react';
import { Route, Routes } from 'react-router-dom'; import { Route, Routes } from 'react-router-dom';
import { ErrorBoundary } from 'react-error-boundary';
import { Error as LayoutError } from 'component/layout/Error/Error';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender'; import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
import { FeedbackNPS } from 'component/feedback/FeedbackNPS/FeedbackNPS'; import { FeedbackNPS } from 'component/feedback/FeedbackNPS/FeedbackNPS';
import { LayoutPicker } from 'component/layout/LayoutPicker/LayoutPicker'; import { LayoutPicker } from 'component/layout/LayoutPicker/LayoutPicker';
@ -51,7 +49,6 @@ export const App = () => {
}, [authDetails, user]); }, [authDetails, user]);
return ( return (
<ErrorBoundary FallbackComponent={LayoutError}>
<SWRProvider> <SWRProvider>
<Suspense fallback={<Loader />}> <Suspense fallback={<Loader />}>
<ConditionallyRender <ConditionallyRender
@ -111,6 +108,5 @@ export const App = () => {
/> />
</Suspense> </Suspense>
</SWRProvider> </SWRProvider>
</ErrorBoundary>
); );
}; };

View File

@ -0,0 +1,31 @@
import useAPI from '../useApi/useApi';
export interface RecordUIErrorSchema {
errorMessage: string;
errorStack: string;
}
export const useRecordUIErrorApi = () => {
const { makeRequest, createRequest, errors, loading } = useAPI({
propagateErrors: true,
});
const recordUiError = async (
payload: RecordUIErrorSchema,
): Promise<number> => {
const path = `api/admin/record-ui-error`;
const req = createRequest(path, {
method: 'POST',
body: JSON.stringify(payload),
});
const res = await makeRequest(req.caller, req.id);
return res.status;
};
return {
loading,
errors,
recordUiError,
};
};

View File

@ -20,17 +20,39 @@ import { FeedbackProvider } from 'component/feedbackNew/FeedbackProvider';
import { PlausibleProvider } from 'component/providers/PlausibleProvider/PlausibleProvider'; import { PlausibleProvider } from 'component/providers/PlausibleProvider/PlausibleProvider';
import { Error as LayoutError } from './component/layout/Error/Error'; import { Error as LayoutError } from './component/layout/Error/Error';
import { ErrorBoundary } from 'react-error-boundary'; import { ErrorBoundary } from 'react-error-boundary';
import { useRecordUIErrorApi } from 'hooks/api/actions/useRecordUIErrorApi/useRecordUiErrorApi';
import { useEffect } from 'react';
window.global ||= window; window.global ||= window;
ReactDOM.render( const ApplicationRoot = () => {
const { recordUiError } = useRecordUIErrorApi();
const sendErrorToApi = async (
error: Error,
info: { componentStack: string },
) => {
try {
await recordUiError({
errorMessage: error.message,
errorStack: error.stack || '',
});
} catch (e) {
console.error('Unable to log error');
}
};
return (
<UIProviderContainer> <UIProviderContainer>
<AccessProvider> <AccessProvider>
<BrowserRouter basename={basePath}> <BrowserRouter basename={basePath}>
<QueryParamProvider adapter={ReactRouter6Adapter}> <QueryParamProvider adapter={ReactRouter6Adapter}>
<ThemeProvider> <ThemeProvider>
<AnnouncerProvider> <AnnouncerProvider>
<ErrorBoundary FallbackComponent={LayoutError}> <ErrorBoundary
FallbackComponent={LayoutError}
onError={sendErrorToApi}
>
<PlausibleProvider> <PlausibleProvider>
<FeedbackProvider> <FeedbackProvider>
<FeedbackCESProvider> <FeedbackCESProvider>
@ -49,6 +71,8 @@ ReactDOM.render(
</QueryParamProvider> </QueryParamProvider>
</BrowserRouter> </BrowserRouter>
</AccessProvider> </AccessProvider>
</UIProviderContainer>, </UIProviderContainer>
document.getElementById('app'), );
); };
ReactDOM.render(<ApplicationRoot />, document.getElementById('app'));

View File

@ -0,0 +1,57 @@
import { Request, Response } from 'express';
import Controller from '../../routes/controller';
import { NONE } from '../../types/permissions';
import { IUnleashConfig } from '../../types/option';
import { IUnleashServices } from '../../types/services';
import { Logger } from '../../logger';
import {
emptyResponse,
getStandardResponses,
} from '../../openapi/util/standard-responses';
import { createRequestSchema } from '../../openapi';
const version = 1;
export class UiObservabilityController extends Controller {
private logger: Logger;
constructor(
config: IUnleashConfig,
{ openApiService }: Pick<IUnleashServices, 'openApiService'>,
) {
super(config);
this.logger = config.getLogger('/admin-api/ui-observability.js');
this.route({
method: 'post',
path: '',
handler: this.recordUiError,
permission: NONE,
middleware: [
openApiService.validPath({
tags: ['Admin UI'],
operationId: 'uiObservability',
summary: 'Accepts errors from the UI client',
description:
'This endpoint accepts error reports from the UI client, so that we can add observability on UI errors.',
requestBody: createRequestSchema('recordUiErrorSchema'),
responses: {
204: emptyResponse,
...getStandardResponses(401, 403),
},
}),
],
});
}
async recordUiError(req: Request, res: Response): Promise<void> {
this.logger.error(
`UI Observability Error: ${req.body.errorMessage}`,
req.body.errorStack,
);
res.status(204).end();
}
}

View File

@ -174,6 +174,7 @@ import {
inactiveUserSchema, inactiveUserSchema,
inactiveUsersSchema, inactiveUsersSchema,
idsSchema, idsSchema,
recordUiErrorSchema,
} from './spec'; } from './spec';
import { IServerOption } from '../types'; import { IServerOption } from '../types';
import { mapValues, omitKeys } from '../util'; import { mapValues, omitKeys } from '../util';
@ -415,6 +416,7 @@ export const schemas: UnleashSchemas = {
featureSearchResponseSchema, featureSearchResponseSchema,
inactiveUserSchema, inactiveUserSchema,
inactiveUsersSchema, inactiveUsersSchema,
recordUiErrorSchema,
}; };
// Remove JSONSchema keys that would result in an invalid OpenAPI spec. // Remove JSONSchema keys that would result in an invalid OpenAPI spec.

View File

@ -175,5 +175,6 @@ export * from './feature-type-count-schema';
export * from './feature-search-response-schema'; export * from './feature-search-response-schema';
export * from './inactive-user-schema'; export * from './inactive-user-schema';
export * from './inactive-users-schema'; export * from './inactive-users-schema';
export * from './record-ui-error-schema';
export * from './project-application-schema'; export * from './project-application-schema';
export * from './project-applications-schema'; export * from './project-applications-schema';

View File

@ -0,0 +1,21 @@
import { FromSchema } from 'json-schema-to-ts';
export const recordUiErrorSchema = {
$id: '#/components/schemas/recordUiErrorSchema',
type: 'object',
components: {},
required: ['errorMessage'],
description: 'An object representing an error from the UI',
properties: {
errorMessage: {
type: 'string',
description: 'The error message',
},
errorStack: {
type: 'string',
description: 'The stack trace of the error',
},
},
} as const;
export type RecordUiErrorSchema = FromSchema<typeof recordUiErrorSchema>;

View File

@ -35,6 +35,7 @@ import ExportImportController from '../../features/export-import-toggles/export-
import { SegmentsController } from '../../features/segment/segment-controller'; import { SegmentsController } from '../../features/segment/segment-controller';
import FeatureSearchController from '../../features/feature-search/feature-search-controller'; import FeatureSearchController from '../../features/feature-search/feature-search-controller';
import { InactiveUsersController } from '../../users/inactive/inactive-users-controller'; import { InactiveUsersController } from '../../users/inactive/inactive-users-controller';
import { UiObservabilityController } from '../../features/ui-observability-controller/ui-observability-controller';
class AdminApi extends Controller { class AdminApi extends Controller {
constructor(config: IUnleashConfig, services: IUnleashServices, db: Db) { constructor(config: IUnleashConfig, services: IUnleashServices, db: Db) {
@ -163,6 +164,11 @@ class AdminApi extends Controller {
'/search', '/search',
new FeatureSearchController(config, services).router, new FeatureSearchController(config, services).router,
); );
this.app.use(
'/record-ui-error',
new UiObservabilityController(config, services).router,
);
} }
} }