feat(logger): support for collecting debug informations when rendering failed (#4524)

* feat(logger): support for collecting debug informations when rendering failed

* chore: update

* feat: add location information

* fix: test

* fix: test

* fix: tests

* fix: bug
This commit is contained in:
YANG QIA 2024-06-04 12:10:17 +08:00 committed by GitHub
parent e842cd4cab
commit 9b7abf7295
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 184 additions and 112 deletions

View File

@ -20,13 +20,13 @@ API_BASE_URL=
# console | file | dailyRotateFile
LOGGER_TRANSPORT=
LOGGER_BASE_PATH=storage/logs
# error | warn | info | debug
# error | warn | info | debug | trace
LOGGER_LEVEL=
# If LOGGER_TRANSPORT is dailyRotateFile and using days, add 'd' as the suffix.
LOGGER_MAX_FILES=
# add 'k', 'm', 'g' as the suffix.
LOGGER_MAX_SIZE=
# json | splitter, split by '|' character
# console | json | logfmt | delimiter
LOGGER_FORMAT=
################# DATABASE #################

View File

@ -764,6 +764,7 @@
"App error": "App error",
"Feedback": "Feedback",
"Try again": "Try again",
"Download logs": "Download logs",
"Data template": "Data template",
"Duplicate": "Duplicate",
"Duplicating": "Duplicating",

View File

@ -795,6 +795,7 @@
"Render Failed": "渲染失败",
"Feedback": "反馈问题",
"Try again": "重试一下",
"Download logs": "下载日志",
"Download": "下载",
"Click or drag file to this area to upload": "点击或拖拽文件到此区域上传",
"Support for a single or bulk upload.": "支持单个或批量上传",

View File

@ -9,17 +9,57 @@
import { Button, Result, Typography } from 'antd';
import React, { FC } from 'react';
import { FallbackProps, useErrorBoundary } from 'react-error-boundary';
import { FallbackProps } from 'react-error-boundary';
import { Trans, useTranslation } from 'react-i18next';
import { ErrorFallbackModal } from './ErrorFallbackModal';
import { useAPIClient } from '../../../api-client';
import { useFieldSchema } from '@formily/react';
import { useLocation } from 'react-router-dom';
const { Paragraph, Text, Link } = Typography;
export const useDownloadLogs = (error: any, data: Record<string, any> = {}) => {
const location = useLocation();
const [loading, setLoading] = React.useState(false);
const api = useAPIClient();
return {
loading,
download: async () => {
setLoading(true);
try {
const res = await api.request({
url: 'logger:collect',
method: 'post',
responseType: 'blob',
data: {
error: {
message: error.message,
stack: error.stack,
},
location,
...data,
},
});
const url = window.URL.createObjectURL(new Blob([res.data], { type: 'application/gzip' }));
const link = document.createElement('a');
link.setAttribute('href', url);
link.setAttribute('download', 'logs.tar.gz');
link.click();
link.remove();
} catch (err) {
console.log(err);
}
setLoading(false);
},
};
};
export const ErrorFallback: FC<FallbackProps> & {
Modal: FC<FallbackProps>;
} = ({ error }) => {
const { resetBoundary } = useErrorBoundary();
const schema = useFieldSchema();
const { t } = useTranslation();
const { loading, download } = useDownloadLogs(error, { schema });
const subTitle = (
<Trans>
@ -41,8 +81,8 @@ export const ErrorFallback: FC<FallbackProps> & {
<Button type="primary" key="feedback" href="https://github.com/nocobase/nocobase/issues" target="_blank">
{t('Feedback')}
</Button>,
<Button key="try" onClick={resetBoundary}>
{t('Try again')}
<Button key="log" loading={loading} onClick={download}>
{t('Download logs')}
</Button>,
]}
>

View File

@ -1,15 +0,0 @@
import React from 'react';
import { ErrorBoundary } from 'react-error-boundary';
import { ErrorFallback } from '../../ErrorFallback';
const App = () => {
throw new Error('error message');
};
export default () => {
return (
<ErrorBoundary FallbackComponent={ErrorFallback} onError={console.error}>
<App />
</ErrorBoundary>
);
};

View File

@ -1,24 +0,0 @@
/**
* This file is part of the NocoBase (R) project.
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
* Authors: NocoBase Team.
*
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import React from 'react';
import { ErrorBoundary } from 'react-error-boundary';
import { ErrorFallback } from '../../ErrorFallback';
const App = () => {
throw new Error('error message');
};
export default () => {
return (
<ErrorBoundary FallbackComponent={ErrorFallback.Modal} onError={console.error}>
<App />
</ErrorBoundary>
);
};

View File

@ -7,19 +7,19 @@
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import { render, screen, userEvent, waitFor } from '@nocobase/test/client';
import { renderApp, screen, userEvent, waitFor } from '@nocobase/test/client';
import React from 'react';
import App1 from './components/basic';
import InlineApp from './components/modal';
import App1 from '../demos/basic';
import ModalApp from '../demos/modal';
describe('ErrorFallback', () => {
it('should render correctly', () => {
render(<App1 />);
it('should render correctly', async () => {
await renderApp(<App1 />);
expect(screen.getByText(/render failed/i)).toBeInTheDocument();
expect(screen.getByText(/this is likely a nocobase internals bug\. please open an issue at/i)).toBeInTheDocument();
expect(screen.getByRole('link', { name: /feedback/i })).toBeInTheDocument();
expect(screen.getByText(/try again/i)).toBeInTheDocument();
expect(screen.getByText(/download logs/i)).toBeInTheDocument();
expect(screen.getByText(/error: error message/i)).toBeInTheDocument();
// 底部复制按钮
@ -27,7 +27,7 @@ describe('ErrorFallback', () => {
});
it('should render inline correctly', async () => {
render(<InlineApp />);
await renderApp(<ModalApp />);
expect(screen.getByText(/Error: error message/i)).toBeInTheDocument();
expect(document.querySelector('.ant-typography-copy')).toBeInTheDocument();

View File

@ -0,0 +1,27 @@
import React from 'react';
import { ErrorBoundary } from 'react-error-boundary';
import { ErrorFallback } from '../ErrorFallback';
import { Plugin } from '@nocobase/client';
import { mockApp } from '@nocobase/client/demo-utils';
const App = () => {
throw new Error('error message');
};
const Demo = () => {
return (
<ErrorBoundary FallbackComponent={ErrorFallback} onError={console.error}>
<App />
</ErrorBoundary>
);
};
class DemoPlugin extends Plugin {
async load() {
this.app.router.add('root', { path: '/', Component: Demo });
}
}
const app = mockApp({ plugins: [DemoPlugin] });
export default app.getRootComponent();

View File

@ -0,0 +1,27 @@
import React from 'react';
import { ErrorBoundary } from 'react-error-boundary';
import { ErrorFallback } from '../ErrorFallback';
import { mockApp } from '@nocobase/client/demo-utils';
import { Plugin } from '@nocobase/client';
const App = () => {
throw new Error('error message');
};
const Demo = () => {
return (
<ErrorBoundary FallbackComponent={ErrorFallback.Modal} onError={console.error}>
<App />
</ErrorBoundary>
);
};
class DemoPlugin extends Plugin {
async load() {
this.app.router.add('root', { path: '/', Component: Demo });
}
}
const app = mockApp({ plugins: [DemoPlugin] });
export default app.getRootComponent();

View File

@ -1,26 +0,0 @@
import React from 'react';
import { ErrorBoundary } from 'react-error-boundary';
import { ErrorFallback } from '@nocobase/client';
import { Button } from 'antd';
const App = () => {
const [showError, setShowError] = React.useState(false);
if (showError) {
throw new Error('error message');
}
return (
<Button danger onClick={() => setShowError(true)}>
show error
</Button>
);
};
export default () => {
return (
<ErrorBoundary FallbackComponent={ErrorFallback} onError={console.error}>
<App />
</ErrorBoundary>
);
};

View File

@ -1,26 +0,0 @@
import React from 'react';
import { ErrorBoundary } from 'react-error-boundary';
import { ErrorFallback } from '@nocobase/client';
import { Button } from 'antd';
const App = () => {
const [showError, setShowError] = React.useState(false);
if (showError) {
throw new Error('error message');
}
return (
<Button danger onClick={() => setShowError(true)}>
show error
</Button>
);
};
export default () => {
return (
<ErrorBoundary FallbackComponent={ErrorFallback.Modal} onError={console.error}>
<App />
</ErrorBoundary>
);
};

View File

@ -6,8 +6,8 @@ Based on the [react-error-boundary](https://github.com/bvaughn/react-error-bound
## Basic
<code src="./demos/new-demos/basic.tsx"></code>
<code src="./demos/basic.tsx"></code>
## Modal
<code src="./demos/new-demos/modal.tsx"></code>
<code src="./demos/modal.tsx"></code>

View File

@ -8,8 +8,8 @@
## Basic
<code src="./demos/new-demos/basic.tsx"></code>
<code src="./demos/basic.tsx"></code>
## Modal
<code src="./demos/new-demos/modal.tsx"></code>
<code src="./demos/modal.tsx"></code>

View File

@ -238,9 +238,15 @@ export class Database extends EventEmitter implements AsyncEmitter {
}
this.options = opts;
this.logger.debug(`create database instance: ${safeJsonStringify(this.options)}`, {
databaseInstanceId: this.instanceId,
});
this.logger.debug(
`create database instance: ${safeJsonStringify(
// remove sensitive information
lodash.omit(this.options, ['storage', 'host', 'password']),
)}`,
{
databaseInstanceId: this.instanceId,
},
);
const sequelizeOptions = this.sequelizeOptions(this.options);
this.sequelize = new Sequelize(sequelizeOptions);

View File

@ -9,11 +9,48 @@
import { Context, Next } from '@nocobase/actions';
import { getLoggerFilePath } from '@nocobase/logger';
import { readdir } from 'fs/promises';
import { readdir, stat } from 'fs/promises';
import { join } from 'path';
import stream from 'stream';
import { pack } from 'tar-fs';
import zlib from 'zlib';
import lodash from 'lodash';
const envVars = [
'APP_ENV',
'APP_PORT',
'API_BASE_PATH',
'API_BASE_URL',
'DB_DIALECT',
'DB_TABLE_PREFIX',
'DB_UNDERSCORED',
'DB_TIMEZONE',
'DB_LOGGING',
'LOGGER_TRANSPORT',
'LOGGER_LEVEL',
];
const getLastestLogs = async (path: string) => {
const files = await readdir(path);
const prefixes = ['request', 'sql', 'system', 'system_error'];
const logs = files.filter((file) => file.endsWith('.log') && prefixes.some((prefix) => file.startsWith(prefix)));
if (!logs.length) {
return [];
}
const mtime = async (file: string) => {
const info = await stat(join(path, file));
return [file, info.mtime];
};
const logsWithTime = await Promise.all(logs.map(mtime));
const getLatestLog = (prefix: string) => {
const logs = logsWithTime.filter((file) => (file[0] as string).startsWith(prefix));
if (!logs.length) {
return null;
}
return logs.reduce((a, b) => (a[1] > b[1] ? a : b))[0] as string;
};
return prefixes.map(getLatestLog).filter((file) => file);
};
const tarFiles = (path: string, files: string[]): Promise<any> => {
return new Promise((resolve, reject) => {
@ -97,5 +134,29 @@ export default {
}
await next();
},
collect: async (ctx: Context, next: Next) => {
const { error, ...info } = ctx.action.params.values || {};
const { message, ...e } = error || {};
ctx.log.error({ message: `Diagnosis, frontend error, ${message}`, ...e });
ctx.log.error(`Diagnostic information`, info);
ctx.log.error('Diagnosis, environment variables', lodash.pick(process.env, envVars));
const path = getLoggerFilePath(ctx.app.name || 'main');
const files = await getLastestLogs(path);
if (!files.length) {
ctx.throw(
404,
ctx.t('No log files found. Please check the LOGGER_TRANSPORTS environment variable configuration.'),
);
}
try {
ctx.attachment('logs.tar.gz');
ctx.body = await tarFiles(path, files);
} catch (err) {
ctx.log.error(`download error: ${err.message}`, { files, err: err.stack });
ctx.throw(500, ctx.t('Download logs failed.'));
}
await next();
},
},
};