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 { 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 { FeedbackNPS } from 'component/feedback/FeedbackNPS/FeedbackNPS';
import { LayoutPicker } from 'component/layout/LayoutPicker/LayoutPicker';
@ -51,66 +49,64 @@ export const App = () => {
}, [authDetails, user]);
return (
<ErrorBoundary FallbackComponent={LayoutError}>
<SWRProvider>
<Suspense fallback={<Loader />}>
<ConditionallyRender
condition={!hasFetchedAuth}
show={<Loader />}
elseShow={
<Demo>
<>
<ConditionallyRender
condition={Boolean(
uiConfig?.maintenanceMode,
)}
show={<MaintenanceBanner />}
/>
<LicenseBanner />
<ExternalBanners />
<InternalBanners />
<EdgeUpgradeBanner />
<StyledContainer>
<ToastRenderer />
<Routes>
{availableRoutes.map((route) => (
<Route
key={route.path}
path={route.path}
element={
<LayoutPicker
isStandalone={
route.isStandalone ===
true
}
>
<ProtectedRoute
route={route}
/>
</LayoutPicker>
}
/>
))}
<SWRProvider>
<Suspense fallback={<Loader />}>
<ConditionallyRender
condition={!hasFetchedAuth}
show={<Loader />}
elseShow={
<Demo>
<>
<ConditionallyRender
condition={Boolean(
uiConfig?.maintenanceMode,
)}
show={<MaintenanceBanner />}
/>
<LicenseBanner />
<ExternalBanners />
<InternalBanners />
<EdgeUpgradeBanner />
<StyledContainer>
<ToastRenderer />
<Routes>
{availableRoutes.map((route) => (
<Route
path='/'
element={<InitialRedirect />}
key={route.path}
path={route.path}
element={
<LayoutPicker
isStandalone={
route.isStandalone ===
true
}
>
<ProtectedRoute
route={route}
/>
</LayoutPicker>
}
/>
<Route
path='*'
element={<NotFound />}
/>
</Routes>
))}
<Route
path='/'
element={<InitialRedirect />}
/>
<Route
path='*'
element={<NotFound />}
/>
</Routes>
<FeedbackNPS openUrl='http://feedback.unleash.run' />
<FeedbackNPS openUrl='http://feedback.unleash.run' />
<SplashPageRedirect />
</StyledContainer>
</>
</Demo>
}
/>
</Suspense>
</SWRProvider>
</ErrorBoundary>
<SplashPageRedirect />
</StyledContainer>
</>
</Demo>
}
/>
</Suspense>
</SWRProvider>
);
};

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,35 +20,59 @@ import { FeedbackProvider } from 'component/feedbackNew/FeedbackProvider';
import { PlausibleProvider } from 'component/providers/PlausibleProvider/PlausibleProvider';
import { Error as LayoutError } from './component/layout/Error/Error';
import { ErrorBoundary } from 'react-error-boundary';
import { useRecordUIErrorApi } from 'hooks/api/actions/useRecordUIErrorApi/useRecordUiErrorApi';
import { useEffect } from 'react';
window.global ||= window;
ReactDOM.render(
<UIProviderContainer>
<AccessProvider>
<BrowserRouter basename={basePath}>
<QueryParamProvider adapter={ReactRouter6Adapter}>
<ThemeProvider>
<AnnouncerProvider>
<ErrorBoundary FallbackComponent={LayoutError}>
<PlausibleProvider>
<FeedbackProvider>
<FeedbackCESProvider>
<StickyProvider>
<InstanceStatus>
<ScrollTop />
<App />
</InstanceStatus>
</StickyProvider>
</FeedbackCESProvider>
</FeedbackProvider>
</PlausibleProvider>
</ErrorBoundary>
</AnnouncerProvider>
</ThemeProvider>
</QueryParamProvider>
</BrowserRouter>
</AccessProvider>
</UIProviderContainer>,
document.getElementById('app'),
);
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>
<AccessProvider>
<BrowserRouter basename={basePath}>
<QueryParamProvider adapter={ReactRouter6Adapter}>
<ThemeProvider>
<AnnouncerProvider>
<ErrorBoundary
FallbackComponent={LayoutError}
onError={sendErrorToApi}
>
<PlausibleProvider>
<FeedbackProvider>
<FeedbackCESProvider>
<StickyProvider>
<InstanceStatus>
<ScrollTop />
<App />
</InstanceStatus>
</StickyProvider>
</FeedbackCESProvider>
</FeedbackProvider>
</PlausibleProvider>
</ErrorBoundary>
</AnnouncerProvider>
</ThemeProvider>
</QueryParamProvider>
</BrowserRouter>
</AccessProvider>
</UIProviderContainer>
);
};
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,
inactiveUsersSchema,
idsSchema,
recordUiErrorSchema,
} from './spec';
import { IServerOption } from '../types';
import { mapValues, omitKeys } from '../util';
@ -415,6 +416,7 @@ export const schemas: UnleashSchemas = {
featureSearchResponseSchema,
inactiveUserSchema,
inactiveUsersSchema,
recordUiErrorSchema,
};
// 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 './inactive-user-schema';
export * from './inactive-users-schema';
export * from './record-ui-error-schema';
export * from './project-application-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 FeatureSearchController from '../../features/feature-search/feature-search-controller';
import { InactiveUsersController } from '../../users/inactive/inactive-users-controller';
import { UiObservabilityController } from '../../features/ui-observability-controller/ui-observability-controller';
class AdminApi extends Controller {
constructor(config: IUnleashConfig, services: IUnleashServices, db: Db) {
@ -163,6 +164,11 @@ class AdminApi extends Controller {
'/search',
new FeatureSearchController(config, services).router,
);
this.app.use(
'/record-ui-error',
new UiObservabilityController(config, services).router,
);
}
}