
简介Tracking Master 是一款面向 Bludit Flat File CMS 的开源网络分析跟踪插件主要服务于个人站长、小型网站开发者以及重视数据自主可控的使用者。它针对无数据库轻量站点在访问统计与用户行为分析上的需求可记录访问量、页面浏览、来源渠道等信息并兼容多供应商分析服务便于按场景选择统计方案。压缩包共 12 个文件约 17KB包含 JSON 配置与元数据、PHP 主程序、CSS 样式、语言资源、图片、说明文档及许可证虽体量精简却覆盖插件运行、界面呈现和配置说明所需的基本模块。目前已有 481 人学习下载适合用作理解 Bludit 插件结构、分析服务接入和轻量统计实现的参考。读者可从中梳理插件目录组织、多语言与样式资源的配合方式并依据开源许可进行二次定制以较低部署成本增强站点分析能力。1. 网络多供应商数据怎么统一Tracking Master 解决的那点事机房里三排机柜、四家厂商的设备出故障时你得同时打开四个管理后台日志格式各写各的时间还差着十几秒一条链路抖动的完整链路拼都拼不起来——这活干一次骂一次。Tracking Master 就是冲这个场景来的一个开源的多供应商网络分析跟踪器把异构设备的 syslog、SNMP 轮询、流量会话收进同一条流水线归一化成统一事件与实体轨迹再落到可视化看板上。它适合手上有异构网络、又不想被单一厂商网管绑死的运维和二开同学也适合想学插件式采集架构怎么落地的人。2. 技术栈与目录结构四层拆分让采集和解析各干各的2.1 为什么拆成采集、归一化、存储、查询四层刚接触这类系统时最容易犯的错是把收日志、解析、入库、查接口全塞进一个进程里。本地跑着挺爽一上生产就原形毕露某台核心交换机重启瞬间吐出几千条 syslog主线程被正则吃满HTTP 接口跟着超时看板一片红——其实底层设备压根没事是你自己的采集器先崩了。Tracking Master 的分层思路很朴素四层各自只认一种数据结构采集层collector只负责“拿到东西”输出RawRecord原始文本、来源 IP、到达时间、设备 ID。它不知道什么叫链路状态也不做任何业务判断。归一化层normalize只负责“翻译”输入RawRecord输出NormalizedEvent。这一层是纯函数式的给定同样的输入必然得到同样的输出所以单元测试特别好写。存储层store只负责“落地”认NormalizedEvent往时序表和关系表里写顺带做去重和聚合。查询层api只负责“取出来”对外暴露 REST 和 WebSocket不碰解析逻辑。层与层之间靠两个 Pydantic 模型传递这个约定非常重要。我见过不少二次开发把厂商私有字段直接塞进事件模型的主字段里结果加第二个厂商时字段名打架最后整张表重建。Tracking Master 的做法是把厂商特有的东西统统塞进attrs这个 JSONB 字段主模型只保留ts / kind / severity / vendor / device_id / entity六个通用维度加厂商不用改表结构。另一个容易被忽略的点是时钟。采集层拿到的是“本机收到的时间”归一化层要的是“设备认为发生的时间”两者在网络抖动时能差出好几秒。四层拆分后时钟对齐放在归一化层做主流程不用感知这是后面避坑章节要重点说的地方。2.2 目录结构与模块职责工程是标准的 Pythonsrc布局适配器单独抽成一个可插拔目录加厂商基本只动adapters/下一级目录路径职责二开时改动频率src/tracking_master/adapters/base.py适配器抽象基类定义五个必须实现的方法极低除非要加新能力类型src/tracking_master/adapters/registry.py按vendor名发现并实例化适配器低src/tracking_master/adapters/vendor_a/A 厂设备的解析与轮询实现高src/tracking_master/adapters/generic_snmp/通用 SNMP 采集走标准 MIB-2 OID中常改 OID 表src/tracking_master/collector/调度器、连接池、背压控制、syslog 监听中src/tracking_master/normalize/归一化管道、时钟对齐、去重、单位换算高src/tracking_master/store/ORM 模型、时序写入、连续聚合视图中src/tracking_master/graph/拓扑构建与 MAC/IP 轨迹拼装中src/tracking_master/api/REST 路由、WebSocket 推送中src/tracking_master/rules/阈值与告警规则纯配置驱动高web/Vue 3 Vite 前端ECharts 画拓扑和时序中deploy/compose 文件、Alembic 迁移、环境变量样例低tests/适配器快照测试、管道单测高registry.py用的是“目录即插件”的约定式发现扫描adapters/下每个包含adapter.py且定义了Adapter类的子目录读取类属性vendor作为注册键。好处是加厂商不用改任何注册代码坏处是拼错目录名会静默失败——所以启动日志里会打一行已注册的 vendor 列表第一次接新厂商时先看这行。2.3 依赖清单与关键配置项技术栈是 Python 3.10 那一套没有特别花哨的东西选型基本围绕“异构数据 时间序列”这两个特征组件用途选型理由FastAPIREST WebSocket异步原生和采集侧的 asyncio 同一套事件循环Pydantic v2事件模型校验校验快序列化直接吐 JSONBSQLAlchemy 2.x AlembicORM 与迁移迁移脚本进版本库二开加字段不慌TimescaleDB事件与会话时序存储就是 PostgreSQL 加超表SQL 生态不用重学Redis去重指纹、采集任务锁、看板缓存一个组件干三件事少一个运维对象APScheduler轮询调度支持抖动jitter避免全网设备同时被轮询Vue 3 ECharts看板拓扑图和大箭头图开箱可用配置文件集中在一个config/adapters.yaml里环境相关的密钥走环境变量插值不明文进仓库配置项默认值说明collectors[].modepollpoll主动轮询syslog被动监听flow收 NetFlow/sFlowcollectors[].interval60轮询周期秒高频指标建议不低于 30collectors[].jitter0.2调度抖动比例全网设备按 ±20% 打散normalize.dedup_ttl180去重窗口秒小于设备重传间隔normalize.clock_drift_max120允许的最大时钟偏移秒超出直接标记异常store.retention_days30原始事件保留天数聚合视图不受影响jitter这个参数是血泪经验换来的。早期版本没做抖动两百多台设备整点同时被轮询管理面 CPU 直接冲顶网络没坏但设备先受不了。加上抖动之后曲线立刻平了这个改动比任何优化都划算。3. 供应商适配层拆解一条 syslog 如何变成统一事件3.1 BaseAdapter 抽象五个方法定死插件边界适配器是整个项目最需要克制的部分。写得太自由每个厂商各写一套三个月后没人敢动约束太死遇到只上报 syslog 不响应 SNMP 的设备又没法接。Tracking Master 的折中是五个方法里两个必须实现其余给默认实现。# src/tracking_master/adapters/base.py from __future__ import annotations from abc import ABC, abstractmethod from typing import AsyncIterator, ClassVar from tracking_master.models import NormalizedEvent, RawRecord class BaseAdapter(ABC): # 注册键必须全局唯一会原样写进事件的 vendor 字段 vendor: ClassVar[str] unknown # 能力声明syslog / snmp / flow / api调度器据此决定走哪种采集模式 capabilities: ClassVar[set[str]] set() # 未在配置里指定周期时的兜底轮询间隔秒 default_interval: ClassVar[int] 60 abstractmethod async def poll(self) - AsyncIterator[RawRecord]: 主动采集入口。被动接收型设备只发 syslog直接 return 空迭代器。 abstractmethod def parse(self, raw: RawRecord) - list[NormalizedEvent]: 把一条原始记录拆成零到多条统一事件。必须是纯函数方便快照测试。 def normalize(self, event: NormalizedEvent) - NormalizedEvent: 单位换算与字段清洗默认原样返回子类按需覆盖。 return event async def healthcheck(self) - bool: 采集前探活默认认为健康有管理面 API 的设备建议覆盖。 return True def map_entity(self, raw: RawRecord, key: str, type_: str) - dict: 把厂商侧的实体名映射成统一实体键默认不做转换。 return {type: type_, key: key}poll返回异步迭代器而不是列表是为了让 SNMP 这种按表分批取的模式能流式吐数据不必一次性把几千行塞进内存。parse强制纯函数是硬约束一旦有人在parse里查数据库或发 HTTP 请求快照测试就废了后面所有回归都靠不住。capabilities这个声明看似多余实际在调度器里起决定作用。配置里写mode: snmp但适配器没声明snmp能力时启动阶段就会报错退出而不是等到运行时空转半小时才发现一条数据都没采到。3.2 从厂商 A 的 syslog 到统一事件模型先看统一事件模型长什么样字段不多但每个都有明确归属# src/tracking_master/models.py from datetime import datetime from typing import Any, Literal from pydantic import BaseModel, Field class EntityRef(BaseModel): type: Literal[device, port, mac, ip, session] key: str # 复合键习惯写成 设备ID:端口名保证跨厂商可对齐 class NormalizedEvent(BaseModel): ts: datetime # 一律 UTC采集侧不落本地时区 kind: str # link_state / flow / session / metric severity: Literal[info, warning, critical] info vendor: str device_id: str entity: EntityRef attrs: dict[str, Any] Field(default_factorydict) # 厂商私有字段全放这里 source: str syslog def fingerprint(self) - str: 去重指纹设备 实体 类型 状态刻意不含时间戳。 state self.attrs.get(state, ) return f{self.device_id}|{self.entity.key}|{self.kind}|{state}attrs用 JSONB 存是这套设计里最关键的一步。MAC 地址、端口索引、VLAN、会话五元组这些字段在不同厂商之间语义对不齐强行建列会导致表宽到无法维护。放进 JSONB 之后TimescaleDB 照样能建 GIN 索引按attrs-mac查代价可控。A 厂的解析实现核心就是一个正则加一段映射# src/tracking_master/adapters/vendor_a/adapter.py import re from tracking_master.adapters.base import BaseAdapter from tracking_master.models import EntityRef, NormalizedEvent, RawRecord # A 厂设备 syslog 样例 # 134Mar 12 09:41:07 SW-01 PORT: port 1/0/12 link down, mac 00:1a:2b:3c:4d:5e LINE re.compile( rport (?Pport\S) link (?Pstateup|down).*?mac (?Pmac[0-9a-f:]{17}), re.IGNORECASE, ) class Adapter(BaseAdapter): vendor vendor_a capabilities {syslog} default_interval 30 async def poll(self): # 该系列只上报 syslog主动采集留空 return yield def parse(self, raw: RawRecord) - list[NormalizedEvent]: m LINE.search(raw.message) if not m: return [] # 解析失败不抛异常交给上层统计 parse_miss 指标 state m[state].lower() return [ NormalizedEvent( tsraw.received_at, kindlink_state, severitywarning if state down else info, vendorself.vendor, device_idraw.device_id, entityEntityRef(typeport, keyf{raw.device_id}:{m[port]}), attrs{mac: m[mac].lower(), state: state}, sourcesyslog, ) ] def normalize(self, event: NormalizedEvent) - NormalizedEvent: # A 厂会把 MAC 写成大写加连字符的变体这里统一成小写冒号分隔 mac event.attrs.get(mac, ) event.attrs[mac] mac.replace(-, :).lower() return event三个细节值得掰开说。第一parse解析失败返回空列表而不是抛异常因为 syslog 里混着大量调试信息和厂商内部日志一条不匹配就中断整批是会真出事故的上层按parse_miss_total计数并配告警阈值比抛异常可控。第二端口实体键用设备ID:端口名的复合形式不同设备的1/0/12才不会被混成一个东西。第三normalize里处理 MAC 格式差异把厂商怪癖挡在存储层之外看板上就不用做二次清洗。3.3 去重、时钟对齐与轨迹拼装单条事件归一化完还只是半成品真正让数据“能用”的是管道里的三件套时钟对齐、去重、轨迹拼装。# src/tracking_master/normalize/pipeline.py async def run(raw_batch, adapter, store, dedup, clock): events [] for raw in raw_batch: for ev in adapter.parse(raw): ev adapter.normalize(ev) # 用滑动中位数估设备时钟偏移超出阈值的事件打标不丢弃 offset await clock.estimate(raw.device_id, raw.received_at) ev.ts raw.received_at - offset if offset else ev.ts ev.attrs[clock_offset_s] offset or 0 # 指纹去重TTL 内重复上报直接丢弃 if await dedup.seen(ev.fingerprint(), ttl180): continue events.append(ev) if events: await store.write_events(events) return eventsclock.estimate用的是笨办法但够用把同一设备最近若干条“设备时间 - 到达时间”的差值取中位数作为当前偏移估计。之所以用中位数而不是平均值是因为网络里偶尔会有一条卡了十几秒才到的日志平均值会被这种离群点带偏。ttl180是对着设备的重传间隔调的常见设备 syslog 重传窗口在 60 到 120 秒之间取 180 有冗余但不至于把正常的连续抖动吃掉。轨迹拼装走的是另一条路输入是 MAC输出是它在时间窗内经过的端口序列# src/tracking_master/graph/trace.py async def build_path(store, mac: str, window: str 10 minutes): rows await store.query_mac_sightings(mac, window) path, last_hop [], None for r in rows: hop (r[device_id], r[port]) if hop last_hop: continue # 同一跳重复出现只保留最早一条 path.append({device: hop[0], port: hop[1], ts: r[ts]}) last_hop hop return pathwindow参数是给“终端在楼里走动”这种场景留的无线环境一般给 10 分钟内够用有线接入层排查环路时我会调到 2 分钟窗口太长会把上一次接入的位置混进来路径看着像瞬移。last_hop去重是为了处理同一端口反复上报的情况否则一条轨迹里全是同一个端口看图的人会以为在抖。4. 跑起来本地起服务、接第一个数据源、看第一条轨迹4.1 依赖起服务compose 加迁移加两个进程整套东西要跑起来只需要 PostgreSQL带 TimescaleDB 扩展和 Redis 两个外部依赖compose 文件里都备好了# 1. 取代码进目录 git clone 仓库地址 tracking-master cd tracking-master # 2. 准备环境变量样例里有数据库、Redis、SNMP community 的占位 cp deploy/env.example .env # 3. 只起外部依赖主服务留在本机跑方便改代码热重载 docker compose -f deploy/docker-compose.yml up -d timescaledb redis # 4. 建虚拟环境并装依赖[dev] 里带 pytest 和 ruff python -m venv .venv source .venv/bin/activate pip install -e .[dev] # 5. 建表并转成超表迁移脚本会顺手建好连续聚合视图 alembic upgrade head注意第 3 步只起依赖不把应用塞进容器。排查解析问题时要反复改adapters/下的代码容器里改一行重建一次镜像一天下来时间全耗在构建上。等适配器写完了再整体打包节奏更顺。服务是双进程API 和采集器分开跑一个挂了不影响另一个。# 终端 AAPI 与 WebSocket默认 8000 端口 python -m tracking_master.api.main --host 0.0.0.0 --port 8000 # 终端 B采集器按 config/adapters.yaml 起所有 collector python -m tracking_master.collector.runner --config config/adapters.yaml --log-level INFO启动后先看采集器日志里那行registered adapters: [vendor_a, vendor_b, generic_snmp]。这行没出现你想要的 vendor就是目录名或类名写错了别急着往下走。4.2 接第一个数据源syslog 监听与 SNMP 轮询配置最省事的接入方式是先用 syslog。配置文件里加一段把监听端口指到 5514避免和宿主机已有的 514 冲突# config/adapters.yaml collectors: - vendor: vendor_a mode: syslog listen: 0.0.0.0:5514 devices: - id: sw-access-01 host: 10.0.0.21 # 用来和来源 IP 做校验防止串设备 - vendor: generic_snmp mode: poll interval: 30 jitter: 0.2 devices: - id: sw-core-01 host: 10.0.0.11 version: 2c community: ${SNMP_COMMUNITY} oids: if_in_octets: 1.3.6.1.2.1.31.1.1.1.6 if_out_octets: 1.3.6.1.2.1.31.1.1.1.10几个参数值得留意。devices[].id是全局唯一标识事件的device_id直接取它所以命名规则最好一次定死比如“位置-角色-序号”后面改起来涉及存量数据。host在 syslog 模式下不是用来连的是用来校验来源 IP 的防止别的设备把日志发到这个端口造成串数据。generic_snmp用的是标准 MIB-2 的ifXTableOID64 位计数器比老的ifTable好在不会因为流量大而在 32 位计数器上回绕实测千兆口跑满时这个差别很明显。手动灌一条测试日志验证链路不用真去拔网线# 模拟 A 厂设备发一条 link down注意用 UDPsyslog 默认无连接 echo 134Mar 12 09:41:07 SW-01 PORT: port 1/0/12 link down, mac 00:1A:2B:3C:4D:5E \ | nc -u -w1 127.0.0.1 5514发完之后采集器日志里应该出现一条parsed1 missed0的统计行。如果missed1说明正则没匹配上把这条原文复制到tests/fixtures/vendor_a/下当样本先在单测里调通再回来看运行时比在服务里打断点快得多。4.3 验证链路接口、看板与自检指标数据进没进去别看数据库先看接口# 查最近 15 分钟 sw-access-01 的事件jq 只取第一条看结构 curl -s http://127.0.0.1:8000/api/v1/events?devicesw-access-01from-15mlimit1 \ | jq .[0] | {ts, kind, entity, attrs} # 查一条 MAC 的移动轨迹确认拼装逻辑生效 curl -s http://127.0.0.1:8000/api/v1/trace?mac00:1a:2b:3c:4d:5ewindow10m | jq length第一个接口返回里重点看两项ts是不是 UTC 且和当前时间对得上attrs.clock_offset_s是不是接近 0。偏移超过几十秒说明设备那边 NTP 没配好先去治设备别在应用层硬扛。第二个接口返回的数组长度大于 1说明轨迹拼装跑通了返回空数组但事件接口有数据八成是attrs.mac的格式和查询参数对不上大小写或分隔符不一致。采集器自身也暴露 Prometheus 指标/metrics里三个数最关键parse_miss_total解析失败累计、collector_poll_duration_seconds单轮轮询耗时、dedup_drop_total去重丢弃数。parse_miss_total持续上涨基本等于厂商改了日志格式poll_duration逼近interval说明设备响应慢或 OID 取多了dedup_drop_total突然暴涨通常意味着某台设备在疯狂重传先去看设备侧。前端是独立的 Vite 项目开发和后端分开跑cd web npm install VITE_API_BASEhttp://127.0.0.1:8000 npm run dev进去之后先在拓扑页确认设备节点有没有出来再去轨迹页按 MAC 搜。第一次跑建议就拿测试日志造三五条事件人工核对看板显示和curl结果是否一致比直接接生产设备容易定位问题。5. 避坑与排查接入第三方设备时最容易翻车的五处5.1 数据侧时钟漂移、重复上报与解析丢失现象一同一条链路的 up 和 down 在时间轴上倒挂轨迹图上出现来回横跳。原因多半是设备没配 NTP本机时间和设备时间差了几十秒而早期版本直接用了设备上报的时间戳。解决办法是在归一化层做偏移估计用“到达时间减设备时间”的滑动中位数修正并把clock_offset_s写进attrs便于事后核对同时把clock_drift_max设成 120 秒超出的直接打clock_skew标记让看板用不同颜色区分而不是悄悄改成看起来正常的值。现象二同一条告警五分钟内出现十几次看板上全是毛刺。syslog 走 UDP设备侧有重传机制加上双上行组网时同一条日志可能从两条路径各来一份。光靠时间去重不行因为重传的时间戳可能一模一样。这里靠的是fingerprint()设备 实体 类型 状态拼成的字符串不含时间戳dedup_ttl设 180 秒。注意指纹里别放attrs全量内容否则厂商多带一个序列号字段指纹就变了去重直接失效。现象三设备固件升级后事件量掉到零但采集器一切正常。这是最典型的一类翻车厂商在新固件里给日志加了前缀或者在 MAC 后面追加了 VLAN 信息正则匹配不上parse返回空列表而空列表不报错所以日志里看着风平浪静。排查顺序是先看parse_miss_total曲线有没有抬升再抓一条原始报文丢进tests/fixtures/跑单测。长期做法是把正则里的关键字段顺序放宽用命名分组加可选段别写死整行格式。5.2 采集侧轮询压力、超时与并发现象四接上三十台设备后交换机管理面响应变慢甚至 SSH 登不进去。根因往往不是单次请求太重而是全网设备在同一秒被轮询。interval: 30加上默认jitter: 0就是这种效果。解法是把jitter开到 0.2 以上让调度打散同时改用GetBulk批量取 OID 而不是逐个Get并且只取ifXTable里真正需要的列别为了省事遍历整张接口表。还有一个容易忽略的点轮询超时要设得比interval小否则上一轮没结束下一轮又开始任务会越堆越多最后 Redis 里的任务锁全被占满。提示接入生产设备前先在非核心设备上把interval调到 10 秒压一轮观察collector_poll_duration_seconds和设备管理面 CPU再决定正式周期。参数拍脑袋定后面全是返工。5.3 存储侧标签基数与写入放大现象五跑了一周磁盘涨了几十 G查询还越来越慢。打开表结构一看mac、port、session_id全被当成了索引列或者标签。时间序列表最怕高基数维度一张表几百万个不同 MAC索引体积能超过数据本身。正确做法是标签只保留低基数字段device_id、vendor、kind把 MAC、端口、五元组这些放进 JSONB 的attrs需要查的时候建 GIN 索引或者用表达式索引单独开一个。另外原始事件按retention_days定期清理聚合视图单独设更长的保留期看板查的是聚合结果不查原始明细。6. 二次开发加一个自定义供应商适配器并做回归验证真正落地时几乎一定会遇到“官方没覆盖的那家设备”。加适配器的完整动作其实只有四步但第四步最容易被跳过。第一步在adapters/下新建目录比如vendor_c/放一个adapter.py类名统一叫Adaptervendor填唯一键。第二步把抓到的真实报文存成 fixture一条 link down、一条 link up、一条无关的调试日志后者用来验证“解析失败返回空列表”这条约定。第三步写解析和normalize把厂商乱七八糟的字段在normalize里洗干净别留到存储层。第四步跑回归。回归这套我是被坑出来的。早先加完适配器只手动灌了两条日志看接口有数据就上线了结果那家设备在端口震荡时会连发五条状态相同的日志去重指纹因为带了个自增序列号字段而完全不同看板上一条链路抖成了心电图。后来补了下面这组测试测试项断言内容覆盖的坑快照解析parse(fixture)输出与expected.json完全一致正则改动导致的字段漂移空输入无关日志返回[]且不抛异常混入调试日志时整批中断指纹稳定性同状态不同序列号的两条事件指纹相同去重失效导致毛刺实体键唯一性两台设备的同名端口key不相等跨设备实体混淆时钟偏移手工把received_at推后 40 秒clock_offset_s应接近 40轨迹时间轴倒挂测试本身不复杂pytest加参数化就够关键是把它挂进 CI改了normalize里的任何一行都必须跑一遍。有个偏工程化的技巧值得单独说给新适配器加一个“影子模式”。配置里加shadow: true采集器照常解析、照常打日志和指标但写库时跳过。挂一天看parse_miss_total和解析出的事件量曲线跟设备侧的实际告警数对得上再切正式写库。这招比任何灰度方案都便宜也基本杜绝了“上线才发现字段全错、历史数据全脏”这种需要清表重来的局面。从那以后我每次接新厂商都强制走一遍「fixture 三条 → 五项回归 → 影子跑一天」这个流程哪怕只加一个字段的解析也不例外。慢是慢了半天但省下的是整张表重刷的工夫。希望帮到你。本文还有配套的精品资源点击获取