fix: 替换所有中文括号为英文括号
feat: 新增操作审计字段 user/action/target/result 到 syslog 输出 docs: 更新 README 输出格式和配置示例说明
This commit is contained in:
@@ -4,9 +4,9 @@
|
||||
db_log_exporter — 数据库日志导出守护进程
|
||||
|
||||
从 MySQL / PostgreSQL 数据库表中定时提取日志,写入标准 syslog 格式文本文件。
|
||||
支持每个数据源独立跟踪偏移量(断点续传),线程安全,无重复日志。
|
||||
支持每个数据源独立跟踪偏移量(断点续传),线程安全,无重复日志。
|
||||
|
||||
标准输出格式(RFC 5424 变体,每行一条):
|
||||
标准输出格式(RFC 5424 变体,每行一条):
|
||||
<Jan 1 12:00:00> <hostname> <app_name>[<pid>]: <priority><version> <timestamp_iso> <hostname> <app_name> <pid> <msg_id> <structured_data> <message>
|
||||
|
||||
作者:QClaw
|
||||
@@ -68,7 +68,7 @@ SYSLOG_PRIORITY = {
|
||||
"FATAL": "<2>",
|
||||
}
|
||||
|
||||
# 默认字段映射(当配置中未指定时使用)
|
||||
# 默认字段映射(当配置中未指定时使用)
|
||||
DEFAULT_COLUMN_MAP = {
|
||||
"id": "id",
|
||||
"timestamp": "created_at",
|
||||
@@ -80,9 +80,14 @@ DEFAULT_COLUMN_MAP = {
|
||||
"trace_id": "trace_id",
|
||||
"span_id": "span_id",
|
||||
"extra": "extra",
|
||||
# operation audit fields
|
||||
"user": "user",
|
||||
"action": "action",
|
||||
"target": "target",
|
||||
"result": "result",
|
||||
}
|
||||
|
||||
DEFAULT_INTERVAL = 30 # 默认轮询间隔(秒)
|
||||
DEFAULT_INTERVAL = 30 # 默认轮询间隔(秒)
|
||||
DEFAULT_BATCH = 1000 # 默认每次最多读取条数
|
||||
DEFAULT_HOSTNAME = socket.gethostname()
|
||||
|
||||
@@ -104,7 +109,7 @@ def setup_logging(level: str = "INFO", log_file: Optional[str] = None) -> loggin
|
||||
ch.setFormatter(logging.Formatter(fmt, datefmt))
|
||||
logger.addHandler(ch)
|
||||
|
||||
# 文件(可选)
|
||||
# 文件(可选)
|
||||
if log_file:
|
||||
fh = logging.FileHandler(log_file, encoding="utf-8")
|
||||
fh.setFormatter(logging.Formatter(fmt, datefmt))
|
||||
@@ -134,22 +139,29 @@ def format_syslog_line(
|
||||
trace_id: str = "",
|
||||
span_id: str = "",
|
||||
extra: str = "",
|
||||
user: str = "",
|
||||
action: str = "",
|
||||
target: str = "",
|
||||
result: str = "",
|
||||
) -> str:
|
||||
"""
|
||||
格式化为 RFC 5424 变体 syslog 行。
|
||||
格式:
|
||||
<priority>version timestamp hostname appname pid msgid structured_data message
|
||||
|
||||
示例(完整一行):
|
||||
<6>1 2026-01-01T12:00:00.123456+08:00 myhost myapp[12345]: [trace=abc] message text
|
||||
示例(完整一行):
|
||||
<6>1 2026-01-01T12:00:00.123456+08:00 myhost myapp[12345]: [user=张三] [action=删除订单] [target=订单系统] [result=成功] message text
|
||||
"""
|
||||
pri = get_syslog_priority(level)
|
||||
# 去掉 level 字符串两端方括号(如果有)
|
||||
clean_msg = message.strip()
|
||||
parts = [f"[{k}={v}]" for k, v in [
|
||||
("trace", trace_id),
|
||||
("span", span_id),
|
||||
("extra", extra),
|
||||
("user", user),
|
||||
("action", action),
|
||||
("target", target),
|
||||
("result", result),
|
||||
] if v]
|
||||
structured = " ".join(parts)
|
||||
if structured:
|
||||
@@ -167,14 +179,14 @@ def acquire_lock_file(lock_path: str) -> int:
|
||||
fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
except BlockingIOError:
|
||||
os.close(fd)
|
||||
raise RuntimeError(f"另一个 {APP_NAME} 实例已在运行(锁文件:{lock_path}),请先停止。")
|
||||
raise RuntimeError(f"另一个 {APP_NAME} 实例已在运行(锁文件:{lock_path}),请先停止。")
|
||||
return fd
|
||||
|
||||
|
||||
def load_checkpoint(checkpoint_dir: str, source_name: str) -> Optional[Any]:
|
||||
"""
|
||||
加载断点文件,返回 last_id(可以是 int、datetime 或任意可序列化值)。
|
||||
若文件不存在,返回 None(从头开始拉取)。
|
||||
加载断点文件,返回 last_id(可以是 int、datetime 或任意可序列化值)。
|
||||
若文件不存在,返回 None(从头开始拉取)。
|
||||
"""
|
||||
path = Path(checkpoint_dir) / f"{source_name}.json"
|
||||
if not path.exists():
|
||||
@@ -229,7 +241,7 @@ def build_mysql_query(cfg: Dict[str, Any], col_map: Dict[str, str],
|
||||
ts_col = col_map["timestamp"]
|
||||
lvl_col = col_map["level"]
|
||||
msg_col = col_map["message"]
|
||||
# 构造 SELECT 列(处理别名)
|
||||
# 构造 SELECT 列(处理别名)
|
||||
extra_cols = []
|
||||
for alias, db_col in col_map.items():
|
||||
if alias not in ("id", "timestamp", "level", "message") and db_col:
|
||||
@@ -262,7 +274,7 @@ def build_mysql_query(cfg: Dict[str, Any], col_map: Dict[str, str],
|
||||
|
||||
def build_pg_query(cfg: Dict[str, Any], col_map: Dict[str, str],
|
||||
last_id: Optional[int], batch: int) -> Tuple[str, Tuple]:
|
||||
"""构建 PostgreSQL 查询语句(语法与 MySQL 基本兼容,%s 替换)"""
|
||||
"""构建 PostgreSQL 查询语句(语法与 MySQL 基本兼容,%s 替换)"""
|
||||
return build_mysql_query(cfg, col_map, last_id, batch) # 参数风格相同
|
||||
|
||||
|
||||
@@ -366,7 +378,7 @@ class LogSource:
|
||||
self.app_name = table_config.get("app_name", name)
|
||||
self.pid_str = str(os.getpid())
|
||||
self.col_map = merge_column_map(table_config.get("columns"))
|
||||
self.filter_query = table_config.get("filter", "") # WHERE 子句(不含 WHERE)
|
||||
self.filter_query = table_config.get("filter", "") # WHERE 子句(不含 WHERE)
|
||||
self.log_file_pattern = table_config.get("log_file", f"{name}.log")
|
||||
|
||||
self._last_id: Optional[Any] = None
|
||||
@@ -420,7 +432,7 @@ class LogSource:
|
||||
# 有新日志,短暂 sleep 避免空转
|
||||
self._stop_evt.wait(min(self.interval, 2.0))
|
||||
except DatabaseError as e:
|
||||
logger.error("[%s] 数据库错误(%s),%ds 后重试: %s", self.name, self.db_type, self.interval, e)
|
||||
logger.error("[%s] 数据库错误(%s),%ds 后重试: %s", self.name, self.db_type, self.interval, e)
|
||||
self._stop_evt.wait(self.interval)
|
||||
except Exception as e:
|
||||
logger.exception("[%s] 未知异常,%ds 后重试: %s", self.name, self.interval, e)
|
||||
@@ -450,7 +462,7 @@ class LogSource:
|
||||
elif self.db_type == "postgresql" or self.db_type == "postgres":
|
||||
return get_rows_pg(self.db_config, self.col_map, self._last_id, self.batch_size)
|
||||
else:
|
||||
raise ValueError(f"不支持的数据库类型: {self.db_type}(仅支持 mysql/postgresql)")
|
||||
raise ValueError(f"不支持的数据库类型: {self.db_type}(仅支持 mysql/postgresql)")
|
||||
|
||||
def _write_rows(self, rows: List[Dict]) -> None:
|
||||
"""将行数据写入日志文件"""
|
||||
@@ -483,6 +495,10 @@ class LogSource:
|
||||
tid_val = str(row.get(self.col_map.get("trace_id", "")) or "")
|
||||
sid_val = str(row.get(self.col_map.get("span_id", "")) or "")
|
||||
ext_val = str(row.get(self.col_map.get("extra", "")) or "")
|
||||
usr_val = str(row.get(self.col_map.get("user", "") or "") or "")
|
||||
act_val = str(row.get(self.col_map.get("action", "") or "") or "")
|
||||
tgt_val = str(row.get(self.col_map.get("target", "") or "") or "")
|
||||
res_val = str(row.get(self.col_map.get("result", "") or "") or "")
|
||||
|
||||
# 处理时间戳格式
|
||||
timestamp = self._format_timestamp(ts_val)
|
||||
@@ -497,11 +513,15 @@ class LogSource:
|
||||
trace_id = tid_val,
|
||||
span_id = sid_val,
|
||||
extra = ext_val,
|
||||
user = usr_val,
|
||||
action = act_val,
|
||||
target = tgt_val,
|
||||
result = res_val,
|
||||
)
|
||||
|
||||
def _format_timestamp(self, val: Any) -> str:
|
||||
"""
|
||||
将任意时间格式转为 ISO 8601 格式(带时区)。
|
||||
将任意时间格式转为 ISO 8601 格式(带时区)。
|
||||
支持: datetime / date / 字符串 / unix timestamp(float/int) / None
|
||||
"""
|
||||
if val is None:
|
||||
@@ -580,7 +600,7 @@ def create_parser() -> argparse.ArgumentParser:
|
||||
epilog="""
|
||||
示例:
|
||||
%(prog)s -c /etc/db_log_exporter/config.yaml
|
||||
%(prog)s -c config.yaml --once # 运行一次(不守护)并退出
|
||||
%(prog)s -c config.yaml --once # 运行一次(不守护)并退出
|
||||
%(prog)s -c config.yaml --dry-run # 仅连接测试,不写入文件
|
||||
%(prog)s -c config.yaml --log-level DEBUG
|
||||
""",
|
||||
@@ -588,14 +608,14 @@ def create_parser() -> argparse.ArgumentParser:
|
||||
parser.add_argument("-c", "--config", required=True,
|
||||
help="YAML 配置文件路径")
|
||||
parser.add_argument("--once", action="store_true",
|
||||
help="仅执行一次轮询后退出(不守护)")
|
||||
help="仅执行一次轮询后退出(不守护)")
|
||||
parser.add_argument("--dry-run", action="store_true",
|
||||
help="仅测试数据库连接,不写入日志文件")
|
||||
parser.add_argument("--log-level", default="INFO",
|
||||
choices=["DEBUG", "INFO", "WARNING", "ERROR"],
|
||||
help="本程序的日志级别(默认 INFO)")
|
||||
help="本程序的日志级别(默认 INFO)")
|
||||
parser.add_argument("--log-file",
|
||||
help="本程序的日志输出文件(默认仅输出到 stdout)")
|
||||
help="本程序的日志输出文件(默认仅输出到 stdout)")
|
||||
return parser
|
||||
|
||||
|
||||
@@ -628,7 +648,7 @@ def main() -> int:
|
||||
logger.warning("配置中没有发现任何数据源,请检查配置文件。")
|
||||
return 0
|
||||
|
||||
# 确保必要目录存在(如果以 root 运行时)
|
||||
# 确保必要目录存在(如果以 root 运行时)
|
||||
for d in [config.get("global", {}).get("output_dir", "/var/log/db_exporter"),
|
||||
config.get("global", {}).get("checkpoint_dir", "/var/lib/db_exporter/checkpoints")]:
|
||||
try:
|
||||
@@ -643,20 +663,20 @@ def main() -> int:
|
||||
for src in sources:
|
||||
try:
|
||||
rows, _ = src._fetch()
|
||||
logger.info("[%s] ✅ 连接成功,当前有新日志 %d 条(未写入)", src.name, len(rows))
|
||||
logger.info("[%s] ✅ 连接成功,当前有新日志 %d 条(未写入)", src.name, len(rows))
|
||||
except Exception as e:
|
||||
logger.error("[%s] ❌ 连接失败: %s", src.name, e)
|
||||
ok = False
|
||||
return 0 if ok else 1
|
||||
|
||||
# 获取锁文件(防止重复启动)
|
||||
# 获取锁文件(防止重复启动)
|
||||
lock_file = f"/var/run/{APP_NAME}/{APP_NAME}.lock"
|
||||
lock_fd = None
|
||||
try:
|
||||
Path(lock_file).parent.mkdir(parents=True, exist_ok=True)
|
||||
lock_fd = acquire_lock_file(lock_file)
|
||||
except PermissionError:
|
||||
logger.warning("无权限创建锁文件 %s,跳过锁定检测(可能重复启动)", lock_file)
|
||||
logger.warning("无权限创建锁文件 %s,跳过锁定检测(可能重复启动)", lock_file)
|
||||
except RuntimeError as e:
|
||||
logger.error("%s", e)
|
||||
return 1
|
||||
@@ -689,7 +709,7 @@ def main() -> int:
|
||||
src.stop(timeout=5.0)
|
||||
logger.info("=== 单次轮询完成,退出 ===")
|
||||
else:
|
||||
# 守护进程主循环(防止主线程退出)
|
||||
# 守护进程主循环(防止主线程退出)
|
||||
while True:
|
||||
time.sleep(3600)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user