Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
179 changes: 179 additions & 0 deletions packages/napcat-test/metrics.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { Registry } from 'prom-client';

const mockWebUiDataRuntime = {
getQQLoginStatus: vi.fn().mockReturnValue(false),
getOneBotContext: vi.fn().mockReturnValue(null),
GetNapCatVersion: vi.fn().mockReturnValue('4.0.0'),
getQQVersion: vi.fn().mockReturnValue('9.9.0'),
getQQLoginUin: vi.fn().mockReturnValue('12345678'),
};

const mockResourceManager = {
getAllResourceStats: vi.fn().mockReturnValue(new Map()),
};

vi.mock('@/napcat-webui-backend/src/helper/Data', () => ({
WebUiDataRuntime: mockWebUiDataRuntime,
}));

vi.mock('napcat-common/src/health', () => ({
resourceManager: mockResourceManager,
}));

let getMetrics: () => Promise<string>;
let register: Registry;

beforeEach(async () => {
vi.clearAllMocks();
mockWebUiDataRuntime.getQQLoginStatus.mockReturnValue(false);
mockWebUiDataRuntime.getOneBotContext.mockReturnValue(null);
mockWebUiDataRuntime.GetNapCatVersion.mockReturnValue('4.0.0');
mockWebUiDataRuntime.getQQVersion.mockReturnValue('9.9.0');
mockWebUiDataRuntime.getQQLoginUin.mockReturnValue('12345678');
mockResourceManager.getAllResourceStats.mockReturnValue(new Map());

const mod = await import('@/napcat-webui-backend/src/api/Metrics');
getMetrics = mod.getMetrics;
register = mod.register;
register.resetMetrics();
});

describe('Prometheus Metrics — getMetrics()', () => {
it('returns valid Prometheus text format with HELP and TYPE lines', async () => {
const output = await getMetrics();
expect(output).toContain('# HELP');
expect(output).toContain('# TYPE');
});

it('includes default Node.js process metrics', async () => {
const output = await getMetrics();
expect(output).toContain('napcat_process_cpu');
});

it('reports napcat_qq_login_status 0 when not logged in', async () => {
mockWebUiDataRuntime.getQQLoginStatus.mockReturnValue(false);
const output = await getMetrics();
expect(output).toMatch(/napcat_qq_login_status 0/);
});

it('reports napcat_qq_login_status 1 when logged in', async () => {
mockWebUiDataRuntime.getQQLoginStatus.mockReturnValue(true);
const output = await getMetrics();
expect(output).toMatch(/napcat_qq_login_status 1/);
});

it('includes napcat_uptime_seconds as a positive number', async () => {
const output = await getMetrics();
const match = output.match(/napcat_uptime_seconds (\d+(?:\.\d+)?)/);
expect(match).not.toBeNull();
expect(Number(match![1])).toBeGreaterThan(0);
});
});

describe('Prometheus Metrics — with OneBot context', () => {
function createMockOb11 (online: boolean, adapters: [string, boolean, boolean][]) {
const adapterMap = new Map<string, { isActive: boolean; isEnable: boolean }>();
for (const [name, active, enabled] of adapters) {
adapterMap.set(name, { isActive: active, isEnable: enabled });
}
return {
core: { selfInfo: { online } },
networkManager: { adapters: adapterMap },
};
}

it('reports napcat_qq_online 1 when bot is online', async () => {
mockWebUiDataRuntime.getOneBotContext.mockReturnValue(
createMockOb11(true, []),
);
const output = await getMetrics();
expect(output).toMatch(/napcat_qq_online 1/);
});

it('reports napcat_qq_online 0 when bot is offline', async () => {
mockWebUiDataRuntime.getOneBotContext.mockReturnValue(
createMockOb11(false, []),
);
const output = await getMetrics();
expect(output).toMatch(/napcat_qq_online 0/);
});

it('includes napcat_info gauge with version labels', async () => {
mockWebUiDataRuntime.getOneBotContext.mockReturnValue(
createMockOb11(true, []),
);
const output = await getMetrics();
expect(output).toContain('napcat_info');
expect(output).toContain('version="4.0.0"');
expect(output).toContain('qq_version="9.9.0"');
expect(output).toContain('uin="12345678"');
});

it('reports per-adapter active and enabled status', async () => {
mockWebUiDataRuntime.getOneBotContext.mockReturnValue(
createMockOb11(true, [
['ws_server', true, true],
['http_client', false, true],
['ws_client', false, false],
]),
);
const output = await getMetrics();

expect(output).toMatch(/napcat_onebot_adapter_active\{name="ws_server"\} 1/);
expect(output).toMatch(/napcat_onebot_adapter_active\{name="http_client"\} 0/);
expect(output).toMatch(/napcat_onebot_adapter_active\{name="ws_client"\} 0/);

expect(output).toMatch(/napcat_onebot_adapter_enabled\{name="ws_server"\} 1/);
expect(output).toMatch(/napcat_onebot_adapter_enabled\{name="http_client"\} 1/);
expect(output).toMatch(/napcat_onebot_adapter_enabled\{name="ws_client"\} 0/);
});
});

