From 1145c1b6315feb50d4d3ee755eda2ef864f080d5 Mon Sep 17 00:00:00 2001 From: Dulguun Otgon Date: Mon, 4 Dec 2023 10:33:03 +0800 Subject: [PATCH] feat: option to enable inspector api through http endpoints --- cli/commands/dev.js | 3 +- cli/commands/docker/utils.js | 5 + packages/api-utils/src/inspect.ts | 147 +++++++++++++++++++ packages/api-utils/src/start-plugin/index.ts | 3 + packages/core/src/index.ts | 3 + packages/gateway/src/index.ts | 3 + 6 files changed, 163 insertions(+), 1 deletion(-) create mode 100644 packages/api-utils/src/inspect.ts diff --git a/cli/commands/dev.js b/cli/commands/dev.js index df7dbe68997..85ce4fa0526 100644 --- a/cli/commands/dev.js +++ b/cli/commands/dev.js @@ -40,7 +40,8 @@ module.exports.devCmd = async program => { RABBITMQ_HOST: 'amqp://127.0.0.1', ELASTICSEARCH_URL: 'http://127.0.0.1:9200', ENABLED_SERVICES_JSON: enabledServicesJson, - ALLOWED_ORIGINS: configs.allowed_origins + ALLOWED_ORIGINS: configs.allowed_origins, + NODE_INSPECTOR: 'enabled', }; let port = 3300; diff --git a/cli/commands/docker/utils.js b/cli/commands/docker/utils.js index 7d21a1088bf..076f235f4ed 100644 --- a/cli/commands/docker/utils.js +++ b/cli/commands/docker/utils.js @@ -127,6 +127,7 @@ const generatePluginBlock = (configs, plugin) => { PORT: plugin.port || SERVICE_INTERNAL_PORT || 80, API_MONGO_URL: api_mongo_url, MONGO_URL: mongo_url, + NODE_INSPECTOR: configs.nodeInspector ? 'enabled' : undefined, LOAD_BALANCER_ADDRESS: generateLBaddress(`http://plugin-${plugin.name}-api`), ...commonEnvs(configs), ...(plugin.extra_env || {}) @@ -492,6 +493,7 @@ const up = async ({ uis, downloadLocales, fromInstaller }) => { JWT_TOKEN_SECRET: configs.jwt_token_secret, LOAD_BALANCER_ADDRESS: generateLBaddress('http://plugin-core-api'), MONGO_URL: mongoEnv(configs), + NODE_INSPECTOR: configs.nodeInspector ? 'enabled' : undefined, EMAIL_VERIFIER_ENDPOINT: configs.email_verifier_endpoint || 'https://email-verifier.erxes.io', @@ -514,6 +516,7 @@ const up = async ({ uis, downloadLocales, fromInstaller }) => { JWT_TOKEN_SECRET: configs.jwt_token_secret, CLIENT_PORTAL_DOMAINS: configs.client_portal_domains || '', MONGO_URL: mongoEnv(configs), + NODE_INSPECTOR: configs.nodeInspector ? 'enabled' : undefined, ...commonEnvs(configs), ...((configs.gateway || {}).extra_env || {}) }, @@ -525,6 +528,7 @@ const up = async ({ uis, downloadLocales, fromInstaller }) => { crons: { image: `erxes/crons:${image_tag}`, environment: { + NODE_INSPECTOR: configs.nodeInspector ? 'enabled' : undefined, MONGO_URL: mongoEnv(configs), ...commonEnvs(configs) }, @@ -538,6 +542,7 @@ const up = async ({ uis, downloadLocales, fromInstaller }) => { JWT_TOKEN_SECRET: configs.jwt_token_secret, LOAD_BALANCER_ADDRESS: generateLBaddress('http://plugin-workers-api'), MONGO_URL: mongoEnv(configs), + NODE_INSPECTOR: configs.nodeInspector ? 'enabled' : undefined, ...commonEnvs(configs), ...((configs.workers || {}).extra_env || {}) }, diff --git a/packages/api-utils/src/inspect.ts b/packages/api-utils/src/inspect.ts new file mode 100644 index 00000000000..72f4e642795 --- /dev/null +++ b/packages/api-utils/src/inspect.ts @@ -0,0 +1,147 @@ +import * as dotenv from 'dotenv'; +dotenv.config(); +import { promisify } from 'util'; + +// TODO: replace it with "node:inspector/promises" after migrating to Node version >= 20 +import { HeapProfiler, Profiler, Session } from 'node:inspector'; +import { Express } from 'express'; +const session = new Session() as any; +session.connect(); + +const post = promisify(session.post.bind(session)); + +const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)); + +const { NODE_INSPECTOR } = process.env; + +const nodeInspector = NODE_INSPECTOR === 'enabled'; + +export async function cpuPreciseCoverage( + seconds = 10 +): Promise { + // session.connect(); + await post('Profiler.enable'); + + await post('Profiler.startPreciseCoverage', { + callCount: true, + detailed: true + }); + + await sleep(seconds * 1000); + + const { result } = await post('Profiler.takePreciseCoverage'); + + await post('Profiler.stopPreciseCoverage'); + await post('Profiler.disable'); + // session.disconnect(); + return result; +} + +export async function cpuProfile(seconds = 10): Promise { + // session.connect(); + await post('Profiler.enable'); + await post('Profiler.start'); + + await sleep(seconds * 1000); + + const { profile } = await post('Profiler.stop'); + + await post('Profiler.disable'); + // session.disconnect(); + return profile; +} + +export async function heapProfile( + seconds = 10 +): Promise { + // session.connect(); + await post('HeapProfiler.enable'); + await post('HeapProfiler.startSampling'); + + await sleep(seconds * 1000); + + const { profile } = await post('HeapProfiler.stopSampling'); + + await post('HeapProfiler.disable'); + // session.disconnect(); + return profile; +} + +export async function heapSnapshot( + chunkCb: (chunk: string) => void +): Promise { + return new Promise(async resolve => { + // session.connect(); + const getChunk = m => { + chunkCb(m.params.chunk); + }; + session.on('HeapProfiler.addHeapSnapshotChunk', getChunk); + + await post('HeapProfiler.takeHeapSnapshot'); + + session.removeListener('HeapProfiler.addHeapSnapshotChunk', getChunk); + // session.disconnect(); + return resolve(); + }); +} + +export function applyInspectorEndpoints(app: Express, name: string) { + app.use('/node-inspector', (req, res, next) => { + if (nodeInspector) { + return next(); + } + return res.status(403).send('Node inspector is not enabled'); + }); + + app.get('/node-inspector/precise-coverage/:seconds', async (req, res) => { + try { + const seconds = Number(req.params.seconds) || 10; + + const coverage = await cpuPreciseCoverage(seconds); + + res.header('Content-Type', 'application/json'); + return res.send(JSON.stringify(coverage, null, 4)); + } catch (e) { + return res.status(500).send(e.message); + } + }); + + app.get('/node-inspector/cpu-profile/:seconds', async (req, res) => { + try { + const seconds = Number(req.params.seconds) || 10; + + const profile = await cpuProfile(seconds); + + res.attachment(`${name}-cpu-profile-${Date.now()}.cpuprofile`); + res.type('application/json'); + return res.send(JSON.stringify(profile)); + } catch (e) { + return res.status(500).send(e.message); + } + }); + + app.get('/node-inspector/heap-profile/:seconds', async (req, res) => { + try { + const seconds = Number(req.params.seconds) || 10; + + const profile = await heapProfile(seconds); + + res.attachment(`${name}-heap-profile-${Date.now()}.heapprofile`); + res.type('application/json'); + return res.send(JSON.stringify(profile)); + } catch (e) { + return res.status(500).send(e.message); + } + }); + + app.get('/node-inspector/heap-snapshot/', async (req, res) => { + try { + res.attachment(`${name}-heap-snapshot-${Date.now()}.heapsnapshot`); + res.type('json'); + await heapSnapshot(chunk => res.write(chunk)); + res.end(); + } catch (e) { + return res.status(500).send(e.message); + } + }); +} diff --git a/packages/api-utils/src/start-plugin/index.ts b/packages/api-utils/src/start-plugin/index.ts index a49f17c6cac..c4a150e0fb6 100644 --- a/packages/api-utils/src/start-plugin/index.ts +++ b/packages/api-utils/src/start-plugin/index.ts @@ -36,6 +36,7 @@ import { leave, redis } from '@erxes/api-utils/src/serviceDiscovery'; +import { applyInspectorEndpoints } from '../inspect'; const { MONGO_URL, @@ -687,6 +688,8 @@ export async function startPlugin(configs: any): Promise { } }); + applyInspectorEndpoints(app, configs.name); + debugInfo(`${configs.name} server is running on port: ${PORT}`); return app; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index b1400d4ddf4..e4d7440e806 100755 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -50,6 +50,7 @@ import exporter from './exporter'; import { moduleObjects } from './data/permissions/actions/permission'; import dashboards from './dashboards'; import { getEnabledServices } from '@erxes/api-utils/src/serviceDiscovery'; +import { applyInspectorEndpoints } from '@erxes/api-utils/src/inspect'; const { JWT_TOKEN_SECRET, @@ -326,6 +327,8 @@ app.get('/plugins/enabled', async (_req, res) => { // The error handler must be before any other error middleware and after all controllers app.use(Sentry.Handlers.errorHandler()); +applyInspectorEndpoints(app, 'core'); + // Wrap the Express server const httpServer = createServer(app); diff --git a/packages/gateway/src/index.ts b/packages/gateway/src/index.ts index 1e36406dd7d..fa9da2a787c 100644 --- a/packages/gateway/src/index.ts +++ b/packages/gateway/src/index.ts @@ -25,6 +25,7 @@ import { startSubscriptionServer, stopSubscriptionServer } from './subscription'; +import { applyInspectorEndpoints } from '@erxes/api-utils/src/inspect'; const { DOMAIN, @@ -116,6 +117,8 @@ const { app.use(express.urlencoded({ limit: '15mb', extended: true })); + applyInspectorEndpoints(app, 'gateway'); + const port = PORT || 4000; await new Promise(resolve => httpServer.listen({ port }, resolve));