mirror of
https://gitee.com/nocobase/nocobase.git
synced 2025-05-05 05:29:26 +08:00
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:
parent
e842cd4cab
commit
9b7abf7295
@ -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 #################
|
||||
|
@ -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",
|
||||
|
@ -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.": "支持单个或批量上传",
|
||||
|
@ -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>,
|
||||
]}
|
||||
>
|
||||
|
@ -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>
|
||||
);
|
||||
};
|
@ -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>
|
||||
);
|
||||
};
|
@ -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();
|
||||
|
@ -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();
|
@ -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();
|
@ -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>
|
||||
);
|
||||
};
|
@ -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>
|
||||
);
|
||||
};
|
@ -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>
|
||||
|
@ -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>
|
||||
|
@ -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);
|
||||
|
@ -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();
|
||||
},
|
||||
},
|
||||
};
|
||||
|
Loading…
x
Reference in New Issue
Block a user