describe('Prometheus Metrics — graceful degradation', () => {
it('succeeds when OneBot context is null', async () => {
mockWebUiDataRuntime.getOneBotContext.mockReturnValue(null);
const output = await getMetrics();
expect(output).toContain('napcat_qq_login_status');
expect(output).toContain('napcat_uptime_seconds');
// napcat_info should have no value line (only HELP/TYPE headers)
expect(output).not.toMatch(/napcat_info\{.*\} \d/);
// No adapter value lines should appear
expect(output).not.toMatch(/napcat_onebot_adapter_active\{.*\} \d/);
expect(output).not.toMatch(/napcat_onebot_adapter_enabled\{.*\} \d/);
});

it('succeeds when OneBot context has no networkManager.adapters', async () => {
mockWebUiDataRuntime.getOneBotContext.mockReturnValue({
core: { selfInfo: { online: true } },
networkManager: {},
});
const output = await getMetrics();
expect(output).toContain('napcat_qq_online 1');
// No adapter value lines should appear
expect(output).not.toMatch(/napcat_onebot_adapter_active\{.*\} \d/);
expect(output).not.toMatch(/napcat_onebot_adapter_enabled\{.*\} \d/);
});
});

describe('Prometheus Metrics — resource stats', () => {
it('reports resource success and failure counts', async () => {
const stats = new Map([
['image_upload', { successCount: 42, failureCount: 3, isEnabled: true, isPermanentlyDisabled: false }],
['file_download', { successCount: 10, failureCount: 0, isEnabled: true, isPermanentlyDisabled: false }],
]);
mockResourceManager.getAllResourceStats.mockReturnValue(stats);

const output = await getMetrics();
expect(output).toMatch(/napcat_resource_success_total\{type="image_upload"\} 42/);
expect(output).toMatch(/napcat_resource_failure_total\{type="image_upload"\} 3/);
expect(output).toMatch(/napcat_resource_success_total\{type="file_download"\} 10/);
expect(output).toMatch(/napcat_resource_failure_total\{type="file_download"\} 0/);
});

it('omits resource metrics when no resources are registered', async () => {
mockResourceManager.getAllResourceStats.mockReturnValue(new Map());
const output = await getMetrics();
expect(output).not.toMatch(/napcat_resource_success_total\{type=/);
});
});
5 changes: 4 additions & 1 deletion packages/napcat-test/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,12 @@
},
"dependencies": {
"@sinclair/typebox": "^0.34.41",
"napcat-common": "workspace:*",
"napcat-core": "workspace:*",
"napcat-image-size": "workspace:*",
"napcat-onebot": "workspace:*",
"napcat-rpc": "workspace:*"
"napcat-rpc": "workspace:*",
"napcat-webui-backend": "workspace:*",
"prom-client": "^15.1.3"
}
}
2 changes: 2 additions & 0 deletions packages/napcat-test/vitest.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ export default defineConfig({
'@/napcat-test': resolve(__dirname, '.'),
'@/napcat-common': resolve(__dirname, '../napcat-common'),
'@/napcat-core': resolve(__dirname, '../napcat-core'),
'@/napcat-webui-backend': resolve(__dirname, '../napcat-webui-backend'),
'@/napcat-onebot': resolve(__dirname, '../napcat-onebot'),
},
},
});
22 changes: 22 additions & 0 deletions packages/napcat-webui-backend/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import { napCatVersion } from 'napcat-common/src/version';
import { fileURLToPath } from 'node:url';
import { NapCatOneBot11Adapter } from '@/napcat-onebot/index';
import { OB11PluginMangerAdapter } from '@/napcat-onebot/network/plugin-manger';
import { getMetrics, register } from '@/napcat-webui-backend/src/api/Metrics';

const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
Expand Down Expand Up @@ -332,6 +333,27 @@ export async function InitWebUi (logger: ILogWrapper, pathWrapper: NapCatPathWra
}
});

// Prometheus metrics endpoint (unauthenticated)
app.get('/metrics', async (req, res) => {
const metricsConfig = await WebUiConfig.GetWebUIConfig();
if (metricsConfig.enableMetrics === false) {
return res.status(404).end('Not Found');
}
if (metricsConfig.metricsToken) {
const authHeader = req.headers['authorization'];
const token = authHeader?.startsWith('Bearer ') ? authHeader.slice(7) : undefined;
if (token !== metricsConfig.metricsToken) {
return res.status(401).end('Unauthorized');
}
}
try {
res.set('Content-Type', register.contentType);
return res.end(await getMetrics());
} catch (e) {
return res.status(500).end(String(e));
}
});

