feat: 运行时日志系统,Pino + pino-pretty + pino-roll,console/file 双输出,敏感信息 redaction
This commit is contained in:
@@ -2,13 +2,22 @@ import { isNumber, isPlainObject, isString } from "es-toolkit";
|
||||
import { dirname, resolve } from "node:path";
|
||||
|
||||
import type { ConfigValidationIssue } from "./schema/issues";
|
||||
import type { DefaultsConfig, EngineRuntimeConfig, RawTargetConfig, ResolvedTargetBase } from "./types";
|
||||
import type {
|
||||
DefaultsConfig,
|
||||
EngineRuntimeConfig,
|
||||
LoggingConfig,
|
||||
LogLevel,
|
||||
RawTargetConfig,
|
||||
ResolvedLoggingConfig,
|
||||
ResolvedTargetBase,
|
||||
RotationFrequency,
|
||||
} from "./types";
|
||||
|
||||
import { checkerRegistry } from "./runner";
|
||||
import { issue, throwConfigIssues } from "./schema/issues";
|
||||
import { asValidatedConfig, type RawProbeConfig } from "./schema/types";
|
||||
import { validateProbeConfigContract } from "./schema/validate";
|
||||
import { parseDuration } from "./utils";
|
||||
import { parseDuration, parseSize } from "./utils";
|
||||
import { resolveVariables } from "./variables";
|
||||
|
||||
const DEFAULT_HOST = "127.0.0.1";
|
||||
@@ -18,11 +27,19 @@ const DEFAULT_INTERVAL = "30s";
|
||||
const DEFAULT_TIMEOUT = "10s";
|
||||
const DEFAULT_MAX_CONCURRENT_CHECKS = 20;
|
||||
const DEFAULT_RETENTION = "7d";
|
||||
const DEFAULT_LOG_LEVEL: LogLevel = "info";
|
||||
const DEFAULT_ROTATION_SIZE = "50MB";
|
||||
const DEFAULT_ROTATION_FREQUENCY: RotationFrequency = "daily";
|
||||
const DEFAULT_ROTATION_MAX_FILES = 14;
|
||||
|
||||
const VALID_LOG_LEVELS: LogLevel[] = ["trace", "debug", "info", "warn", "error", "fatal"];
|
||||
const VALID_ROTATION_FREQUENCIES: RotationFrequency[] = ["hourly", "daily", "weekly"];
|
||||
|
||||
export interface ResolvedConfig {
|
||||
configDir: string;
|
||||
dataDir: string;
|
||||
host: string;
|
||||
logging: ResolvedLoggingConfig;
|
||||
maxConcurrentChecks: number;
|
||||
port: number;
|
||||
retentionMs: number;
|
||||
@@ -80,7 +97,10 @@ export async function loadConfig(configPath: string): Promise<ResolvedConfig> {
|
||||
const maxConcurrentChecks = resolveMaxConcurrentChecks(runtime);
|
||||
const retentionMs = resolveRetention(runtime);
|
||||
|
||||
const logging = resolveLogging(validated.logging ?? {}, dataDir, configDir);
|
||||
|
||||
const allRuntimeIssues = [...allIssues];
|
||||
validateLoggingConfig(validated.logging, allRuntimeIssues);
|
||||
if (allRuntimeIssues.length > 0) {
|
||||
throwConfigIssues(dedupeIssues(allRuntimeIssues));
|
||||
}
|
||||
@@ -92,7 +112,7 @@ export async function loadConfig(configPath: string): Promise<ResolvedConfig> {
|
||||
resolveTarget(target, defaults, defaultIntervalMs, defaultTimeoutMs, configDir),
|
||||
);
|
||||
|
||||
return { configDir, dataDir, host, maxConcurrentChecks, port, retentionMs, targets };
|
||||
return { configDir, dataDir, host, logging, maxConcurrentChecks, port, retentionMs, targets };
|
||||
}
|
||||
|
||||
function canRunSemanticValidation(value: unknown): boolean {
|
||||
@@ -113,6 +133,45 @@ function dedupeIssues(issues: ConfigValidationIssue[]): ConfigValidationIssue[]
|
||||
|
||||
export { parseDuration } from "./utils";
|
||||
|
||||
function isAbsolute(p: string): boolean {
|
||||
return p.startsWith("/") || /^[A-Za-z]:/.test(p);
|
||||
}
|
||||
|
||||
function resolveLogging(logging: LoggingConfig, dataDir: string, configDir: string): ResolvedLoggingConfig {
|
||||
const globalLevel = resolveLogLevel(logging.level, DEFAULT_LOG_LEVEL);
|
||||
const consoleLevel = resolveLogLevel(logging.console?.level, globalLevel);
|
||||
const fileLevel = resolveLogLevel(logging.file?.level, globalLevel);
|
||||
|
||||
const rawPath = logging.file?.path;
|
||||
const filePath = rawPath
|
||||
? isAbsolute(rawPath)
|
||||
? rawPath
|
||||
: resolve(configDir, rawPath)
|
||||
: resolve(dataDir, "logs/dial.log");
|
||||
|
||||
const rotationRaw = logging.file?.rotation;
|
||||
const rotationSizeRaw = rotationRaw?.size ?? DEFAULT_ROTATION_SIZE;
|
||||
const rotationSizeBytes = parseSize(rotationSizeRaw);
|
||||
const rotationFrequency = rotationRaw?.frequency ?? DEFAULT_ROTATION_FREQUENCY;
|
||||
const rotationMaxFiles = rotationRaw?.maxFiles ?? DEFAULT_ROTATION_MAX_FILES;
|
||||
|
||||
return {
|
||||
consoleLevel,
|
||||
fileLevel,
|
||||
filePath,
|
||||
rotationFrequency,
|
||||
rotationMaxFiles,
|
||||
rotationSizeBytes,
|
||||
rotationSizeRaw,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveLogLevel(level: unknown, fallback: LogLevel): LogLevel {
|
||||
if (!isString(level)) return fallback;
|
||||
if (VALID_LOG_LEVELS.includes(level as LogLevel)) return level as LogLevel;
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function resolveMaxConcurrentChecks(runtime: EngineRuntimeConfig): number {
|
||||
if (runtime.maxConcurrentChecks === undefined) return DEFAULT_MAX_CONCURRENT_CHECKS;
|
||||
if (
|
||||
@@ -263,3 +322,69 @@ function validateDurationValue(
|
||||
issues.push(issue("invalid-duration", path, error instanceof Error ? error.message : "时长格式不合法", targetName));
|
||||
}
|
||||
}
|
||||
|
||||
function validateLoggingConfig(logging: LoggingConfig | undefined, issues: ConfigValidationIssue[]): void {
|
||||
if (logging === undefined) return;
|
||||
|
||||
if (logging.level !== undefined && !VALID_LOG_LEVELS.includes(logging.level)) {
|
||||
issues.push(
|
||||
issue("invalid-value", "logging.level", `日志等级非法: "${logging.level}",支持: ${VALID_LOG_LEVELS.join(", ")}`),
|
||||
);
|
||||
}
|
||||
|
||||
if (logging.console?.level !== undefined && !VALID_LOG_LEVELS.includes(logging.console.level)) {
|
||||
issues.push(
|
||||
issue(
|
||||
"invalid-value",
|
||||
"logging.console.level",
|
||||
`日志等级非法: "${logging.console.level}",支持: ${VALID_LOG_LEVELS.join(", ")}`,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (logging.file?.level !== undefined && !VALID_LOG_LEVELS.includes(logging.file.level)) {
|
||||
issues.push(
|
||||
issue(
|
||||
"invalid-value",
|
||||
"logging.file.level",
|
||||
`日志等级非法: "${logging.file.level}",支持: ${VALID_LOG_LEVELS.join(", ")}`,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (logging.file?.path !== undefined) {
|
||||
if (!isString(logging.file.path) || logging.file.path.trim() === "") {
|
||||
issues.push(issue("invalid-value", "logging.file.path", "日志路径不能为空字符串或空白字符串"));
|
||||
}
|
||||
}
|
||||
|
||||
const rotation = logging.file?.rotation;
|
||||
if (rotation?.size !== undefined) {
|
||||
try {
|
||||
const bytes = parseSize(rotation.size);
|
||||
if (bytes <= 0) {
|
||||
issues.push(issue("invalid-value", "logging.file.rotation.size", "滚动大小必须为正整数字节数"));
|
||||
}
|
||||
} catch (error) {
|
||||
issues.push(
|
||||
issue("invalid-value", "logging.file.rotation.size", error instanceof Error ? error.message : "size 格式非法"),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (rotation?.frequency !== undefined && !VALID_ROTATION_FREQUENCIES.includes(rotation.frequency)) {
|
||||
issues.push(
|
||||
issue(
|
||||
"invalid-value",
|
||||
"logging.file.rotation.frequency",
|
||||
`滚动频率非法: "${rotation.frequency}",支持: ${VALID_ROTATION_FREQUENCIES.join(", ")}`,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (rotation?.maxFiles !== undefined) {
|
||||
if (!isNumber(rotation.maxFiles) || !Number.isInteger(rotation.maxFiles) || rotation.maxFiles <= 0) {
|
||||
issues.push(issue("invalid-value", "logging.file.rotation.maxFiles", "maxFiles 必须为正整数"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +1,18 @@
|
||||
import { groupBy, isError, Semaphore } from "es-toolkit";
|
||||
|
||||
import type { Logger } from "../logger";
|
||||
import type { ProbeStore } from "./store";
|
||||
import type { CheckResult, ResolvedTargetBase } from "./types";
|
||||
|
||||
import { createNoopLogger } from "../logger";
|
||||
import { errorFailure } from "./expect/failure";
|
||||
import { checkerRegistry } from "./runner";
|
||||
|
||||
const PRUNE_INTERVAL_MS = 3600000;
|
||||
|
||||
export class ProbeEngine {
|
||||
private lastMatched = new Map<string, boolean>();
|
||||
private logger: Logger;
|
||||
private retentionMs: number;
|
||||
private semaphore: Semaphore;
|
||||
private store: ProbeStore;
|
||||
@@ -16,12 +20,20 @@ export class ProbeEngine {
|
||||
private targets: ResolvedTargetBase[];
|
||||
private timers: Array<ReturnType<typeof setInterval>> = [];
|
||||
|
||||
constructor(store: ProbeStore, targets: ResolvedTargetBase[], maxConcurrentChecks?: number, retentionMs?: number) {
|
||||
constructor(
|
||||
store: ProbeStore,
|
||||
targets: ResolvedTargetBase[],
|
||||
maxConcurrentChecks?: number,
|
||||
retentionMs?: number,
|
||||
logger?: Logger,
|
||||
) {
|
||||
this.store = store;
|
||||
this.targets = targets;
|
||||
this.semaphore = new Semaphore(maxConcurrentChecks ?? 20);
|
||||
this.retentionMs = retentionMs ?? 0;
|
||||
this.logger = logger ?? createNoopLogger();
|
||||
this.refreshCache();
|
||||
this.initStateCache();
|
||||
}
|
||||
|
||||
start(): void {
|
||||
@@ -53,6 +65,49 @@ export class ProbeEngine {
|
||||
this.timers = [];
|
||||
}
|
||||
|
||||
private initStateCache(): void {
|
||||
const latestMap = this.store.getLatestChecksMap();
|
||||
for (const [id, row] of latestMap) {
|
||||
this.lastMatched.set(id, row.matched === 1);
|
||||
}
|
||||
}
|
||||
|
||||
private logCheckDebug(result: CheckResult): void {
|
||||
this.logger.debug({
|
||||
durationMs: result.durationMs,
|
||||
failureMessage: result.failure?.message ?? null,
|
||||
failurePhase: result.failure?.phase ?? null,
|
||||
matched: result.matched,
|
||||
targetId: result.targetId,
|
||||
});
|
||||
}
|
||||
|
||||
private logStateChange(result: CheckResult): void {
|
||||
const previous = this.lastMatched.get(result.targetId);
|
||||
const current = result.matched;
|
||||
|
||||
if (previous === undefined) {
|
||||
if (!current) {
|
||||
this.logger.warn(
|
||||
{ durationMs: result.durationMs, failurePhase: result.failure?.phase, targetId: result.targetId },
|
||||
`目标首次 DOWN: ${result.targetId}`,
|
||||
);
|
||||
}
|
||||
} else if (previous && !current) {
|
||||
this.logger.warn(
|
||||
{ durationMs: result.durationMs, failurePhase: result.failure?.phase, targetId: result.targetId },
|
||||
`目标状态变化 UP → DOWN: ${result.targetId}`,
|
||||
);
|
||||
} else if (!previous && current) {
|
||||
this.logger.info(
|
||||
{ durationMs: result.durationMs, targetId: result.targetId },
|
||||
`目标恢复 DOWN → UP: ${result.targetId}`,
|
||||
);
|
||||
}
|
||||
|
||||
this.lastMatched.set(result.targetId, current);
|
||||
}
|
||||
|
||||
private async probeGroup(targets: ResolvedTargetBase[]): Promise<void> {
|
||||
const results = await Promise.allSettled(
|
||||
targets.map(async (target) => {
|
||||
@@ -68,19 +123,25 @@ export class ProbeEngine {
|
||||
for (const [index, result] of results.entries()) {
|
||||
if (result.status === "fulfilled") {
|
||||
this.writeResult(result.value);
|
||||
this.logStateChange(result.value);
|
||||
this.logCheckDebug(result.value);
|
||||
} else {
|
||||
const target = targets[index];
|
||||
console.warn(`探针执行失败: ${formatReason(result.reason)}`);
|
||||
if (!target) continue;
|
||||
this.writeResult({
|
||||
detail: null,
|
||||
durationMs: null,
|
||||
failure: errorFailure("internal", "engine", formatReason(result.reason)),
|
||||
matched: false,
|
||||
observation: null,
|
||||
targetId: target.id,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
if (target) {
|
||||
this.logger.error(
|
||||
{ reason: formatReason(result.reason), targetId: target.id, targetType: target.type },
|
||||
`探针执行失败: ${formatReason(result.reason)}`,
|
||||
);
|
||||
this.writeResult({
|
||||
detail: null,
|
||||
durationMs: null,
|
||||
failure: errorFailure("internal", "engine", formatReason(result.reason)),
|
||||
matched: false,
|
||||
observation: null,
|
||||
targetId: target.id,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,9 +10,13 @@ import {
|
||||
createRawValueExpectationSchema,
|
||||
createValueMatcherObjectSchema,
|
||||
durationSchema,
|
||||
sizeSchema,
|
||||
variableValueSchema,
|
||||
} from "./fragments";
|
||||
|
||||
const LOG_LEVELS = ["trace", "debug", "info", "warn", "error", "fatal"] as const;
|
||||
const ROTATION_FREQUENCIES = ["hourly", "daily", "weekly"] as const;
|
||||
|
||||
export function createExternalProbeConfigSchema(checkers: CheckerDefinition[]): Record<string, unknown> {
|
||||
return {
|
||||
...cloneSchema(createProbeConfigSchema(checkers, true)),
|
||||
@@ -31,6 +35,7 @@ export function createProbeConfigSchema(checkers: CheckerDefinition[], external
|
||||
return Type.Object(
|
||||
{
|
||||
defaults: Type.Optional(createDefaultsSchema(checkers)),
|
||||
logging: Type.Optional(createLoggingSchema()),
|
||||
runtime: Type.Optional(
|
||||
Type.Object(
|
||||
{
|
||||
@@ -107,3 +112,35 @@ function createDefaultsSchema(checkers: CheckerDefinition[]): TSchema {
|
||||
function createExternalTargetSchema(checkers: CheckerDefinition[]): TSchema {
|
||||
return Type.Union(checkers.map((checker) => createTargetSchema(checker)) as [TSchema, ...TSchema[]]);
|
||||
}
|
||||
|
||||
function createLoggingSchema(): TSchema {
|
||||
const logLevelSchema = Type.Union(LOG_LEVELS.map((l) => Type.Literal(l)) as unknown as [TSchema, ...TSchema[]]);
|
||||
return Type.Object(
|
||||
{
|
||||
console: Type.Optional(Type.Object({ level: Type.Optional(logLevelSchema) }, { additionalProperties: false })),
|
||||
file: Type.Optional(
|
||||
Type.Object(
|
||||
{
|
||||
level: Type.Optional(logLevelSchema),
|
||||
path: Type.Optional(Type.String({ minLength: 1 })),
|
||||
rotation: Type.Optional(
|
||||
Type.Object(
|
||||
{
|
||||
frequency: Type.Optional(
|
||||
Type.Union(ROTATION_FREQUENCIES.map((f) => Type.Literal(f)) as unknown as [TSchema, ...TSchema[]]),
|
||||
),
|
||||
maxFiles: Type.Optional(Type.Integer({ minimum: 1 })),
|
||||
size: Type.Optional(sizeSchema),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
),
|
||||
),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
),
|
||||
),
|
||||
level: Type.Optional(logLevelSchema),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
}
|
||||
|
||||
@@ -17,8 +17,33 @@ export interface EngineRuntimeConfig {
|
||||
|
||||
export type JsonValue = boolean | JsonValue[] | null | number | string | { [key: string]: JsonValue };
|
||||
|
||||
export interface LoggingConfig {
|
||||
console?: LoggingConsoleConfig;
|
||||
file?: LoggingFileConfig;
|
||||
level?: LogLevel;
|
||||
}
|
||||
|
||||
export interface LoggingConsoleConfig {
|
||||
level?: LogLevel;
|
||||
}
|
||||
|
||||
export interface LoggingFileConfig {
|
||||
level?: LogLevel;
|
||||
path?: string;
|
||||
rotation?: LoggingFileRotationConfig;
|
||||
}
|
||||
|
||||
export interface LoggingFileRotationConfig {
|
||||
frequency?: RotationFrequency;
|
||||
maxFiles?: number;
|
||||
size?: string;
|
||||
}
|
||||
|
||||
export type LogLevel = "debug" | "error" | "fatal" | "info" | "trace" | "warn";
|
||||
|
||||
export interface ProbeConfig {
|
||||
defaults?: DefaultsConfig;
|
||||
logging?: LoggingConfig;
|
||||
runtime?: EngineRuntimeConfig;
|
||||
server?: ServerConfig;
|
||||
targets: RawTargetConfig[];
|
||||
@@ -37,6 +62,16 @@ export interface RawTargetConfig {
|
||||
type: string;
|
||||
}
|
||||
|
||||
export interface ResolvedLoggingConfig {
|
||||
consoleLevel: LogLevel;
|
||||
fileLevel: LogLevel;
|
||||
filePath: string;
|
||||
rotationFrequency: RotationFrequency;
|
||||
rotationMaxFiles: number;
|
||||
rotationSizeBytes: number;
|
||||
rotationSizeRaw: string;
|
||||
}
|
||||
|
||||
export interface ResolvedTargetBase {
|
||||
[key: string]: unknown;
|
||||
description: null | string;
|
||||
@@ -50,6 +85,8 @@ export interface ResolvedTargetBase {
|
||||
type: string;
|
||||
}
|
||||
|
||||
export type RotationFrequency = "daily" | "hourly" | "weekly";
|
||||
|
||||
export interface ServerConfig {
|
||||
dataDir?: string;
|
||||
host?: string;
|
||||
|
||||
Reference in New Issue
Block a user