kt-template-online-api/src/modules/admin/platform-config/system-log/system-log.service.ts

553 lines
16 KiB
TypeScript

import { HttpStatus, Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import * as http from 'node:http';
import * as https from 'node:https';
import { URL } from 'node:url';
import {
getAppName,
getLokiEnvironment,
transformKtDateTimeFields,
throwVbenError,
ToolsService,
} from '@/common';
import { SystemLogDto } from './system-log.dto';
import type {
SystemLogQueryDto,
SystemLogStatusDto,
SystemLogSummaryDto,
} from './system-log.dto';
type LokiStreamResult = {
stream?: Record<string, string>;
values?: Array<[string, string, Record<string, string>?]>;
};
type LokiQueryRangeResponse = {
data?: {
result?: LokiStreamResult[];
resultType?: string;
};
status?: string;
};
type LokiMetricResult = {
metric?: Record<string, string>;
value?: [number | string, string];
};
type LokiQueryResponse = {
data?: {
result?: LokiMetricResult[];
resultType?: string;
};
status?: string;
};
const DEFAULT_LEVELS = ['debug', 'info', 'warning', 'error', 'critical'];
const PINO_LEVEL_MAP: Record<string, string> = {
'10': 'debug',
'20': 'debug',
'30': 'info',
'40': 'warning',
'50': 'error',
'60': 'critical',
};
@Injectable()
export class SystemLogService {
private readonly appName: string;
private readonly environment: string;
private readonly host: string;
constructor(
private readonly configService: ConfigService,
private readonly toolsService: ToolsService,
) {
this.appName = getAppName(configService);
this.environment = getLokiEnvironment(configService);
this.host = this.normalizeUrl(
this.getConfig('LOKI_QUERY_HOST') ||
this.getConfig('LOKI_HOST') ||
this.getConfig('LOKI_URL'),
);
}
status(): SystemLogStatusDto {
return {
app: this.appName,
configured: !!this.host,
env: this.environment,
host: this.maskHost(this.host),
selector: this.getBaseSelector(),
};
}
levels() {
return DEFAULT_LEVELS.map((level) => ({
label: level,
value: level,
}));
}
async page(query: SystemLogQueryDto = {}) {
if (!this.host) {
return {
items: [],
total: 0,
};
}
const { pageNo, pageSize } = this.toolsService.getPageParams(query, 1, 20);
const skip = (pageNo - 1) * pageSize;
const requestLimit = Math.min(
this.toolsService.toPositiveNumber(query.limit, skip + pageSize),
this.getNumberConfig('LOKI_QUERY_MAX_LIMIT', 1000),
);
const [logs, total] = await Promise.all([
this.queryLogs(query, Math.max(requestLimit, pageSize)),
this.queryLogCount(query),
]);
const filteredLogs = logs.filter((item) => this.matchesQuery(item, query));
return {
items: filteredLogs.slice(skip, skip + pageSize),
total,
};
}
async summary(query: SystemLogQueryDto = {}): Promise<SystemLogSummaryDto[]> {
if (!this.host) {
return DEFAULT_LEVELS.map((level) => ({ count: 0, level }));
}
const countMap = new Map(DEFAULT_LEVELS.map((level) => [level, 0]));
const counts = await this.queryLogSummary(query);
counts.forEach(({ count, level }) => {
countMap.set(level, count);
});
return DEFAULT_LEVELS.map((level) => ({
count: countMap.get(level) || 0,
level,
}));
}
private async queryLogs(query: SystemLogQueryDto, limit: number) {
const url = new URL(
this.getConfig('LOKI_QUERY_ENDPOINT', '/loki/api/v1/query_range'),
this.host,
);
const { start, end } = this.getTimeRange(query);
url.searchParams.set('query', this.buildLogQL(query));
url.searchParams.set('start', this.toNanoseconds(start));
url.searchParams.set('end', this.toNanoseconds(end));
url.searchParams.set('limit', `${limit}`);
url.searchParams.set('direction', 'backward');
let response: LokiQueryRangeResponse;
try {
response = await this.requestJson<LokiQueryRangeResponse>(url);
} catch (error) {
throwVbenError(
this.toolsService.getErrorMessage(error, 'Loki 查询失败'),
HttpStatus.BAD_GATEWAY,
);
}
if (response.status && response.status !== 'success') {
throwVbenError('Loki 查询失败', HttpStatus.BAD_GATEWAY, response.status);
}
return this.flattenLogs(response.data?.result || []);
}
private async queryLogCount(query: SystemLogQueryDto) {
const response = await this.queryInstant(
this.buildCountLogQL(query),
query,
);
const value = response.data?.result?.[0]?.value?.[1];
return this.toOptionalNumber(value) || 0;
}
private async queryLogSummary(query: SystemLogQueryDto) {
const response = await this.queryInstant(
this.buildSummaryLogQL(query),
query,
);
return (response.data?.result || [])
.map((item) => ({
count: this.toOptionalNumber(item.value?.[1]) || 0,
level: this.normalizeLevel(item.metric?.level) || 'info',
}))
.filter((item) => DEFAULT_LEVELS.includes(item.level));
}
private async queryInstant(logql: string, query: SystemLogQueryDto) {
const url = new URL(this.getInstantQueryEndpoint(), this.host);
const { end } = this.getTimeRange(query);
url.searchParams.set('query', logql);
url.searchParams.set('time', `${Math.floor(end.getTime() / 1000)}`);
let response: LokiQueryResponse;
try {
response = await this.requestJson<LokiQueryResponse>(url);
} catch (error) {
throwVbenError(
this.toolsService.getErrorMessage(error, 'Loki 查询失败'),
HttpStatus.BAD_GATEWAY,
);
}
if (response.status && response.status !== 'success') {
throwVbenError('Loki 查询失败', HttpStatus.BAD_GATEWAY, response.status);
}
return response;
}
private buildLogQL(query: SystemLogQueryDto) {
const selector = this.withLevelSelector(
this.getBaseSelector(),
this.normalizeLevel(query.level),
);
const lineFilters = [
query.keyword,
query.context,
query.path,
query.requestId,
]
.map((value) => this.toolsService.toTrimmedString(value))
.filter(Boolean)
.map((value) => `|= "${this.escapeLogqlString(value)}"`);
return [selector, ...lineFilters].join(' ');
}
private buildCountLogQL(query: SystemLogQueryDto) {
return `sum(count_over_time(${this.buildLogQL(query)}[${this.getLogqlRange(query)}]))`;
}
private buildSummaryLogQL(query: SystemLogQueryDto) {
return `sum by (level)(count_over_time(${this.buildLogQL(query)}[${this.getLogqlRange(query)}]))`;
}
private withLevelSelector(selector: string, level?: string) {
if (!level || selector.includes('level=')) return selector;
return selector.replace(
/}\s*$/,
`,level="${this.escapeLabelValue(level)}"}`,
);
}
private getBaseSelector() {
const selector = this.getConfig('LOKI_QUERY_SELECTOR');
if (selector) return selector;
return `{app="${this.escapeLabelValue(
this.appName,
)}",env="${this.escapeLabelValue(this.environment)}"}`;
}
private flattenLogs(streams: LokiStreamResult[]): SystemLogDto[] {
return streams
.flatMap((stream, streamIndex) =>
(stream.values || []).map(([timestampNs, line, metadata], rowIndex) =>
this.serializeLog({
line,
metadata,
rowIndex,
stream: stream.stream || {},
streamIndex,
timestampNs,
}),
),
)
.sort((prev, next) =>
this.compareTimestamp(next.timestampNs, prev.timestampNs),
);
}
private serializeLog(params: {
line: string;
metadata?: Record<string, string>;
rowIndex: number;
stream: Record<string, string>;
streamIndex: number;
timestampNs: string;
}): SystemLogDto {
const parsed = this.parseLogLine(params.line);
const req = this.asRecord(parsed.req);
const res = this.asRecord(parsed.res);
const meta = this.asRecord(parsed.meta);
const level =
this.normalizeLevel(params.stream.level) ||
this.normalizeLevel(parsed.level) ||
'info';
const requestId =
this.pickText(
params.metadata?.requestId,
parsed.requestId,
meta.requestId,
req.id,
) || undefined;
const path =
this.toolsService.normalizeRequestPathValue(
this.pickText(
parsed.path,
parsed.url,
parsed.originalUrl,
req.path,
req.url,
req.originalUrl,
),
) || undefined;
const method =
this.pickText(parsed.method, req.method)?.toUpperCase() || undefined;
return transformKtDateTimeFields(
Object.assign(new SystemLogDto(), {
context:
this.pickText(parsed.context, params.stream.context) || undefined,
durationMs: this.toOptionalNumber(
parsed.durationMs,
parsed.responseTime,
),
hostname: params.stream.hostname,
id: `${params.timestampNs}-${params.streamIndex}-${params.rowIndex}`,
level,
message:
this.pickText(parsed.msg, parsed.message, parsed.err?.message) ||
params.line,
method,
path,
raw: params.line,
requestId,
statusCode: this.toOptionalNumber(parsed.statusCode, res.statusCode),
timestamp: this.timestampNsToDate(params.timestampNs),
timestampNs: params.timestampNs,
}),
);
}
private matchesQuery(item: SystemLogDto, query: SystemLogQueryDto) {
const level = this.normalizeLevel(query.level);
if (level && item.level !== level) return false;
if (
!this.includes(item.raw, query.keyword) &&
!this.includes(item.message, query.keyword)
) {
return false;
}
if (!this.includes(item.context, query.context)) return false;
if (!this.includes(item.path, query.path)) return false;
if (!this.includes(item.requestId, query.requestId)) return false;
return true;
}
private includes(value: unknown, keyword: unknown) {
const normalizedKeyword = this.toolsService.toTrimmedString(keyword);
if (!normalizedKeyword) return true;
return this.toolsService.includesText(value, normalizedKeyword);
}
private getTimeRange(query: SystemLogQueryDto) {
const end = this.toDate(query.endTime) || new Date();
const start =
this.toDate(query.startTime) ||
new Date(
end.getTime() -
this.toolsService.toPositiveNumber(query.rangeMinutes, 60) *
60 *
1000,
);
return { end, start };
}
private getLogqlRange(query: SystemLogQueryDto) {
const { end, start } = this.getTimeRange(query);
const seconds = Math.max(
1,
Math.ceil((end.getTime() - start.getTime()) / 1000),
);
return `${seconds}s`;
}
private getInstantQueryEndpoint() {
const endpoint = this.getConfig('LOKI_QUERY_INSTANT_ENDPOINT');
if (endpoint) return endpoint;
return this.getConfig(
'LOKI_QUERY_ENDPOINT',
'/loki/api/v1/query_range',
).replace(/query_range$/, 'query');
}
private requestJson<T>(url: URL) {
return new Promise<T>((resolve, reject) => {
const client = url.protocol === 'http:' ? http : https;
const request = client.request(
url,
{
headers: {
Accept: 'application/json',
'User-Agent': 'kt-template-online-api/admin-system-log',
...this.getHeaders(),
},
method: 'GET',
timeout: this.getNumberConfig('LOKI_QUERY_TIMEOUT_MS', 10000),
},
(response) => {
let body = '';
response.setEncoding('utf8');
response.on('data', (chunk) => {
body += chunk;
});
response.on('end', () => {
if ((response.statusCode || 500) >= 400) {
reject(new Error(`Loki 查询失败:${response.statusCode}`));
return;
}
try {
resolve(JSON.parse(body) as T);
} catch {
reject(new Error('Loki 返回不是合法 JSON'));
}
});
},
);
request.on('timeout', () => {
request.destroy(new Error('Loki 查询超时'));
});
request.on('error', reject);
request.end();
});
}
private getHeaders() {
const headers: Record<string, string> = {};
const tenantId = this.getConfig('LOKI_TENANT_ID');
const username = this.getConfig('LOKI_USERNAME');
const password = this.getConfig('LOKI_PASSWORD');
if (tenantId) headers['X-Scope-OrgID'] = tenantId;
if (username && password) {
headers.Authorization = `Basic ${Buffer.from(
`${username}:${password}`,
).toString('base64')}`;
}
return headers;
}
private parseLogLine(line: string): Record<string, any> {
try {
const parsed = JSON.parse(line);
return parsed && typeof parsed === 'object' ? parsed : {};
} catch {
return {};
}
}
private asRecord(value: unknown): Record<string, any> {
return value && typeof value === 'object'
? (value as Record<string, any>)
: {};
}
private pickText(...values: unknown[]) {
return this.toolsService.pickFirstText(...values);
}
private normalizeLevel(value: unknown) {
const text = this.toolsService.toTrimmedString(value).toLowerCase();
if (!text) return '';
if (PINO_LEVEL_MAP[text]) return PINO_LEVEL_MAP[text];
if (text === 'warn') return 'warning';
return DEFAULT_LEVELS.includes(text) ? text : '';
}
private toDate(value: unknown) {
const text = this.toolsService.toTrimmedString(value);
if (!text) return null;
const timestamp = /^\d+$/.test(text)
? this.normalizeTimestamp(text)
: Date.parse(text);
const date = new Date(timestamp);
return Number.isNaN(date.getTime()) ? null : date;
}
private toNanoseconds(date: Date) {
return `${BigInt(date.getTime()) * 1000000n}`;
}
private timestampNsToDate(value: string) {
try {
return new Date(Number(BigInt(value) / 1000000n));
} catch {
return new Date();
}
}
private compareTimestamp(left: string, right: string) {
try {
const diff = BigInt(left) - BigInt(right);
return diff > 0n ? 1 : diff < 0n ? -1 : 0;
} catch {
return left.localeCompare(right);
}
}
private toOptionalNumber(...values: unknown[]) {
for (const value of values) {
const nextValue = Number(value);
if (Number.isFinite(nextValue)) return nextValue;
}
return undefined;
}
private escapeLogqlString(value: string) {
return value.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
}
private escapeLabelValue(value: string) {
return this.escapeLogqlString(value);
}
private getConfig(key: string, fallback = '') {
const value = this.configService.get<string>(key);
return this.toolsService.toTrimmedString(value || fallback);
}
private getNumberConfig(key: string, fallback: number) {
const value = Number(this.configService.get<string>(key));
return Number.isFinite(value) && value > 0 ? value : fallback;
}
private normalizeUrl(value: string) {
return value.replace(/\/+$/g, '');
}
private maskHost(host: string) {
if (!host) return undefined;
try {
const url = new URL(host);
if (url.username || url.password) {
url.username = '***';
url.password = '***';
}
return url.toString().replace(/\/+$/g, '');
} catch {
return host;
}
}
private normalizeTimestamp(value: string) {
if (value.length === 10) return Number(value) * 1000;
if (value.length === 16) return Math.floor(Number(value) / 1000);
if (value.length >= 19) return Number(BigInt(value) / 1000000n);
return Number(value);
}
}