// ------------中间件结束------------

// ------------挂载路由------------
Expand Down
1 change: 1 addition & 0 deletions packages/napcat-webui-backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
"napcat-common": "workspace:*",
"napcat-dpapi": "workspace:*",
"napcat-pty": "workspace:*",
"prom-client": "^15.1.3",
"ws": "^8.18.3"
},
"devDependencies": {
Expand Down
98 changes: 98 additions & 0 deletions packages/napcat-webui-backend/src/api/Metrics.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import { Registry, Gauge, collectDefaultMetrics } from 'prom-client';
import { WebUiDataRuntime } from '@/napcat-webui-backend/src/helper/Data';
import { resourceManager } from 'napcat-common/src/health';

export const register = new Registry();

collectDefaultMetrics({ register, prefix: 'napcat_' });

const qqLoginStatus = new Gauge({
name: 'napcat_qq_login_status',
help: 'QQ login status (1 = logged in, 0 = not logged in)',
registers: [register],
});

const qqOnline = new Gauge({
name: 'napcat_qq_online',
help: 'QQ online status (1 = online, 0 = offline)',
registers: [register],
});

const napcatInfo = new Gauge({
name: 'napcat_info',
help: 'NapCat build info',
labelNames: ['version', 'qq_version', 'uin'] as const,
registers: [register],
});

const uptimeSeconds = new Gauge({
name: 'napcat_uptime_seconds',
help: 'Process uptime in seconds',
registers: [register],
});

const adapterActive = new Gauge({
name: 'napcat_onebot_adapter_active',
help: 'Whether an OneBot adapter is currently active (1 = active, 0 = inactive)',
labelNames: ['name'] as const,
registers: [register],
});

const adapterEnabled = new Gauge({
name: 'napcat_onebot_adapter_enabled',
help: 'Whether an OneBot adapter is enabled (1 = enabled, 0 = disabled)',
labelNames: ['name'] as const,
registers: [register],
});

const resourceSuccess = new Gauge({
name: 'napcat_resource_success_total',
help: 'Total successful resource calls',
labelNames: ['type'] as const,
registers: [register],
});

const resourceFailure = new Gauge({
name: 'napcat_resource_failure_total',
help: 'Total failed resource calls',
labelNames: ['type'] as const,
registers: [register],
});

export async function getMetrics (): Promise<string> {
qqLoginStatus.set(WebUiDataRuntime.getQQLoginStatus() ? 1 : 0);
uptimeSeconds.set(process.uptime());

const ob11 = WebUiDataRuntime.getOneBotContext();
if (ob11) {
qqOnline.set(ob11.core?.selfInfo?.online ? 1 : 0);

napcatInfo.reset();
napcatInfo
.labels(
WebUiDataRuntime.GetNapCatVersion(),
WebUiDataRuntime.getQQVersion(),
WebUiDataRuntime.getQQLoginUin(),
)
.set(1);

adapterActive.reset();
adapterEnabled.reset();
if (ob11.networkManager?.adapters) {
for (const [name, adapter] of ob11.networkManager.adapters) {
adapterActive.labels(name).set(adapter.isActive ? 1 : 0);
adapterEnabled.labels(name).set(adapter.isEnable ? 1 : 0);
}
}
}

resourceSuccess.reset();
resourceFailure.reset();
const allStats = resourceManager.getAllResourceStats();
for (const [type, stats] of allStats) {
resourceSuccess.labels(type).set(stats.successCount);
resourceFailure.labels(type).set(stats.failureCount);
}

return register.metrics();
}
4 changes: 4 additions & 0 deletions packages/napcat-webui-backend/src/helper/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,10 @@ const WebUiConfigSchema = Type.Object({
ipBlacklist: Type.Array(Type.String(), { default: [] }),
// 是否启用 X-Forwarded-For 获取真实IP
enableXForwardedFor: Type.Boolean({ default: false }),
// 是否启用 Prometheus metrics 端点
enableMetrics: Type.Boolean({ default: true }),
// Prometheus metrics 端点的 Bearer token(空字符串表示无需认证)
metricsToken: Type.String({ default: '' }),
});

export type WebUiConfigType = Static<typeof WebUiConfigSchema>;
Expand Down
2 changes: 2 additions & 0 deletions packages/napcat-webui-backend/src/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ export interface WebUiConfigType {
ipWhitelist?: string[];
ipBlacklist?: string[];
enableXForwardedFor?: boolean;
enableMetrics?: boolean;
metricsToken?: string;
}
export interface WebUiCredentialInnerJson {
CreatedTime: number;
Expand Down
Loading
Loading