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
|
# console | file | dailyRotateFile
|
||||||
LOGGER_TRANSPORT=
|
LOGGER_TRANSPORT=
|
||||||
LOGGER_BASE_PATH=storage/logs
|
LOGGER_BASE_PATH=storage/logs
|
||||||
# error | warn | info | debug
|
# error | warn | info | debug | trace
|
||||||
LOGGER_LEVEL=
|
LOGGER_LEVEL=
|
||||||
# If LOGGER_TRANSPORT is dailyRotateFile and using days, add 'd' as the suffix.
|
# If LOGGER_TRANSPORT is dailyRotateFile and using days, add 'd' as the suffix.
|
||||||
LOGGER_MAX_FILES=
|
LOGGER_MAX_FILES=
|
||||||
# add 'k', 'm', 'g' as the suffix.
|
# add 'k', 'm', 'g' as the suffix.
|
||||||
LOGGER_MAX_SIZE=
|
LOGGER_MAX_SIZE=
|
||||||
# json | splitter, split by '|' character
|
# console | json | logfmt | delimiter
|
||||||
LOGGER_FORMAT=
|
LOGGER_FORMAT=
|
||||||
|
|
||||||
################# DATABASE #################
|
################# DATABASE #################
|
||||||
|
@ -764,6 +764,7 @@
|
|||||||
"App error": "App error",
|
"App error": "App error",
|
||||||
"Feedback": "Feedback",
|
"Feedback": "Feedback",
|
||||||
"Try again": "Try again",
|
"Try again": "Try again",
|
||||||
|
"Download logs": "Download logs",
|
||||||
"Data template": "Data template",
|
"Data template": "Data template",
|
||||||
"Duplicate": "Duplicate",
|
"Duplicate": "Duplicate",
|
||||||
"Duplicating": "Duplicating",
|
"Duplicating": "Duplicating",
|
||||||
|
@ -795,6 +795,7 @@
|
|||||||
"Render Failed": "渲染失败",
|
"Render Failed": "渲染失败",
|
||||||
"Feedback": "反馈问题",
|
"Feedback": "反馈问题",
|
||||||
"Try again": "重试一下",
|
"Try again": "重试一下",
|
||||||
|
"Download logs": "下载日志",
|
||||||
"Download": "下载",
|
"Download": "下载",
|
||||||
"Click or drag file to this area to upload": "点击或拖拽文件到此区域上传",
|
"Click or drag file to this area to upload": "点击或拖拽文件到此区域上传",
|
||||||
"Support for a single or bulk upload.": "支持单个或批量上传",
|
"Support for a single or bulk upload.": "支持单个或批量上传",
|
||||||
|
@ -9,17 +9,57 @@
|
|||||||
|
|
||||||
import { Button, Result, Typography } from 'antd';
|
import { Button, Result, Typography } from 'antd';
|
||||||
import React, { FC } from 'react';
|
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 { Trans, useTranslation } from 'react-i18next';
|
||||||
import { ErrorFallbackModal } from './ErrorFallbackModal';
|
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;
|
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> & {
|
export const ErrorFallback: FC<FallbackProps> & {
|
||||||
Modal: FC<FallbackProps>;
|
Modal: FC<FallbackProps>;
|
||||||
} = ({ error }) => {
|
} = ({ error }) => {
|
||||||
const { resetBoundary } = useErrorBoundary();
|
const schema = useFieldSchema();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const { loading, download } = useDownloadLogs(error, { schema });
|
||||||
|
|
||||||
const subTitle = (
|
const subTitle = (
|
||||||
<Trans>
|
<Trans>
|
||||||
@ -41,8 +81,8 @@ export const ErrorFallback: FC<FallbackProps> & {
|
|||||||
<Button type="primary" key="feedback" href="https://github.com/nocobase/nocobase/issues" target="_blank">
|
<Button type="primary" key="feedback" href="https://github.com/nocobase/nocobase/issues" target="_blank">
|
||||||
{t('Feedback')}
|
{t('Feedback')}
|
||||||
</Button>,
|
</Button>,
|
||||||
<Button key="try" onClick={resetBoundary}>
|
<Button key="log" loading={loading} onClick={download}>
|
||||||
{t('Try again')}
|
{t('Download logs')}
|
||||||
</Button>,
|
</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.
|
* 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 React from 'react';
|
||||||
import App1 from './components/basic';
|
import App1 from '../demos/basic';
|
||||||
import InlineApp from './components/modal';
|
import ModalApp from '../demos/modal';
|
||||||
|
|
||||||
describe('ErrorFallback', () => {
|
describe('ErrorFallback', () => {
|
||||||
it('should render correctly', () => {
|
it('should render correctly', async () => {
|
||||||
render(<App1 />);
|
await renderApp(<App1 />);
|
||||||
|
|
||||||
expect(screen.getByText(/render failed/i)).toBeInTheDocument();
|
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.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.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();
|
expect(screen.getByText(/error: error message/i)).toBeInTheDocument();
|
||||||
|
|
||||||
// 底部复制按钮
|
// 底部复制按钮
|
||||||
@ -27,7 +27,7 @@ describe('ErrorFallback', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should render inline correctly', async () => {
|
it('should render inline correctly', async () => {
|
||||||
render(<InlineApp />);
|
await renderApp(<ModalApp />);
|
||||||
|
|
||||||
expect(screen.getByText(/Error: error message/i)).toBeInTheDocument();
|
expect(screen.getByText(/Error: error message/i)).toBeInTheDocument();
|
||||||
expect(document.querySelector('.ant-typography-copy')).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
|
## Basic
|
||||||
|
|
||||||
<code src="./demos/new-demos/basic.tsx"></code>
|
<code src="./demos/basic.tsx"></code>
|
||||||
|
|
||||||
## Modal
|
## Modal
|
||||||
|
|
||||||
<code src="./demos/new-demos/modal.tsx"></code>
|
<code src="./demos/modal.tsx"></code>
|
||||||
|
@ -8,8 +8,8 @@
|
|||||||
|
|
||||||
## Basic
|
## Basic
|
||||||
|
|
||||||
<code src="./demos/new-demos/basic.tsx"></code>
|
<code src="./demos/basic.tsx"></code>
|
||||||
|
|
||||||
## Modal
|
## 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.options = opts;
|
||||||
this.logger.debug(`create database instance: ${safeJsonStringify(this.options)}`, {
|
this.logger.debug(
|
||||||
databaseInstanceId: this.instanceId,
|
`create database instance: ${safeJsonStringify(
|
||||||
});
|
// remove sensitive information
|
||||||
|
lodash.omit(this.options, ['storage', 'host', 'password']),
|
||||||
|
)}`,
|
||||||
|
{
|
||||||
|
databaseInstanceId: this.instanceId,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
const sequelizeOptions = this.sequelizeOptions(this.options);
|
const sequelizeOptions = this.sequelizeOptions(this.options);
|
||||||
this.sequelize = new Sequelize(sequelizeOptions);
|
this.sequelize = new Sequelize(sequelizeOptions);
|
||||||
|
@ -9,11 +9,48 @@
|
|||||||
|
|
||||||
import { Context, Next } from '@nocobase/actions';
|
import { Context, Next } from '@nocobase/actions';
|
||||||
import { getLoggerFilePath } from '@nocobase/logger';
|
import { getLoggerFilePath } from '@nocobase/logger';
|
||||||
import { readdir } from 'fs/promises';
|
import { readdir, stat } from 'fs/promises';
|
||||||
import { join } from 'path';
|
import { join } from 'path';
|
||||||
import stream from 'stream';
|
import stream from 'stream';
|
||||||
import { pack } from 'tar-fs';
|
import { pack } from 'tar-fs';
|
||||||
import zlib from 'zlib';
|
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> => {
|
const tarFiles = (path: string, files: string[]): Promise<any> => {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
@ -97,5 +134,29 @@ export default {
|
|||||||
}
|
}
|
||||||
await next();
|
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