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:
parent
4972b9686c
commit
260ef70309
@ -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>
|
||||
);
|
||||
};
|
||||
|
@ -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,
|
||||
};
|
||||
};
|
@ -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'));
|
||||
|
@ -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();
|
||||
}
|
||||
}
|
@ -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.
|
||||
|
@ -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';
|
||||
|
21
src/lib/openapi/spec/record-ui-error-schema.ts
Normal file
21
src/lib/openapi/spec/record-ui-error-schema.ts
Normal 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>;
|
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user