已合并
[Metrics] 支持用户单目录多 YAML 及外部 Handler #439
ChaseChe77创建于 8月11日
[Metrics] 支持用户单目录多 YAML 及外部 Handler #439
已合并
共 13 个文件变更+1315-14
| @@ -0,0 +1,696 @@ | |||
| 1 | +# ms-service-metric 可扩展指标架构重构设计文档 | ||
| 2 | + | ||
| 3 | +状态(Status): Draft | ||
| 4 | +作者(Authors): @ChaseChe77 | ||
| 5 | +创建日期(Created): 2026-08-10 | ||
| 6 | +更新日期(Updated): 2026-08-10 | ||
| 7 | +相关 Issue/PR: msserviceprofiler PR !433、vLLM-Ascend PR #13783 | ||
| 8 | + | ||
| 9 | +--- | ||
| 10 | + | ||
| 11 | +# 1. 概述 | ||
| 12 | + | ||
| 13 | +## 1.1 简介 | ||
| 14 | + | ||
| 15 | +本文档描述 `ms-service-metric` 指标采集架构的完整重构方案,覆盖 | ||
| 16 | +`msserviceprofiler` 与 `vLLM-Ascend` 两个代码仓的职责调整、配置与 Handler 迁移、 | ||
| 17 | +Provider 插件机制、多 YAML 加载、渐进式兼容策略,以及最终用户通过配置目录拆分 YAML | ||
| 18 | +的能力。 | ||
| 19 | + | ||
| 20 | +重构后,系统被划分为三个扩展层次: | ||
| 21 | + | ||
| 22 | +1. `ms-service-metric Core`:提供 Hook、配置合并、Handler 构造、Prometheus 上报、 | ||
| 23 | + Provider 发现和事务式重载等稳定底层能力。 | ||
| 24 | +2. `Framework Provider`:由 vLLM-Ascend 等框架仓维护与自身版本强相关的 symbol、YAML 和 | ||
| 25 | + Handler,通过 Python Entry Point 接入 Core。 | ||
| 26 | +3. `User Config`:最终用户通过现有环境变量提供单文件或单目录多 YAML,并可在同一配置 | ||
| 27 | + 根目录放置以原有 `module:function` 语法引用的独立 Handler 文件。 | ||
| 28 | + | ||
| 29 | +该设计既解决框架配置跨仓维护问题,也兼容 Core 和 Provider 发版不同步的过渡阶段,并为 | ||
| 30 | +后续接入其他框架保留统一扩展入口。 | ||
| 31 | + | ||
| 32 | +## 1.2 背景与动机 | ||
| 33 | + | ||
| 34 | +重构前,vLLM 与 vLLM-Ascend 的指标点位、版本范围和部分业务 Handler 主要维护在 | ||
| 35 | +`msserviceprofiler` 仓内。该模式可以快速实现指标采集,但长期演进存在以下问题: | ||
| 36 | + | ||
| 37 | +- YAML 中的 symbol 与框架源码强绑定。vLLM/vLLM-Ascend 重构、改名或移动函数后, | ||
| 38 | + `ms-service-metric` 配置容易失效。 | ||
| 39 | +- 框架业务 Handler 位于 Core 仓,代码所有权与维护责任不一致,框架改动需要跨仓同步。 | ||
| 40 | +- `msserviceprofiler`、vLLM 和 vLLM-Ascend 发版节奏不同,难以保证配置始终匹配运行版本。 | ||
| 41 | +- 用户自定义配置只能使用单个 YAML;复杂指标无法按 scheduler、executor、EPLB 等模块拆分。 | ||
| 42 | +- 外部 Handler 必须位于可导入 Python 包中,用户需要修改仓库、安装包或调整 | ||
| 43 | + `PYTHONPATH`。 | ||
| 44 | +- 如果框架仓一次性接管整个前缀,而业务代码和 YAML 尚未全部迁移,会产生指标静默缺失。 | ||
| 45 | +- 如果简单叠加新旧配置,又可能对相同 symbol 和 Handler 重复 Hook,造成指标重复上报。 | ||
| 46 | + | ||
| 47 | +因此,本次重构不能只解决“读取多个 YAML”,而需要同时建立清晰的代码所有权、渐进迁移、 | ||
| 48 | +故障降级和最终用户扩展机制。 | ||
| 49 | + | ||
| 50 | +## 1.3 设计目标 | ||
| 51 | + | ||
| 52 | +本次重构目标如下: | ||
| 53 | + | ||
| 54 | +- Core 与框架业务解耦,Core 不反向依赖 vLLM-Ascend 私有实现。 | ||
| 55 | +- vLLM-Ascend 可以在自身仓库维护框架强相关 YAML 和 Handler,并随框架版本发布。 | ||
| 56 | +- 支持 Provider 单目录多 YAML,并保证 YAML 被正确打入 Wheel。 | ||
| 57 | +- 支持 Provider 外部 Handler,同时限制其可导入模块范围。 | ||
| 58 | +- 迁移期默认使用 Overlay,Core 与 Provider 存在版本 Gap 时仍能使用 Core 兜底指标。 | ||
| 59 | +- 迁移完成后允许 Provider 使用 Exclusive 显式完整接管所属 symbol 前缀。 | ||
| 60 | +- 使用配置语义指纹去重,不要求在 YAML 中增加过渡性质的 `id` 字段。 | ||
| 61 | +- 用户可通过 `MS_SERVICE_METRIC_CONFIG_PATH` 加载单文件或单目录多 YAML。 | ||
| 62 | +- 用户可通过现有环境变量加载单文件或单目录多 YAML,不增加新的环境变量。 | ||
| 63 | +- 用户无需修改或安装 Python 包,即可从配置根目录加载 YAML 明确引用的外部 Handler。 | ||
| 64 | +- 配置和 Handler 重载失败时不提交部分状态,尽量保持原有 Hook 与推理流程。 | ||
| 65 | +- 现有单 YAML、内置 Adapter、模块式 Handler 和 Prometheus 暴露方式保持兼容。 | ||
| 66 | + | ||
| 67 | +## 1.4 非目标 | ||
| 68 | + | ||
| 69 | +- 不在本次设计中实现 SGLang 适配;后续可复用同一 Provider 架构接入。 | ||
| 70 | +- 不修改 Prometheus、Grafana 的部署和持久化机制。 | ||
| 71 | +- 不将 Metrics 扩展为算子级 Profiling;Metrics 仍用于持续观测和第一层问题定位。 | ||
| 72 | +- 不为 Provider Handler 提供进程级安全沙箱;Handler 本质上仍是可信 Python 代码。 | ||
| 73 | +- 不承诺修改 Handler Python 源码后可以在服务进程内无状态热替换。 | ||
| 74 | + | ||
| 75 | +# 2. 用例分析 | ||
| 76 | + | ||
| 77 | +## 2.1 Core 单独升级 | ||
| 78 | + | ||
| 79 | +`ms-service-metric` 已合入重构,而 vLLM-Ascend 尚未合入 Provider。此时 Core 发现不到 | ||
| 80 | +`ms_service_metric.providers` Entry Point,继续加载原有 Adapter YAML 和通用 Handler, | ||
| 81 | +指标功能保持可用。 | ||
| 82 | + | ||
| 83 | +该场景用于支持两个仓分开提交、分开发版,以及 Provider PR 尚未合入的时间窗口。 | ||
| 84 | + | ||
| 85 | +## 2.2 Provider 渐进迁移 | ||
| 86 | + | ||
| 87 | +Core 已合入重构,vLLM-Ascend 只迁移了部分 YAML 和 Handler。Provider 使用 Overlay: | ||
| 88 | + | ||
| 89 | +- Provider 已配置的 symbol 以 Provider 配置为准。 | ||
| 90 | +- Provider 未配置的 symbol 继续使用 Core/Adapter 兜底。 | ||
| 91 | +- Provider 失效、YAML 无效或 Handler 导入失败时,Provider 整体跳过,保留 Core 兜底。 | ||
| 92 | + | ||
| 93 | +该场景允许按 symbol 逐步迁移,不要求两个仓一次性完成全部改造。 | ||
| 94 | + | ||
| 95 | +## 2.3 Provider 完整接管 | ||
| 96 | + | ||
| 97 | +当 vLLM-Ascend 已完整维护所属 symbol 后,可以继续使用 Overlay,也可以显式设置 | ||
| 98 | +`ownership_mode="exclusive"`。Exclusive 会先过滤 Core 中属于 | ||
| 99 | +`owned_symbol_prefixes` 的兜底 symbol,再加载 Provider 配置,从而完成最终所有权收敛。 | ||
| 100 | + | ||
| 101 | +Exclusive 只适合迁移完成后启用;过早启用会让尚未迁移的指标失去兜底。 | ||
| 102 | + | ||
| 103 | +## 2.4 Provider 多 YAML 与业务 Handler | ||
| 104 | + | ||
| 105 | +vLLM-Ascend 将基础执行、内存和 EPLB 等指标拆分为多个 YAML,并把 EPLB 等强业务逻辑 | ||
| 106 | +Handler 放在自身仓库。Provider 自动发现配置目录下的 YAML,将路径列表交给 Core。 | ||
| 107 | + | ||
| 108 | +## 2.5 最终用户多 YAML | ||
| 109 | + | ||
| 110 | +用户不修改 `msserviceprofiler` 或 vLLM-Ascend,也不构建 Provider 包,只需要: | ||
| 111 | + | ||
| 112 | +- 一个 YAML 文件,或一个包含多个 YAML 的配置目录。 | ||
| 113 | +- `MS_SERVICE_METRIC_CONFIG_PATH` 指向配置文件或配置目录。 | ||
| 114 | + | ||
| 115 | +该能力适合客户现场、实验性指标以及尚不适合合入框架仓的业务扩展。 | ||
| 116 | + | ||
| 117 | +## 2.6 异常与回滚 | ||
| 118 | + | ||
| 119 | +运行时执行 metric on/restart 时,系统先加载并校验完整候选配置,再构造候选 Handler。 | ||
| 120 | +任一阶段失败时恢复上一次已提交的配置和 Handler 状态,避免先拆旧 Hook、后发现新配置 | ||
| 121 | +不可用所造成的指标整体中断。 | ||
| 122 | + | ||
| 123 | +# 3. 方案设计 | ||
| 124 | + | ||
| 125 | +## 3.1 总体架构 | ||
| 126 | + | ||
| 127 | +```mermaid | ||
| 128 | +flowchart LR | ||
| 129 | + subgraph CORE["ms-service-metric Core"] | ||
| 130 | + A["Adapter / Core fallback YAML"] | ||
| 131 | + B["ProviderRegistry"] | ||
| 132 | + C["SymbolConfig"] | ||
| 133 | + D["Semantic Dedup"] | ||
| 134 | + E["MetricHandler"] | ||
| 135 | + F["SymbolHandlerManager"] | ||
| 136 | + G["MetricsManager"] | ||
| 137 | + end | ||
| 138 | + | ||
| 139 | + subgraph ASCEND["vLLM-Ascend Provider"] | ||
| 140 | + H["Python Entry Point"] | ||
| 141 | + I["base_metrics.yaml"] | ||
| 142 | + J["eplb_metrics.yaml"] | ||
| 143 | + K["Framework Handlers"] | ||
| 144 | + end | ||
| 145 | + | ||
| 146 | + subgraph USER["User Config"] | ||
| 147 | + L["Single YAML / YAML Directory"] | ||
| 148 | + end | ||
| 149 | + | ||
| 150 | + H --> B | ||
| 151 | + I --> H | ||
| 152 | + J --> H | ||
| 153 | + K --> E | ||
| 154 | + A --> C | ||
| 155 | + B --> C | ||
| 156 | + L --> C | ||
| 157 | + C --> D | ||
| 158 | + D --> E | ||
| 159 | + E --> F | ||
| 160 | + F --> G | ||
| 161 | + G --> N["vLLM /metrics"] | ||
| 162 | +``` | ||
| 163 | + | ||
| 164 | +## 3.2 Core 与 Provider 职责边界 | ||
| 165 | + | ||
| 166 | +### 3.2.1 Core 职责 | ||
| 167 | + | ||
| 168 | +`ms-service-metric Core` 保留以下通用能力: | ||
| 169 | + | ||
| 170 | +- Hook 链、Symbol 解析、模块监听和 Hook 生命周期管理。 | ||
| 171 | +- YAML 解析、版本过滤、配置合并和语义去重。 | ||
| 172 | +- Provider Entry Point 发现、描述符校验、冲突处理和激活。 | ||
| 173 | +- Handler 构造、通用 Handler 以及稳定 Provider Handler 门面。 | ||
| 174 | +- Prometheus 指标注册、记录、多进程聚合和 `/metrics` 数据链路。 | ||
| 175 | +- DP、PD role、phase 等通用运行时元数据。 | ||
| 176 | +- metric on/off/restart 控制和事务式重载。 | ||
| 177 | + | ||
| 178 | +Core 不保存必须跟随 vLLM-Ascend 版本变化的业务实现,也不直接 import | ||
| 179 | +`vllm_ascend.*`。 | ||
| 180 | + | ||
| 181 | +### 3.2.2 Provider 职责 | ||
| 182 | + | ||
| 183 | +框架 Provider 负责维护: | ||
| 184 | + | ||
| 185 | +- 框架私有 symbol 路径和版本约束。 | ||
| 186 | +- 与框架源码强绑定的 YAML。 | ||
| 187 | +- 需要访问框架对象、局部状态或业务结构的 Handler。 | ||
| 188 | +- Provider 所属 symbol 前缀和迁移模式。 | ||
| 189 | +- YAML、Handler 和 Entry Point 的 Wheel 打包。 | ||
| 190 | +- 框架源码与 YAML symbol 一致性的 UT。 | ||
| 191 | + | ||
| 192 | +### 3.2.3 Handler 归属原则 | ||
| 193 | + | ||
| 194 | +- 纯计时、通用 phase、通用内存解析等稳定逻辑保留在 Core,通过 | ||
| 195 | + `ms_service_metric.provider_handlers` 暴露。 | ||
| 196 | +- EPLB 专家热度、框架私有对象解析等强业务逻辑迁移到 | ||
| 197 | + `vllm_ascend.observability.ms_metrics.handlers`。 | ||
| 198 | +- Provider 不应引用 `ms_service_metric.adapters.*` 等 Core 内部路径,避免 Core 内部重构 | ||
| 199 | + 导致 Provider 静默失效。 | ||
| 200 | +- Provider Handler 通过 `ms_service_metric.provider_api` 获取窄化的指标写入接口,不直接 | ||
| 201 | + 操作 Core 内部单例实现。 | ||
| 202 | + | ||
| 203 | +## 3.3 Provider 描述模型 | ||
| 204 | + | ||
| 205 | +Core 定义稳定描述对象 `MetricProvider`: | ||
| 206 | + | ||
| 207 | +```python | ||
| 208 | +@dataclass(frozen=True) | ||
| 209 | +class MetricProvider: | ||
| 210 | + name: str | ||
| 211 | + config_paths: Sequence[str] | ||
| 212 | + priority: int = 100 | ||
| 213 | + framework_package: str | None = None | ||
| 214 | + owned_symbol_prefixes: Sequence[str] = () | ||
| 215 | + handler_module_prefixes: Sequence[str] = () | ||
| 216 | + ownership_mode: str = "overlay" | ||
| 217 | +``` | ||
| 218 | + | ||
| 219 | +字段含义如下: | ||
| 220 | + | ||
| 221 | +| 字段 | 含义 | | ||
| 222 | +|---|---| | ||
| 223 | +| `name` | Provider 唯一名称,用于排序、日志和冲突识别 | | ||
| 224 | +| `config_paths` | Provider 提供的一个或多个 YAML 绝对路径 | | ||
| 225 | +| `priority` | Provider 加载顺序,数值越小越先处理 | | ||
| 226 | +| `framework_package` | 需要版本过滤时用于读取框架安装版本 | | ||
| 227 | +| `owned_symbol_prefixes` | Provider 声明可维护的 symbol 模块前缀 | | ||
| 228 | +| `handler_module_prefixes` | Provider 自有 Handler 的允许导入前缀 | | ||
| 229 | +| `ownership_mode` | `overlay` 渐进迁移或 `exclusive` 完整接管 | | ||
| 230 | + | ||
| 231 | +本次没有增加 `provider_api_version` 和 `core_version_spec`。当前 Provider 接口很小,且 | ||
| 232 | +Python 导入、描述符校验和稳定门面已经能暴露不兼容问题;过早引入版本握手会增加两个仓的 | ||
| 233 | +维护成本。未来稳定 API 发生破坏性变化时,再增加显式协议版本。 | ||
| 234 | + | ||
| 235 | +## 3.4 Provider 发现与注册 | ||
| 236 | + | ||
| 237 | +vLLM-Ascend 安装时在 `setup.py` 注册 Entry Point: | ||
| 238 | + | ||
| 239 | +```python | ||
| 240 | +entry_points={ | ||
| 241 | + "ms_service_metric.providers": [ | ||
| 242 | + "vllm-ascend = " | ||
| 243 | + "vllm_ascend.observability.ms_metrics:get_metric_provider", | ||
| 244 | + ], | ||
| 245 | +} | ||
| 246 | +``` | ||
| 247 | + | ||
| 248 | +Core 使用 `importlib.metadata.entry_points()` 发现 | ||
| 249 | +`ms_service_metric.providers` 组。Entry Point 只声明 Provider 工厂,不意味着所有 | ||
| 250 | +`vllm_ascend.*` Hook 自动路由到某个 YAML;真正生效的点位仍由 Provider | ||
| 251 | +`config_paths` 中实际声明的 symbol 决定。 | ||
| 252 | + | ||
| 253 | +vLLM-Ascend 的 `get_metric_provider()` 在函数内部惰性导入 | ||
| 254 | +`ms_service_metric.provider_api`。因此 vLLM-Ascend 本身不硬依赖 | ||
| 255 | +`ms-service-metric`:未安装 Core 时,没有 Core 去发现和调用该 Entry Point,vLLM-Ascend | ||
| 256 | +原有推理功能不受影响。 | ||
| 257 | + | ||
| 258 | +## 3.5 vLLM-Ascend 侧目录与打包 | ||
| 259 | + | ||
| 260 | +vLLM-Ascend 侧目录如下: | ||
| 261 | + | ||
| 262 | +```text | ||
| 263 | +vllm_ascend/observability/ms_metrics/ | ||
| 264 | +├── __init__.py | ||
| 265 | +├── provider.py | ||
| 266 | +├── handlers.py | ||
| 267 | +└── config/ | ||
| 268 | + ├── base_metrics.yaml | ||
| 269 | + └── eplb_metrics.yaml | ||
| 270 | +``` | ||
| 271 | + | ||
| 272 | +`provider.py` 对 `config/*.yaml` 排序后生成 `config_paths`,因此同一目录可以继续增加 YAML, | ||
| 273 | +无需修改 Provider 工厂。`setup.py` 通过下面的 `package_data` 将 YAML 打入源码包和 Wheel: | ||
| 274 | + | ||
| 275 | +```python | ||
| 276 | +package_data={ | ||
| 277 | + "vllm_ascend.observability.ms_metrics": ["config/*.yaml"], | ||
| 278 | +} | ||
| 279 | +``` | ||
| 280 | + | ||
| 281 | +仅源码目录中存在 YAML 不足以证明发布可用,正式验证需要构建或安装 Wheel,并从 | ||
| 282 | +`importlib.metadata` 发现 Entry Point,再确认 `provider.config_paths` 中每个路径均存在。 | ||
| 283 | + | ||
| 284 | +## 3.6 Overlay 与 Exclusive | ||
| 285 | + | ||
| 286 | +### 3.6.1 Overlay | ||
| 287 | + | ||
| 288 | +Overlay 是默认模式,也是迁移期推荐模式。处理规则为: | ||
| 289 | + | ||
| 290 | +1. 先加载 Core 默认和 Adapter 兜底配置。 | ||
| 291 | +2. Provider 对自己实际提供的 symbol 执行 symbol 级整体替换。 | ||
| 292 | +3. Provider 未提供的 symbol 保持 Core/Adapter 兜底。 | ||
| 293 | + | ||
| 294 | +同一个 symbol 是迁移原子单元。Provider YAML 必须声明该 symbol 最终需要保留的全部 | ||
| 295 | +Handler,避免只迁移其中一条 Handler 后,产生不完整指标集合。 | ||
| 296 | + | ||
| 297 | +### 3.6.2 Exclusive | ||
| 298 | + | ||
| 299 | +Exclusive 用于迁移完成后的最终接管: | ||
| 300 | + | ||
| 301 | +1. 根据 `owned_symbol_prefixes` 从 Core/Adapter 中过滤 Provider 所有权范围内的 symbol。 | ||
| 302 | +2. 加载 Provider 配置。 | ||
| 303 | +3. 所属前缀下未出现在 Provider YAML 的 symbol 不再使用 Core 兜底。 | ||
| 304 | + | ||
| 305 | +因此 Exclusive 是显式完成状态,不应作为过渡期默认值。 | ||
| 306 | + | ||
| 307 | +### 3.6.3 Provider 冲突 | ||
| 308 | + | ||
| 309 | +- 同名 Provider 被视为歧义,同名项全部跳过。 | ||
| 310 | +- Overlay Provider 之间允许组合。 | ||
| 311 | +- 只要参与方存在 Exclusive,且 owned prefix 相互包含,则冲突双方都跳过。 | ||
| 312 | +- Provider 按 `(priority, name)` 稳定排序。 | ||
| 313 | + | ||
| 314 | +## 3.7 配置加载、覆盖与语义去重 | ||
| 315 | + | ||
| 316 | +整体处理顺序如下: | ||
| 317 | + | ||
| 318 | +1. Core 默认配置。 | ||
| 319 | +2. Adapter 内置兼容配置。 | ||
| 320 | +3. Active Provider 配置。 | ||
| 321 | +4. 框架级用户配置,例如 `MS_SERVICE_METRIC_VLLM_CONFIG`。 | ||
| 322 | +5. 全局用户配置 `MS_SERVICE_METRIC_CONFIG_PATH`。 | ||
| 323 | + | ||
| 324 | +```mermaid | ||
| 325 | +flowchart TD | ||
| 326 | + A["Core Default"] --> B["Adapter Fallback"] | ||
| 327 | + B --> C{"Provider Active?"} | ||
| 328 | + C -- "No" --> F["Keep Fallback"] | ||
| 329 | + C -- "Overlay" --> D["Replace Contributed Symbols"] | ||
| 330 | + C -- "Exclusive" --> E["Filter Owned Prefixes and Load Provider"] | ||
| 331 | + D --> G["Framework User Config"] | ||
| 332 | + E --> G | ||
| 333 | + F --> G | ||
| 334 | + G --> H["Global User Config"] | ||
| 335 | + H --> I["Fill Defaults and Semantic Dedup"] | ||
| 336 | +``` | ||
| 337 | + | ||
| 338 | +去重不依赖 YAML `id`,而是根据 Handler 的有效运行语义生成内部指纹,主要包含: | ||
| 339 | + | ||
| 340 | +- Handler 引用或默认 Handler。 | ||
| 341 | +- 显式有效名称。 | ||
| 342 | +- 版本范围和 lock patch。 | ||
| 343 | +- Metric 名称、类型、表达式、桶和 labels。 | ||
| 344 | + | ||
| 345 | +省略默认值与显式写出相同默认值会得到相同指纹;同一 Handler 但 metrics 或 labels 不同, | ||
| 346 | +会得到不同指纹并允许同时生效。 | ||
| 347 | + | ||
| 348 | +## 3.8 Provider 校验与失败降级 | ||
| 349 | + | ||
| 350 | +Provider 激活前执行以下检查: | ||
| 351 | + | ||
| 352 | +- 描述符字段类型、非空值和 ownership mode。 | ||
| 353 | +- YAML 路径必须为普通文件,文件不能为空。 | ||
| 354 | +- symbol 格式和 owned prefix 归属。 | ||
| 355 | +- Handler 必须来自稳定 Core 门面或 `handler_module_prefixes`。 | ||
| 356 | +- Metric 类型、名称、labels、buckets 和表达式结构。 | ||
| 357 | +- Provider 与已有配置的同名 Metric schema 必须兼容。 | ||
| 358 | +- 存在版本上下界时,必须能够读取 `framework_package` 版本。 | ||
| 359 | + | ||
| 360 | +任一检查失败时,该 Provider 在获得所有权之前整体跳过,Core/Adapter 兜底继续生效。 | ||
| 361 | +Provider 失败不应留下“前缀已经接管、配置却没有加载”的半激活状态。 | ||
| 362 | + | ||
| 363 | +## 3.9 事务式重载 | ||
| 364 | + | ||
| 365 | +metric on/restart 的配置更新流程如下: | ||
| 366 | + | ||
| 367 | +```mermaid | ||
| 368 | +sequenceDiagram | ||
| 369 | + participant C as Control | ||
| 370 | + participant S as SymbolConfig | ||
| 371 | + participant P as ProviderRegistry | ||
| 372 | + participant M as SymbolHandlerManager | ||
| 373 | + participant H as Live Hooks | ||
| 374 | + | ||
| 375 | + C->>S: snapshot_state() | ||
| 376 | + C->>S: reload candidate configs | ||
| 377 | + S->>P: discover and validate providers | ||
| 378 | + P-->>S: active providers | ||
| 379 | + S-->>M: merged candidate config | ||
| 380 | + M->>M: build all candidate handlers | ||
| 381 | + alt candidate valid | ||
| 382 | + M->>H: reconcile handlers and hooks | ||
| 383 | + else load or build failed | ||
| 384 | + M->>S: restore_state(snapshot) | ||
| 385 | + M->>H: keep previous handlers | ||
| 386 | + end | ||
| 387 | +``` | ||
| 388 | + | ||
| 389 | +该机制保护的是配置与 Handler 构造阶段。任意自定义 Handler 在实际执行时仍可能产生业务 | ||
| 390 | +副作用,Core 无法对第三方 Python 代码提供完全隔离。 | ||
| 391 | + | ||
| 392 | +## 3.10 用户单目录多 YAML | ||
| 393 | + | ||
| 394 | +`MS_SERVICE_METRIC_CONFIG_PATH` 保持原单文件兼容,并扩展为文件或目录: | ||
| 395 | + | ||
| 396 | +```bash | ||
| 397 | +export MS_SERVICE_METRIC_CONFIG_PATH=/data/custom_metrics/config | ||
| 398 | +``` | ||
| 399 | + | ||
| 400 | +目录加载规则: | ||
| 401 | + | ||
| 402 | +1. 只读取指定目录第一层普通文件。 | ||
| 403 | +2. 扩展名大小写不敏感地匹配 `.yaml` 和 `.yml`。 | ||
| 404 | +3. 按文件名稳定排序。 | ||
| 405 | +4. 忽略子目录和其他扩展名文件。 | ||
| 406 | +5. 逐文件解析并复用现有 `_merge_configs` 与语义去重。 | ||
| 407 | +6. 任一 YAML 解析失败时,本轮候选配置整体失败,不提交部分结果。 | ||
| 408 | + | ||
| 409 | +本次正式需求为“单目录多 YAML”,不递归扫描子目录。非递归规则可以避免误加载备份文件、 | ||
| 410 | +不确定加载范围和大目录遍历开销。 | ||
| 411 | + | ||
| 412 | +### 3.10.1 用户外部 Handler | ||
| 413 | + | ||
| 414 | +配置目录同时作为外部 Handler 根目录,不新增环境变量,也不改变 YAML 的 | ||
| 415 | +`module.path:function_name` 写法: | ||
| 416 | + | ||
| 417 | +```text | ||
| 418 | +/data/custom_metrics/config/ | ||
| 419 | +├── scheduler.yaml | ||
| 420 | +├── executor.yaml | ||
| 421 | +├── custom_handler.py | ||
| 422 | +└── helpers/ | ||
| 423 | + └── request_handler.py | ||
| 424 | +``` | ||
| 425 | + | ||
| 426 | +`custom_handler:record` 映射到 `custom_handler.py`, | ||
| 427 | +`helpers.request_handler:record` 映射到 `helpers/request_handler.py`。当环境变量指向单个 | ||
| 428 | +YAML 时,以 YAML 的父目录作为 Handler 根目录。 | ||
| 429 | + | ||
| 430 | +子目录不要求存在 `__init__.py`。外部 Handler 可以导入运行环境中已经安装的依赖,但 | ||
| 431 | +配置根目录不会加入 `sys.path`,所以不支持依赖配置目录内其他 Python 文件的隐式或相对 | ||
| 432 | +导入。需要跨文件复用的 Handler 代码应安装为正式 Python 包,并继续走原有允许列表或 | ||
| 433 | +Provider 模块前缀机制。用户模块名还应避开 Core 和 Provider 已声明的模块前缀,避免 | ||
| 434 | +优先进入标准 Python import 分支。 | ||
| 435 | + | ||
| 436 | +加载顺序保持兼容:Core 和 Provider 允许列表中的模块继续使用标准 Python import;只有 | ||
| 437 | +不在允许列表且用户配置根目录存在时,才使用受控文件加载器。加载器不修改 `sys.path`, | ||
| 438 | +不递归扫描 Python 文件,只加载 YAML 明确引用的目标文件。模块路径每一段必须是合法 | ||
| 439 | +Python 标识符;目标文件经过真实路径解析后必须仍位于根目录中,目录穿越和符号链接逃逸 | ||
| 440 | +均被拒绝。 | ||
| 441 | + | ||
| 442 | +外部 Handler 模块以“真实文件路径哈希”作为进程内模块缓存键。同一文件只执行一次, | ||
| 443 | +Handler 根目录也参与外部 Handler 的语义指纹,避免切换配置目录后错误复用旧 Handler。 | ||
| 444 | +首版不提供源码热重载:修改 YAML 可执行 metric restart,修改 Handler Python 文件需要 | ||
| 445 | +重启 vLLM 服务。 | ||
| 446 | + | ||
| 447 | +责任边界如下:Core 负责配置发现、受控加载、候选状态构造和失败回滚;Provider 负责与 | ||
| 448 | +框架版本绑定的官方 symbol、YAML 和 Handler;用户负责外部 Handler 的代码正确性、依赖、 | ||
| 449 | +目录权限和运行时异常保护。外部 Handler 与 Provider Handler 一样属于推理进程内的可信 | ||
| 450 | +Python 代码,Core 不提供进程级安全沙箱。 | ||
| 451 | + | ||
| 452 | +## 3.11 初始化与运行时数据链路 | ||
| 453 | + | ||
| 454 | +vLLM 启动时通过已有 general plugin 初始化 `VLLMMetricAdapter`: | ||
| 455 | + | ||
| 456 | +1. 检测 vLLM 版本。 | ||
| 457 | +2. 初始化 Prometheus 多进程环境和指标前缀。 | ||
| 458 | +3. 初始化 DP rank、PD role 等元数据。 | ||
| 459 | +4. 创建带 `ProviderRegistry` 的 `SymbolHandlerManager`。 | ||
| 460 | +5. 加载 Core/Adapter、Provider 和用户配置。 | ||
| 461 | +6. 构造 Handler 并应用 Hook。 | ||
| 462 | +7. Handler 将值写入 `MetricsManager`。 | ||
| 463 | +8. 指标通过 vLLM 原生 `/metrics` 暴露。 | ||
| 464 | + | ||
| 465 | +```mermaid | ||
| 466 | +flowchart LR | ||
| 467 | + A["vLLM Process Start"] --> B["VLLMMetricAdapter"] | ||
| 468 | + B --> C["SymbolHandlerManager"] | ||
| 469 | + C --> D["Resolve Symbol and Apply Hook"] | ||
| 470 | + D --> E["Runtime Function Call"] | ||
| 471 | + E --> F["Handler"] | ||
| 472 | + F --> G["MetricsManager"] | ||
| 473 | + G --> H["Prometheus Multiprocess Registry"] | ||
| 474 | + H --> I["/metrics"] | ||
| 475 | +``` | ||
| 476 | + | ||
| 477 | +# 4. 代码修改设计 | ||
| 478 | + | ||
| 479 | +## 4.1 msserviceprofiler 修改 | ||
| 480 | + | ||
| 481 | +| 模块 | 修改目的 | | ||
| 482 | +|---|---| | ||
| 483 | +| `core/config/provider.py` | 定义 Provider 描述、Entry Point 发现、排序和冲突处理 | | ||
| 484 | +| `provider_api.py` | 为框架 Handler 提供稳定、窄化的导入门面 | | ||
| 485 | +| `provider_handlers.py` | 惰性导出可供 Provider 复用的通用 Handler | | ||
| 486 | +| `core/config/symbol_config.py` | Provider 激活、配置覆盖、schema 校验、语义去重、多 YAML 目录加载和用户 Handler 根目录状态 | | ||
| 487 | +| `core/external_handler_loader.py` | 在用户配置根目录内隔离解析并缓存外部 Handler 模块 | | ||
| 488 | +| `core/handler.py` | 保持 Core/Provider 标准导入,并为用户 Handler 接入受控文件加载 | | ||
| 489 | +| `core/symbol_handler_manager.py` | 候选 Handler 预构造和事务式重载回滚 | | ||
| 490 | +| `adapters/vllm/adapter.py` | 在初始化 manager 时注入 `ProviderRegistry` | | ||
| 491 | + | ||
| 492 | +## 4.2 vLLM-Ascend 修改 | ||
| 493 | + | ||
| 494 | +| 模块 | 修改目的 | 是否可以省略 | | ||
| 495 | +|---|---|---| | ||
| 496 | +| `observability/ms_metrics/provider.py` | 描述 YAML、所有权和 Handler 前缀 | Provider 模式下不可省略 | | ||
| 497 | +| `observability/ms_metrics/__init__.py` | 暴露 Entry Point 工厂 | 不可省略 | | ||
| 498 | +| `observability/ms_metrics/config/*.yaml` | 维护框架强相关 symbol | 业务点位需要 | | ||
| 499 | +| `observability/ms_metrics/handlers.py` | 维护框架强相关处理逻辑 | 仅有自定义业务逻辑时需要 | | ||
| 500 | +| `setup.py` Entry Point | 让 Core 自动发现 Provider | Provider 自动发现不可省略 | | ||
| 501 | +| `setup.py` package data | 把 YAML 打入 Wheel | Wheel 发布不可省略 | | ||
| 502 | +| Provider UT | 看护发现、打包、symbol 和 Handler | 正式合入需要 | | ||
| 503 | + | ||
| 504 | +最终用户多 YAML 只扩展配置文件组织方式,不替代 Provider。由框架官方长期维护的 | ||
| 505 | +版本绑定指标仍推荐放在 Provider 中。 | ||
| 506 | + | ||
| 507 | +# 5. 兼容性与迁移方案 | ||
| 508 | + | ||
| 509 | +## 5.1 三种合入状态 | ||
| 510 | + | ||
| 511 | +| 场景 | ms-service-metric | vLLM-Ascend | 预期行为 | | ||
| 512 | +|---|---|---|---| | ||
| 513 | +| 1 | 重构已合入 | 未合入 Provider | Core/Adapter 兜底正常,发现不到 Provider | | ||
| 514 | +| 2 | 重构已合入 | 部分 YAML/Handler | Overlay 覆盖已迁移 symbol,其余继续兜底 | | ||
| 515 | +| 3 | 重构已合入 | 完整 Provider | Provider 全量生效;完成迁移后可切 Exclusive | | ||
| 516 | + | ||
| 517 | +两个 PR 可以分开合入,但建议顺序为先合 Core、后合 Provider。旧 Core 不认识 Provider | ||
| 518 | +Entry Point,vLLM-Ascend 新增文件不会自动生效;新 Core 配旧 vLLM-Ascend 则可以正常走 | ||
| 519 | +兜底。 | ||
| 520 | + | ||
| 521 | +## 5.2 迁移步骤 | ||
| 522 | + | ||
| 523 | +1. 在 Core 合入 Provider 基础设施和兼容测试。 | ||
| 524 | +2. vLLM-Ascend 注册 Overlay Provider。 | ||
| 525 | +3. 按 symbol 将 YAML 从 Core/Adapter 迁移到 vLLM-Ascend。 | ||
| 526 | +4. 通用 Handler 改为引用稳定 Core 门面。 | ||
| 527 | +5. 强业务 Handler 迁移到 vLLM-Ascend。 | ||
| 528 | +6. 基础和 EPLB 场景分别验证指标完整性。 | ||
| 529 | +7. 所属 prefix 全量迁移并稳定后,评估是否启用 Exclusive。 | ||
| 530 | +8. 确认 Exclusive 后,再清理 Core 中已无兜底价值的旧点位。 | ||
| 531 | + | ||
| 532 | +## 5.3 不增加 YAML `id` | ||
| 533 | + | ||
| 534 | +YAML `id` 只能解决迁移期重复识别,但长期业务价值较低,且会成为需要解释和维护的永久 | ||
| 535 | +字段。本方案通过标准化有效配置并计算内部语义指纹去重,不把迁移状态泄漏到用户配置格式。 | ||
| 536 | + | ||
| 537 | +## 5.4 版本 Gap 风险 | ||
| 538 | + | ||
| 539 | +Overlay 可以避免版本 Gap 直接造成整段指标缺失,但不能保证每个旧 symbol 在新框架版本中 | ||
| 540 | +仍然存在。需要结合以下机制控制风险: | ||
| 541 | + | ||
| 542 | +- Provider YAML 与框架源码同仓维护。 | ||
| 543 | +- UT 静态确认 YAML module、class 和 method 存在。 | ||
| 544 | +- 启动日志记录 Hook 成功、not hooked 和 Provider 跳过原因。 | ||
| 545 | +- 冒烟或 ST 对声明指标和 `/metrics` 实际指标做集合对比。 | ||
| 546 | +- 指标缺失不应影响推理服务,但必须可通过日志和验证工具感知,避免长期静默。 | ||
| 547 | + | ||
| 548 | +# 6. 安全、可靠性与性能 | ||
| 549 | + | ||
| 550 | +## 6.1 安全 | ||
| 551 | + | ||
| 552 | +- Provider Handler 仅允许稳定 Core 门面和 Provider 声明的模块前缀。 | ||
| 553 | +- Provider import、YAML 和 schema 在获得所有权前校验。 | ||
| 554 | +- Core 和 Provider Handler 继续使用现有模块允许列表和标准 import。 | ||
| 555 | +- 用户外部 Handler 仅允许从配置根目录按明确模块路径加载,不修改全局 `sys.path`。 | ||
| 556 | +- 外部模块路径拒绝绝对路径、路径分隔符、`..` 和符号链接逃逸,不扫描无关 Python 文件。 | ||
| 557 | +- Handler 是可信代码而不是安全沙箱,部署方必须控制 Provider 包和用户配置目录写权限。 | ||
| 558 | + | ||
| 559 | +## 6.2 可靠性 | ||
| 560 | + | ||
| 561 | +- vLLM general plugin 入口采用 fail-open:依赖缺失、共享内存初始化、配置或 Handler 初始化 | ||
| 562 | + 异常均记录错误并禁用 Metrics,不向上抛出导致 `vllm serve` 启动失败。 | ||
| 563 | +- 初始化中途失败时清理已创建的 Manager、Watcher 和 Hook,避免残留半初始化状态。 | ||
| 564 | +- Provider 发现异常按 Provider 隔离,不阻断其他 Provider。 | ||
| 565 | +- 重复名称和所有权冲突采用拒绝激活,而不是不确定覆盖。 | ||
| 566 | +- 配置与 Handler 候选状态完整构造后再更新 Live Hook。 | ||
| 567 | +- 失败时恢复已提交配置,保留原有 Handler。 | ||
| 568 | +- 业务 Handler 应捕获自身指标处理异常,并优先保持原始推理调用结果。 | ||
| 569 | + | ||
| 570 | +## 6.3 性能 | ||
| 571 | + | ||
| 572 | +- Provider 仅在初始化和 metric 重载时发现,不进入每次请求热路径。 | ||
| 573 | +- 多 YAML 扫描只读取目录第一层并按确定顺序处理。 | ||
| 574 | +- Handler 模块使用进程内缓存。 | ||
| 575 | +- 语义指纹只在配置加载阶段计算。 | ||
| 576 | +- 实际运行开销仍主要由 Hook 数量、Handler 计算和指标 label 基数决定。 | ||
| 577 | + | ||
| 578 | +# 7. 测试设计 | ||
| 579 | + | ||
| 580 | +## 7.1 Core UT | ||
| 581 | + | ||
| 582 | +- Provider Entry Point 发现、排序、重复名称和 ownership 冲突。 | ||
| 583 | +- Overlay symbol 级替换和未迁移 symbol 兜底。 | ||
| 584 | +- Exclusive prefix 过滤。 | ||
| 585 | +- Provider YAML、Handler 模块和 Metric schema 校验。 | ||
| 586 | +- Provider 失败时保留 Core fallback。 | ||
| 587 | +- 多配置文件语义重复去重。 | ||
| 588 | +- 配置快照、失败重载回滚和 Handler reconcile。 | ||
| 589 | +- 用户单 YAML 兼容、单目录多 YAML 排序和非递归规则。 | ||
| 590 | +- 用户外部 Handler 的同目录/子路径加载、路径逃逸防护、失败回滚和模块缓存。 | ||
| 591 | + | ||
| 592 | +## 7.2 vLLM-Ascend UT | ||
| 593 | + | ||
| 594 | +- `get_metric_provider()` 返回 Overlay Provider。 | ||
| 595 | +- Provider 自动发现 `base_metrics.yaml` 和 `eplb_metrics.yaml`。 | ||
| 596 | +- Entry Point 与 package data 正确注册。 | ||
| 597 | +- YAML symbol 对应当前源码中的 module、class 和 method。 | ||
| 598 | +- YAML 不包含人工 `id`。 | ||
| 599 | +- EPLB Handler 正确记录热度指标、跳过非零 rank。 | ||
| 600 | +- 指标系统异常时 EPLB Handler 保持原始推理结果。 | ||
| 601 | +- Provider 在没有安装 `ms-service-metric` 时不形成 vLLM-Ascend 硬依赖。 | ||
| 602 | + | ||
| 603 | +## 7.3 Wheel 安装验证 | ||
| 604 | + | ||
| 605 | +1. 从目标 vLLM-Ascend commit 构建或安装 Wheel。 | ||
| 606 | +2. 确认 `importlib.metadata.entry_points()` 只发现一个 `vllm-ascend` Provider。 | ||
| 607 | +3. 确认 Entry Point distribution 为 `vllm_ascend`,不是测试 bootstrap 包。 | ||
| 608 | +4. 加载 Provider 并确认 `ownership_mode == "overlay"`。 | ||
| 609 | +5. 确认 `config_paths` 对应 Wheel 安装目录中的全部 YAML 且文件存在。 | ||
| 610 | +6. 确认 Handler 从 Wheel 安装路径导入。 | ||
| 611 | + | ||
| 612 | +## 7.4 场景一:Core + 旧 vLLM-Ascend | ||
| 613 | + | ||
| 614 | +- 确认没有 vLLM-Ascend Metrics Provider,或没有残留测试 bootstrap Entry Point。 | ||
| 615 | +- 清空独立的 Prometheus multiprocess 目录。 | ||
| 616 | +- 启动基础模型、发送请求并抓取 `/metrics`。 | ||
| 617 | +- 使用 Core YAML 声明集合检查实际指标。 | ||
| 618 | +- 检查 Hook warning/error,区分版本已有点位缺失与重构回归。 | ||
| 619 | + | ||
| 620 | +验收目标:基础推理正常、Core 指标正常、没有因 Provider 缺失导致初始化失败。 | ||
| 621 | + | ||
| 622 | +## 7.5 场景二:Core + vLLM-Ascend Provider | ||
| 623 | + | ||
| 624 | +基础场景: | ||
| 625 | + | ||
| 626 | +- 确认 Provider 来源、Overlay 模式和多 YAML 路径。 | ||
| 627 | +- 使用基础模型触发 executor 和 memory 点位。 | ||
| 628 | +- 对 `base_metrics.yaml` 做声明指标与 `/metrics` 集合检查。 | ||
| 629 | +- EPLB YAML 指标在基础模型中未触发属于预期。 | ||
| 630 | + | ||
| 631 | +EPLB 场景: | ||
| 632 | + | ||
| 633 | +- 使用支持 EPLB 的 MoE 模型和对应设备数启动服务。 | ||
| 634 | +- 发送足够请求触发热度更新和专家迁移。 | ||
| 635 | +- 分别检查 `base_metrics.yaml` 与 `eplb_metrics.yaml`。 | ||
| 636 | +- Provider 声明指标应全部出现,缺失数为 0。 | ||
| 637 | +- 日志不得存在 Provider 导入、Handler 构造或 Hook 异常。 | ||
| 638 | + | ||
| 639 | +## 7.6 用户多 YAML ST | ||
| 640 | + | ||
| 641 | +1. 创建包含至少两个 YAML 的用户配置目录。 | ||
| 642 | +2. 在同一目录创建独立 Handler 文件,YAML 沿用 `module:function` 写法引用。 | ||
| 643 | +3. 设置 `MS_SERVICE_METRIC_CONFIG_PATH` 指向该目录。 | ||
| 644 | +4. 不修改 `PYTHONPATH`,启动服务并确认两个 YAML 和外部 Handler 均被加载。 | ||
| 645 | +5. 发送请求并确认两个 YAML 声明的指标均上报。 | ||
| 646 | +6. 配置语义重复项,确认没有重复记录。 | ||
| 647 | +7. 增加错误 YAML,确认候选配置被拒绝且不产生部分 Hook。 | ||
| 648 | + | ||
| 649 | +# 8. 影响面 | ||
| 650 | + | ||
| 651 | +## 8.1 对 msserviceprofiler 的影响 | ||
| 652 | + | ||
| 653 | +- 增加 Provider 稳定接口,并扩展用户配置路径的目录加载能力。 | ||
| 654 | +- 扩展配置合并与重载逻辑,是本次主要风险区域。 | ||
| 655 | +- 原有默认 YAML、Adapter 和模块式 Handler 保持兼容。 | ||
| 656 | +- 后续框架接入可以复用 Provider,不需要继续把业务代码放回 Core。 | ||
| 657 | + | ||
| 658 | +## 8.2 对 vLLM-Ascend 的影响 | ||
| 659 | + | ||
| 660 | +- 新增观测目录、YAML、Handler、Entry Point 和打包配置。 | ||
| 661 | +- 不修改 vLLM-Ascend 推理主流程。 | ||
| 662 | +- 未安装 Core 时 Entry Point 不会被消费,不形成强绑定。 | ||
| 663 | +- YAML 和 Handler 可随 vLLM-Ascend 版本一同维护和发布。 | ||
| 664 | + | ||
| 665 | +## 8.3 对用户的影响 | ||
| 666 | + | ||
| 667 | +- 旧用法不变。 | ||
| 668 | +- Provider 对用户透明,安装匹配版本后自动发现。 | ||
| 669 | +- 新增可选的单目录多 YAML 用法,Handler 使用方式不变。 | ||
| 670 | +- 新增无需安装 Python 包的用户外部 Handler 用法;已有可导入模块式 Handler 保持兼容。 | ||
| 671 | +- 用户外部 Handler 按可信代码管理,目录写权限和代码质量由部署方负责。 | ||
| 672 | + | ||
| 673 | +# 9. 缺点和风险 | ||
| 674 | + | ||
| 675 | +- 两个仓仍存在发布顺序和版本组合,需要依赖 Overlay、测试矩阵和日志降低风险。 | ||
| 676 | +- Overlay 长期保留会造成 Core 与 Provider 双份配置维护,迁移完成后应及时收敛。 | ||
| 677 | +- Exclusive 配置错误可能导致所属前缀指标缺失,启用前必须证明迁移完整。 | ||
| 678 | +- 静态 symbol UT 无法覆盖运行时动态 patch 和条件定义,仍需真实服务验证。 | ||
| 679 | +- Handler Python 代码修改仍建议重启服务;metric restart 主要保证 YAML 重载。 | ||
| 680 | +- 外部 Handler 不提供 Python 包语义和源码热重载;其依赖应使用已安装的 Python 包。 | ||
| 681 | +- 多 YAML 没有额外优先级字段,文件顺序只保证确定性,冲突语义由既有合并规则决定。 | ||
| 682 | + | ||
| 683 | +# 10. 后续演进 | ||
| 684 | + | ||
| 685 | +- 复用 Provider 架构适配 SGLang,避免在 Core 中增加新的框架业务分支。 | ||
| 686 | +- 在迁移完整并经过多个版本验证后,评估 vLLM-Ascend 切换 Exclusive。 | ||
| 687 | +- 增加独立配置预检 CLI,在服务启动前检查 YAML、symbol 和 Handler。 | ||
| 688 | +- 将 Metrics 异常时间窗与 Trace/Profiling 下钻能力关联。 | ||
| 689 | +- 根据指标开销增加 L0/L1 分级和按需启用机制。 | ||
| 690 | +- 根据真实需求评估递归配置目录或显式 manifest,而不是默认扩大扫描范围。 | ||
| 691 | + | ||
| 692 | +# 11. 未解决问题 | ||
| 693 | + | ||
| 694 | +- vLLM-Ascend 完整迁移的 symbol 清单和 Exclusive 切换版本需要由双方共同确认。 | ||
| 695 | +- Core fallback 的最终清理节奏需要与 vLLM-Ascend 最低支持版本对齐。 | ||
| 696 | +- Provider API 何时需要显式版本握手,取决于稳定门面是否出现破坏性变化。 | ||
| @@ -117,6 +117,23 @@ nav: | |||
| 117 | - Tracer: zh/cpp_api/trace_data_monitoring/Tracer.md | 117 | - Tracer: zh/cpp_api/trace_data_monitoring/Tracer.md |
| 118 | - Unattach: zh/cpp_api/trace_data_monitoring/Unattach.md | 118 | - Unattach: zh/cpp_api/trace_data_monitoring/Unattach.md |
| 119 | - 更多: | 119 | - 更多: |
| 120 | + - 典型案例: | ||
| 121 | + - 概述: zh/best_practices/README.md | ||
| 122 | + - 异步双发未生效: zh/best_practices/async_dual_launch_failure.md | ||
| 123 | + - 同一Batch内请求长度不均: zh/best_practices/batch_request_length_imbalance.md | ||
| 124 | + - DP负载不均: zh/best_practices/dp_load_imbalance.md | ||
| 125 | + - EP负载不均: zh/best_practices/ep_load_imbalance.md | ||
| 126 | + - 框架调度下发不同步: zh/best_practices/framework_dispatch_desync.md | ||
| 127 | + - KV Block数量不足: zh/best_practices/kv_block_shortage.md | ||
| 128 | + - KVCache传输影响模型性能: zh/best_practices/kvcache_transfer_performance.md | ||
| 129 | + - 模型性能劣化导致SLO劣化: zh/best_practices/model_performance_slo_degradation.md | ||
| 130 | + - 模型前后处理耗时过长: zh/best_practices/model_pre_post_processing_latency.md | ||
| 131 | + - 多实例负载不均: zh/best_practices/multi_instance_load_imbalance.md | ||
| 132 | + - PrefixCache未命中: zh/best_practices/prefix_cache_miss.md | ||
| 133 | + - 模型推理请求等待时间过长: zh/best_practices/request_wait_latency.md | ||
| 134 | + - sampler执行耗时过长: zh/best_practices/sampler_high_latency.md | ||
| 135 | + - scheduler耗时过长: zh/best_practices/scheduler_high_latency.md | ||
| 136 | + - 服务化与纯模型性能差异过大: zh/best_practices/service_vs_model_performance_gap.md | ||
| 120 | - 开发者指南: zh/developer_guide/development_guide.md | 137 | - 开发者指南: zh/developer_guide/development_guide.md |
| 121 | - 社区扩展: | 138 | - 社区扩展: |
| 122 | - 扩展列表: community_extension_docs/community_extensions.md | 139 | - 扩展列表: community_extension_docs/community_extensions.md |
| @@ -131,3 +148,4 @@ nav: | |||
| 131 | - 设计文档: | 148 | - 设计文档: |
| 132 | - 26.0.0 特性设计: design/MindStudio Service Profiler 26.0.0 特性设计说明书.md | 149 | - 26.0.0 特性设计: design/MindStudio Service Profiler 26.0.0 特性设计说明书.md |
| 133 | - ms-service-metric 监控指标设计: design/ms_service_metric_Monitoring_Metrics_Design.md | 150 | - ms-service-metric 监控指标设计: design/ms_service_metric_Monitoring_Metrics_Design.md |
| 151 | + - ms-service-metric 配置重构设计: design/ms_service_metric_Refactor_Design.md | ||
| @@ -319,7 +319,7 @@ ms-service-metric status | |||
| 319 | 319 | ||
| 320 | | 变量名 | 说明 | 默认值 | | 320 | | 变量名 | 说明 | 默认值 | |
| 321 | |--------|------|--------| | 321 | |--------|------|--------| |
| 322 | -| MS_SERVICE_METRIC_CONFIG_PATH | 配置文件路径 | 无 | | 322 | +| MS_SERVICE_METRIC_CONFIG_PATH | 用户配置文件或单层 YAML 配置目录路径 | 无 | |
| 323 | | MS_SERVICE_METRIC_SHM_PREFIX | 共享内存前缀 | /ms_service_metric | | 323 | | MS_SERVICE_METRIC_SHM_PREFIX | 共享内存前缀 | /ms_service_metric | |
| 324 | | MS_SERVICE_METRIC_MAX_PROCS | 最大进程数 | 1000 | | 324 | | MS_SERVICE_METRIC_MAX_PROCS | 最大进程数 | 1000 | |
| 325 | | PROMETHEUS_MULTIPROC_DIR | 多进程指标目录 | 无 | | 325 | | PROMETHEUS_MULTIPROC_DIR | 多进程指标目录 | 无 | |
| @@ -449,6 +449,65 @@ YAML 增加 `id`。同一 symbol、同一 Handler 但 metrics 或 labels 不同 | |||
| 449 | 已导入的 Python 模块不会热重载,因此修改 YAML 后可通过 `metric restart` 生效,修改 | 449 | 已导入的 Python 模块不会热重载,因此修改 YAML 后可通过 `metric restart` 生效,修改 |
| 450 | Handler Python 代码仍需重启服务进程。 | 450 | Handler Python 代码仍需重启服务进程。 |
| 451 | 451 | ||
| 452 | +### 用户单目录多 YAML | ||
| 453 | + | ||
| 454 | +可以通过现有环境变量加载用户目录中的多个 YAML: | ||
| 455 | + | ||
| 456 | +```bash | ||
| 457 | +export MS_SERVICE_METRIC_CONFIG_PATH=/data/custom_metrics/config | ||
| 458 | +``` | ||
| 459 | + | ||
| 460 | +`MS_SERVICE_METRIC_CONFIG_PATH` 兼容原有单 YAML 文件,也可以指向一个目录。目录模式仅按 | ||
| 461 | +文件名顺序读取目录第一层的 `.yaml` 和 `.yml` 文件,不递归读取子目录;多个文件继续使用 | ||
| 462 | +现有语义指纹去重。Handler 继续使用原有 `module.path:function_name` 写法。对于不属于 | ||
| 463 | +Core 或 Provider 允许模块的 Handler,系统会在用户配置目录中按模块名查找对应 `.py` | ||
| 464 | +文件,不需要安装额外 Python 包,也不会修改全局 `sys.path`。 | ||
| 465 | + | ||
| 466 | +```text | ||
| 467 | +/data/custom_metrics/config/ | ||
| 468 | +├── scheduler.yaml | ||
| 469 | +├── executor.yaml | ||
| 470 | +├── custom_handler.py | ||
| 471 | +└── helpers/ | ||
| 472 | + └── request_handler.py | ||
| 473 | +``` | ||
| 474 | + | ||
| 475 | +```yaml | ||
| 476 | +- symbol: vllm.example.module:target_function | ||
| 477 | + handler: custom_handler:record_duration | ||
| 478 | + | ||
| 479 | +- symbol: vllm.example.module:another_function | ||
| 480 | + handler: helpers.request_handler:record_request | ||
| 481 | +``` | ||
| 482 | + | ||
| 483 | +映射关系如下: | ||
| 484 | + | ||
| 485 | +| YAML 中的 `handler` | 实际文件 | 文件内函数 | | ||
| 486 | +|---|---|---| | ||
| 487 | +| `custom_handler:record_duration` | `<配置根目录>/custom_handler.py` | `record_duration` | | ||
| 488 | +| `helpers.request_handler:record_request` | `<配置根目录>/helpers/request_handler.py` | `record_request` | | ||
| 489 | + | ||
| 490 | +子目录只是模块路径的一部分,不要求创建 `__init__.py`。模块名的每一段必须是合法 Python | ||
| 491 | +标识符,因此不能使用 `my-handler.py`、绝对路径或 `../handler.py`。外部 Handler 文件可以 | ||
| 492 | +导入已经安装在运行环境中的第三方包,但配置根目录不会加入 `sys.path`,因此不要依赖 | ||
| 493 | +该目录内其他 Python 文件的隐式或相对导入;需要复用的代码应放入同一个 Handler 文件, | ||
| 494 | +或安装为正式 Python 包。 | ||
| 495 | + | ||
| 496 | +环境变量指向目录时,该目录就是 Handler 根目录;指向单个 YAML 时,YAML 所在目录是 | ||
| 497 | +Handler 根目录。只加载 YAML 明确引用的 `.py` 文件,不扫描或执行目录中的其他 Python | ||
| 498 | +文件。模块名必须由合法 Python 标识符组成,且解析后的文件必须位于 Handler 根目录内。 | ||
| 499 | +外部 Handler 是可信代码,部署方需要控制目录写权限。Handler 模块在进程内缓存,因此 | ||
| 500 | +修改 YAML 可使用 `metric restart`,修改 Handler Python 代码需要重启 vLLM 服务进程。 | ||
| 501 | + | ||
| 502 | +责任边界: | ||
| 503 | + | ||
| 504 | +- `ms-service-metric` 负责配置发现、路径校验、模块隔离加载、候选 Handler 预构造和失败回滚。 | ||
| 505 | +- Provider 负责维护随框架版本变化的官方 YAML、symbol 和框架业务 Handler。 | ||
| 506 | +- 用户负责外部 Handler 的代码正确性、依赖安装、目录权限和运行时异常处理;Handler | ||
| 507 | + 属于推理进程内执行的可信代码,不是安全沙箱。 | ||
| 508 | +- 用户自定义模块应避开 `ms_service_metric.*` 和 Provider 已声明的模块前缀;这些名称优先 | ||
| 509 | + 按 Core/Provider 的标准 Python import 处理,不会映射到用户目录。 | ||
| 510 | + | ||
| 452 | 兼容策略如下: | 511 | 兼容策略如下: |
| 453 | 512 | ||
| 454 | | 组合 | 行为 | | 513 | | 组合 | 行为 | |
| @@ -103,16 +103,17 @@ class VLLMMetricAdapter: | |||
| 103 | 103 | ||
| 104 | def shutdown(self): | 104 | def shutdown(self): |
| 105 | """关闭适配器""" | 105 | """关闭适配器""" |
| 106 | - if not self._initialized: | 106 | + if not self._initialized and self._manager is None: |
| 107 | return | 107 | return |
| 108 | 108 | ||
| 109 | logger.info("Shutting down VLLMMetricAdapter") | 109 | logger.info("Shutting down VLLMMetricAdapter") |
| 110 | 110 | ||
| 111 | - if self._manager: | 111 | + manager = self._manager |
| 112 | - self._manager.shutdown() | 112 | + self._manager = None |
| 113 | - self._manager = None | ||
| 114 | - | ||
| 115 | self._initialized = False | 113 | self._initialized = False |
| 114 | + if manager: | ||
| 115 | + manager.shutdown() | ||
| 116 | + | ||
| 116 | logger.info("VLLMMetricAdapter shutdown complete") | 117 | logger.info("VLLMMetricAdapter shutdown complete") |
| 117 | 118 | ||
| 118 | def _setup_dp_rank(self): | 119 | def _setup_dp_rank(self): |
| @@ -290,5 +291,16 @@ def initialize_vllm_metric(): | |||
| 290 | >>> from ms_service_metric.adapters.vllm import initialize_vllm_metric | 291 | >>> from ms_service_metric.adapters.vllm import initialize_vllm_metric |
| 291 | >>> initialize_vllm_metric() | 292 | >>> initialize_vllm_metric() |
| 292 | """ | 293 | """ |
| 293 | - adapter = get_vllm_adapter() | 294 | + adapter = None |
| 294 | - adapter.initialize() | 295 | + try: |
| 296 | + adapter = get_vllm_adapter() | ||
| 297 | + adapter.initialize() | ||
| 298 | + except Exception: | ||
| 299 | + logger.exception( | ||
| 300 | + "Failed to initialize optional vLLM metrics; metrics are disabled and vLLM startup will continue" | ||
| 301 | + ) | ||
| 302 | + if adapter is not None: | ||
| 303 | + try: | ||
| 304 | + adapter.shutdown() | ||
| 305 | + except Exception: | ||
| 306 | + logger.exception("Failed to clean up partially initialized vLLM metrics") | ||
| @@ -59,6 +59,7 @@ from ms_service_metric.core.config.provider import ( | |||
| 59 | ProviderRegistry, | 59 | ProviderRegistry, |
| 60 | ) | 60 | ) |
| 61 | from ms_service_metric.core.handler import MetricHandler | 61 | from ms_service_metric.core.handler import MetricHandler |
| 62 | +from ms_service_metric.core.external_handler_loader import resolve_external_handler_root | ||
| 62 | from ms_service_metric.utils.exceptions import ConfigError | 63 | from ms_service_metric.utils.exceptions import ConfigError |
| 63 | from ms_service_metric.utils.import_security import is_allowed_handler_module | 64 | from ms_service_metric.utils.import_security import is_allowed_handler_module |
| 64 | from ms_service_metric.utils.logger import get_logger | 65 | from ms_service_metric.utils.logger import get_logger |
| @@ -90,6 +91,7 @@ class SymbolConfigState: | |||
| 90 | user_config_path: Optional[str] | 91 | user_config_path: Optional[str] |
| 91 | default_config_path: Optional[str] | 92 | default_config_path: Optional[str] |
| 92 | framework_config_is_user: bool | 93 | framework_config_is_user: bool |
| 94 | + user_handler_root: Optional[str] | ||
| 93 | allowed_handler_module_prefixes: Tuple[str, ...] | 95 | allowed_handler_module_prefixes: Tuple[str, ...] |
| 94 | allowed_symbol_module_prefixes: Tuple[str, ...] | 96 | allowed_symbol_module_prefixes: Tuple[str, ...] |
| 95 | active_provider_names: Tuple[str, ...] | 97 | active_provider_names: Tuple[str, ...] |
| @@ -126,6 +128,7 @@ class SymbolConfig: | |||
| 126 | self._current_version = current_version | 128 | self._current_version = current_version |
| 127 | self._provider_registry = provider_registry | 129 | self._provider_registry = provider_registry |
| 128 | self._framework_config_is_user = framework_config_is_user | 130 | self._framework_config_is_user = framework_config_is_user |
| 131 | + self._user_handler_root: Optional[str] = None | ||
| 129 | self._allowed_handler_module_prefixes: Tuple[str, ...] = () | 132 | self._allowed_handler_module_prefixes: Tuple[str, ...] = () |
| 130 | self._allowed_symbol_module_prefixes: Tuple[str, ...] = () | 133 | self._allowed_symbol_module_prefixes: Tuple[str, ...] = () |
| 131 | self._active_provider_names: Tuple[str, ...] = () | 134 | self._active_provider_names: Tuple[str, ...] = () |
| @@ -188,7 +191,14 @@ class SymbolConfig: | |||
| 188 | # 2. Preserve the existing contract: an environment config replaces | 191 | # 2. Preserve the existing contract: an environment config replaces |
| 189 | # the adapter config path instead of being appended to it. | 192 | # the adapter config path instead of being appended to it. |
| 190 | environment_path = os.environ.get(self.ENV_CONFIG_PATH) | 193 | environment_path = os.environ.get(self.ENV_CONFIG_PATH) |
| 194 | + if environment_path: | ||
| 195 | + environment_path = os.path.expanduser(environment_path) | ||
| 191 | framework_path = None if self._path_exists(environment_path) else effective_framework_path | 196 | framework_path = None if self._path_exists(environment_path) else effective_framework_path |
| 197 | + user_handler_root = resolve_external_handler_root( | ||
| 198 | + environment_path, | ||
| 199 | + effective_framework_path, | ||
| 200 | + effective_framework_config_is_user, | ||
| 201 | + ) | ||
| 192 | framework_config = self._filter_config_by_version( | 202 | framework_config = self._filter_config_by_version( |
| 193 | self._load_config_path(framework_path, "framework"), | 203 | self._load_config_path(framework_path, "framework"), |
| 194 | effective_version, | 204 | effective_version, |
| @@ -259,6 +269,7 @@ class SymbolConfig: | |||
| 259 | self._framework_config_is_user = effective_framework_config_is_user | 269 | self._framework_config_is_user = effective_framework_config_is_user |
| 260 | self._default_config_path = effective_default_path | 270 | self._default_config_path = effective_default_path |
| 261 | self._user_config_path = effective_framework_path | 271 | self._user_config_path = effective_framework_path |
| 272 | + self._user_handler_root = user_handler_root | ||
| 262 | self._config = merged_config | 273 | self._config = merged_config |
| 263 | self._allowed_handler_module_prefixes = allowed_handler_module_prefixes | 274 | self._allowed_handler_module_prefixes = allowed_handler_module_prefixes |
| 264 | self._allowed_symbol_module_prefixes = allowed_symbol_module_prefixes | 275 | self._allowed_symbol_module_prefixes = allowed_symbol_module_prefixes |
| @@ -570,7 +581,37 @@ class SymbolConfig: | |||
| 570 | if not os.path.exists(path): | 581 | if not os.path.exists(path): |
| 571 | logger.warning("Metric config from %s does not exist: %s", source, path) | 582 | logger.warning("Metric config from %s does not exist: %s", source, path) |
| 572 | return {} | 583 | return {} |
| 573 | - return self._load_yaml(path) | 584 | + if os.path.isfile(path): |
| 585 | + return self._load_yaml(path) | ||
| 586 | + if not os.path.isdir(path): | ||
| 587 | + logger.warning("Metric config from %s is neither a file nor directory: %s", source, path) | ||
| 588 | + return {} | ||
| 589 | + | ||
| 590 | + config = {} | ||
| 591 | + try: | ||
| 592 | + with os.scandir(path) as entries: | ||
| 593 | + yaml_paths = sorted( | ||
| 594 | + ( | ||
| 595 | + entry.path | ||
| 596 | + for entry in entries | ||
| 597 | + if entry.is_file() and os.path.splitext(entry.name)[1].lower() in {".yaml", ".yml"} | ||
| 598 | + ), | ||
| 599 | + key=lambda item: ( | ||
| 600 | + os.path.basename(item).casefold(), | ||
| 601 | + os.path.basename(item), | ||
| 602 | + ), | ||
| 603 | + ) | ||
| 604 | + except OSError as error: | ||
| 605 | + raise ConfigError(f"Failed to scan metric config directory {path}: {error}") from error | ||
| 606 | + for yaml_path in yaml_paths: | ||
| 607 | + config = self._merge_configs(config, self._load_yaml(yaml_path)) | ||
| 608 | + logger.info( | ||
| 609 | + "Loaded %s metric YAML file(s) from %s directory: %s", | ||
| 610 | + len(yaml_paths), | ||
| 611 | + source, | ||
| 612 | + path, | ||
| 613 | + ) | ||
| 614 | + return config | ||
| 574 | 615 | ||
| 575 | 616 | ||
| 576 | def _path_exists(path: Optional[str]) -> bool: | 617 | def _path_exists(path: Optional[str]) -> bool: |
| @@ -903,6 +944,10 @@ class SymbolConfig: | |||
| 903 | """Return handler module prefixes contributed by active providers.""" | 944 | """Return handler module prefixes contributed by active providers.""" |
| 904 | return self._allowed_handler_module_prefixes | 945 | return self._allowed_handler_module_prefixes |
| 905 | 946 | ||
| 947 | + def get_user_handler_root(self) -> Optional[str]: | ||
| 948 | + """Return the committed root for user-owned external Handler files.""" | ||
| 949 | + return self._user_handler_root | ||
| 950 | + | ||
| 906 | def get_allowed_symbol_module_prefixes(self) -> Tuple[str, ...]: | 951 | def get_allowed_symbol_module_prefixes(self) -> Tuple[str, ...]: |
| 907 | """Return Symbol module prefixes contributed by active providers.""" | 952 | """Return Symbol module prefixes contributed by active providers.""" |
| 908 | return self._allowed_symbol_module_prefixes | 953 | return self._allowed_symbol_module_prefixes |
| @@ -919,6 +964,7 @@ class SymbolConfig: | |||
| 919 | user_config_path=self._user_config_path, | 964 | user_config_path=self._user_config_path, |
| 920 | default_config_path=self._default_config_path, | 965 | default_config_path=self._default_config_path, |
| 921 | framework_config_is_user=self._framework_config_is_user, | 966 | framework_config_is_user=self._framework_config_is_user, |
| 967 | + user_handler_root=self._user_handler_root, | ||
| 922 | allowed_handler_module_prefixes=self._allowed_handler_module_prefixes, | 968 | allowed_handler_module_prefixes=self._allowed_handler_module_prefixes, |
| 923 | allowed_symbol_module_prefixes=self._allowed_symbol_module_prefixes, | 969 | allowed_symbol_module_prefixes=self._allowed_symbol_module_prefixes, |
| 924 | active_provider_names=self._active_provider_names, | 970 | active_provider_names=self._active_provider_names, |
| @@ -931,6 +977,7 @@ class SymbolConfig: | |||
| 931 | self._user_config_path = state.user_config_path | 977 | self._user_config_path = state.user_config_path |
| 932 | self._default_config_path = state.default_config_path | 978 | self._default_config_path = state.default_config_path |
| 933 | self._framework_config_is_user = state.framework_config_is_user | 979 | self._framework_config_is_user = state.framework_config_is_user |
| 980 | + self._user_handler_root = state.user_handler_root | ||
| 934 | self._allowed_handler_module_prefixes = state.allowed_handler_module_prefixes | 981 | self._allowed_handler_module_prefixes = state.allowed_handler_module_prefixes |
| 935 | self._allowed_symbol_module_prefixes = state.allowed_symbol_module_prefixes | 982 | self._allowed_symbol_module_prefixes = state.allowed_symbol_module_prefixes |
| 936 | self._active_provider_names = state.active_provider_names | 983 | self._active_provider_names = state.active_provider_names |
| @@ -0,0 +1,92 @@ | |||
| 1 | +# ------------------------------------------------------------------------- | ||
| 2 | +# This file is part of the MindStudio project. | ||
| 3 | +# Copyright (c) 2025 Huawei Technologies Co.,Ltd. | ||
| 4 | +# | ||
| 5 | +# MindStudio is licensed under Mulan PSL v2. | ||
| 6 | +# You can use this software according to the terms and conditions of the Mulan PSL v2. | ||
| 7 | +# You may obtain a copy of Mulan PSL v2 at: | ||
| 8 | +# | ||
| 9 | +# http://license.coscl.org.cn/MulanPSL2 | ||
| 10 | +# | ||
| 11 | +# THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND, | ||
| 12 | +# EITHER EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT, | ||
| 13 | +# MERCHANTABILITY OR FIT FOR A PARTICULAR PURPOSE. | ||
| 14 | +# See the Mulan PSL v2 for more details. | ||
| 15 | +# ------------------------------------------------------------------------- | ||
| 16 | +"""Load explicitly configured user Handlers without changing ``sys.path``.""" | ||
| 17 | + | ||
| 18 | +import hashlib | ||
| 19 | +import importlib.util | ||
| 20 | +import sys | ||
| 21 | +import threading | ||
| 22 | +from pathlib import Path | ||
| 23 | +from types import ModuleType | ||
| 24 | +from typing import Optional | ||
| 25 | + | ||
| 26 | +from ms_service_metric.utils.exceptions import HandlerError | ||
| 27 | + | ||
| 28 | +_MODULE_LOAD_LOCK = threading.RLock() | ||
| 29 | + | ||
| 30 | + | ||
| 31 | +def resolve_external_handler_root( | ||
| 32 | + environment_path: Optional[str], | ||
| 33 | + framework_path: Optional[str], | ||
| 34 | + framework_config_is_user: bool, | ||
| 35 | +) -> Optional[str]: | ||
| 36 | + """Resolve the user-owned configuration directory, if one is active.""" | ||
| 37 | + candidate = environment_path if _path_exists(environment_path) else None | ||
| 38 | + if candidate is None and framework_config_is_user and _path_exists(framework_path): | ||
| 39 | + candidate = framework_path | ||
| 40 | + if candidate is None: | ||
| 41 | + return None | ||
| 42 | + | ||
| 43 | + resolved = Path(candidate).expanduser().resolve(strict=True) | ||
| 44 | + return str(resolved if resolved.is_dir() else resolved.parent) | ||
| 45 | + | ||
| 46 | + | ||
| 47 | +def _path_exists(path: Optional[str]) -> bool: | ||
| 48 | + return bool(path and Path(path).expanduser().exists()) | ||
| 49 | + | ||
| 50 | + | ||
| 51 | +def load_external_handler_module(module_path: str, handler_root: str) -> ModuleType: | ||
| 52 | + """Load ``module_path`` from a trusted user configuration directory.""" | ||
| 53 | + module_parts = module_path.split(".") | ||
| 54 | + if not module_path or any(not part.isidentifier() for part in module_parts): | ||
| 55 | + raise HandlerError(f"Invalid external Handler module path: {module_path!r}") | ||
| 56 | + | ||
| 57 | + try: | ||
| 58 | + root = Path(handler_root).expanduser().resolve(strict=True) | ||
| 59 | + except (FileNotFoundError, RuntimeError) as error: | ||
| 60 | + raise HandlerError(f"External Handler root does not exist: {handler_root}") from error | ||
| 61 | + if not root.is_dir(): | ||
| 62 | + raise HandlerError(f"External Handler root is not a directory: {handler_root}") | ||
| 63 | + | ||
| 64 | + module_file = root.joinpath(*module_parts).with_suffix(".py") | ||
| 65 | + try: | ||
| 66 | + resolved_file = module_file.resolve(strict=True) | ||
| 67 | + resolved_file.relative_to(root) | ||
| 68 | + except (FileNotFoundError, ValueError, RuntimeError) as error: | ||
| 69 | + raise HandlerError(f"External Handler module not found under configured root: {module_path}") from error | ||
| 70 | + | ||
| 71 | + if not resolved_file.is_file(): | ||
| 72 | + raise HandlerError(f"External Handler module is not a file: {resolved_file}") | ||
| 73 | + | ||
| 74 | + module_key = hashlib.sha256(str(resolved_file).encode("utf-8")).hexdigest() | ||
| 75 | + synthetic_name = f"_ms_service_metric_external_handler_{module_key}" | ||
| 76 | + with _MODULE_LOAD_LOCK: | ||
| 77 | + loaded_module = sys.modules.get(synthetic_name) | ||
| 78 | + if loaded_module is not None: | ||
| 79 | + return loaded_module | ||
| 80 | + | ||
| 81 | + spec = importlib.util.spec_from_file_location(synthetic_name, resolved_file) | ||
| 82 | + if spec is None or spec.loader is None: | ||
| 83 | + raise HandlerError(f"Cannot create module spec for external Handler: {resolved_file}") | ||
| 84 | + | ||
| 85 | + module = importlib.util.module_from_spec(spec) | ||
| 86 | + sys.modules[synthetic_name] = module | ||
| 87 | + try: | ||
| 88 | + spec.loader.exec_module(module) | ||
| 89 | + except Exception as error: | ||
| 90 | + sys.modules.pop(synthetic_name, None) | ||
| 91 | + raise HandlerError(f"Failed to load external Handler module: {resolved_file}") from error | ||
| 92 | + return module | ||
| @@ -66,6 +66,7 @@ from contextlib import contextmanager | |||
| 66 | from enum import Enum | 66 | from enum import Enum |
| 67 | from typing import Callable, ContextManager, Dict, List, Optional, Sequence, Tuple | 67 | from typing import Callable, ContextManager, Dict, List, Optional, Sequence, Tuple |
| 68 | 68 | ||
| 69 | +from ms_service_metric.core.external_handler_loader import load_external_handler_module | ||
| 69 | from ms_service_metric.metrics.metrics_manager import MetricConfig, MetricType | 70 | from ms_service_metric.metrics.metrics_manager import MetricConfig, MetricType |
| 70 | from ms_service_metric.utils.exceptions import HandlerError | 71 | from ms_service_metric.utils.exceptions import HandlerError |
| 71 | from ms_service_metric.utils.import_security import is_allowed_handler_module | 72 | from ms_service_metric.utils.import_security import is_allowed_handler_module |
| @@ -403,6 +404,7 @@ class MetricHandler(Handler): | |||
| 403 | config: Dict, | 404 | config: Dict, |
| 404 | symbol_path: str, | 405 | symbol_path: str, |
| 405 | allowed_handler_module_prefixes: Sequence[str] = (), | 406 | allowed_handler_module_prefixes: Sequence[str] = (), |
| 407 | + user_handler_root: Optional[str] = None, | ||
| 406 | ) -> 'MetricHandler': | 408 | ) -> 'MetricHandler': |
| 407 | """ | 409 | """ |
| 408 | 从配置创建MetricHandler实例 | 410 | 从配置创建MetricHandler实例 |
| @@ -448,12 +450,20 @@ class MetricHandler(Handler): | |||
| 448 | 'max_version': config.get('max_version'), | 450 | 'max_version': config.get('max_version'), |
| 449 | } | 451 | } |
| 450 | hook_func = None | 452 | hook_func = None |
| 453 | + fingerprint_handler_root = None | ||
| 451 | # 解析handler路径 | 454 | # 解析handler路径 |
| 452 | handler_path = config.get('handler') | 455 | handler_path = config.get('handler') |
| 453 | if handler_path: | 456 | if handler_path: |
| 457 | + module_path = handler_path.rsplit(':', 1)[0] | ||
| 458 | + if not is_allowed_handler_module( | ||
| 459 | + module_path, | ||
| 460 | + allowed_handler_module_prefixes, | ||
| 461 | + ): | ||
| 462 | + fingerprint_handler_root = user_handler_root | ||
| 454 | hook_func = cls._import_handler( | 463 | hook_func = cls._import_handler( |
| 455 | handler_path, | 464 | handler_path, |
| 456 | allowed_handler_module_prefixes, | 465 | allowed_handler_module_prefixes, |
| 466 | + user_handler_root, | ||
| 457 | ) | 467 | ) |
| 458 | else: | 468 | else: |
| 459 | hook_func = cls._import_handler("ms_service_metric.handlers:default_handler") | 469 | hook_func = cls._import_handler("ms_service_metric.handlers:default_handler") |
| @@ -467,11 +477,18 @@ class MetricHandler(Handler): | |||
| 467 | max_version=config.get('max_version'), | 477 | max_version=config.get('max_version'), |
| 468 | metrics_config=metrics_config, | 478 | metrics_config=metrics_config, |
| 469 | lock_patch=config.get('lock_patch', False), | 479 | lock_patch=config.get('lock_patch', False), |
| 470 | - config_fingerprint=cls._fingerprint_config(config), | 480 | + config_fingerprint=cls._fingerprint_config( |
| 481 | + config, | ||
| 482 | + fingerprint_handler_root, | ||
| 483 | + ), | ||
| 471 | ) | 484 | ) |
| 472 | 485 | ||
| 473 | 486 | ||
| 474 | - def _fingerprint_config(cls, config: Dict) -> str: | 487 | + def _fingerprint_config( |
| 488 | + cls, | ||
| 489 | + config: Dict, | ||
| 490 | + user_handler_root: Optional[str] = None, | ||
| 491 | + ) -> str: | ||
| 475 | """Derive identity from effective behavior, not YAML spelling.""" | 492 | """Derive identity from effective behavior, not YAML spelling.""" |
| 476 | raw_metrics = config.get("metrics", []) | 493 | raw_metrics = config.get("metrics", []) |
| 477 | if not isinstance(raw_metrics, list): | 494 | if not isinstance(raw_metrics, list): |
| @@ -483,6 +500,7 @@ class MetricHandler(Handler): | |||
| 483 | "min_version": config.get("min_version"), | 500 | "min_version": config.get("min_version"), |
| 484 | "max_version": config.get("max_version"), | 501 | "max_version": config.get("max_version"), |
| 485 | "lock_patch": config.get("lock_patch", False), | 502 | "lock_patch": config.get("lock_patch", False), |
| 503 | + "user_handler_root": user_handler_root, | ||
| 486 | "metrics": [ | 504 | "metrics": [ |
| 487 | { | 505 | { |
| 488 | "name": metric.name, | 506 | "name": metric.name, |
| @@ -529,6 +547,7 @@ class MetricHandler(Handler): | |||
| 529 | def _import_handler( | 547 | def _import_handler( |
| 530 | handler_path: str, | 548 | handler_path: str, |
| 531 | allowed_handler_module_prefixes: Sequence[str] = (), | 549 | allowed_handler_module_prefixes: Sequence[str] = (), |
| 550 | + user_handler_root: Optional[str] = None, | ||
| 532 | ) -> Callable: | 551 | ) -> Callable: |
| 533 | """ | 552 | """ |
| 534 | 导入handler函数 | 553 | 导入handler函数 |
| @@ -549,14 +568,19 @@ class MetricHandler(Handler): | |||
| 549 | 568 | ||
| 550 | module_path, func_name = handler_path.rsplit(':', 1) | 569 | module_path, func_name = handler_path.rsplit(':', 1) |
| 551 | logger.debug("Importing handler: %s.%s", module_path, func_name) | 570 | logger.debug("Importing handler: %s.%s", module_path, func_name) |
| 552 | - if not is_allowed_handler_module( | 571 | + is_allowed_module = is_allowed_handler_module( |
| 553 | module_path, | 572 | module_path, |
| 554 | allowed_handler_module_prefixes, | 573 | allowed_handler_module_prefixes, |
| 555 | - ): | 574 | + ) |
| 575 | + if not is_allowed_module and user_handler_root is None: | ||
| 556 | raise HandlerError(f"Handler module is not allowed: {module_path}") | 576 | raise HandlerError(f"Handler module is not allowed: {module_path}") |
| 557 | 577 | ||
| 558 | # 导入模块 | 578 | # 导入模块 |
| 559 | - module = importlib.import_module(module_path) | 579 | + module = ( |
| 580 | + importlib.import_module(module_path) | ||
| 581 | + if is_allowed_module | ||
| 582 | + else load_external_handler_module(module_path, user_handler_root) | ||
| 583 | + ) | ||
| 560 | 584 | ||
| 561 | # 获取函数 | 585 | # 获取函数 |
| 562 | func = getattr(module, func_name, None) | 586 | func = getattr(module, func_name, None) |
| @@ -255,6 +255,7 @@ class SymbolHandlerManager: | |||
| 255 | """Construct every candidate handler before changing live hooks.""" | 255 | """Construct every candidate handler before changing live hooks.""" |
| 256 | target_handlers: Dict[str, Handler] = {} | 256 | target_handlers: Dict[str, Handler] = {} |
| 257 | allowed_handler_module_prefixes = self._config.get_allowed_handler_module_prefixes() | 257 | allowed_handler_module_prefixes = self._config.get_allowed_handler_module_prefixes() |
| 258 | + user_handler_root = self._config.get_user_handler_root() | ||
| 258 | 259 | ||
| 259 | for symbol_path, handlers_config in config.items(): | 260 | for symbol_path, handlers_config in config.items(): |
| 260 | if not isinstance(handlers_config, list): | 261 | if not isinstance(handlers_config, list): |
| @@ -269,6 +270,7 @@ class SymbolHandlerManager: | |||
| 269 | handler_config, | 270 | handler_config, |
| 270 | symbol_path, | 271 | symbol_path, |
| 271 | allowed_handler_module_prefixes, | 272 | allowed_handler_module_prefixes, |
| 273 | + user_handler_root, | ||
| 272 | ) | 274 | ) |
| 273 | except Exception as error: | 275 | except Exception as error: |
| 274 | raise ConfigError(f"Failed to create handler for {symbol_path}: {error}") from error | 276 | raise ConfigError(f"Failed to create handler for {symbol_path}: {error}") from error |
| @@ -0,0 +1,71 @@ | |||
| 1 | +# ------------------------------------------------------------------------- | ||
| 2 | +# This file is part of the MindStudio project. | ||
| 3 | +# Copyright (c) 2025 Huawei Technologies Co.,Ltd. | ||
| 4 | +# | ||
| 5 | +# MindStudio is licensed under Mulan PSL v2. | ||
| 6 | +# You can use this software according to the terms and conditions of the Mulan PSL v2. | ||
| 7 | +# You may obtain a copy of Mulan PSL v2 at: | ||
| 8 | +# | ||
| 9 | +# http://license.coscl.org.cn/MulanPSL2 | ||
| 10 | +# | ||
| 11 | +# THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND, | ||
| 12 | +# EITHER EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT, | ||
| 13 | +# MERCHANTABILITY OR FIT FOR A PARTICULAR PURPOSE. | ||
| 14 | +# See the Mulan PSL v2 for more details. | ||
| 15 | +# ------------------------------------------------------------------------- | ||
| 16 | +"""Configuration source tests for user-owned external Handlers.""" | ||
| 17 | + | ||
| 18 | +from ms_service_metric.core.config.symbol_config import SymbolConfig | ||
| 19 | + | ||
| 20 | + | ||
| 21 | +def test_given_env_config_directory_when_load_then_commits_external_handler_root( | ||
| 22 | + tmp_path, | ||
| 23 | + monkeypatch, | ||
| 24 | +): | ||
| 25 | + config_dir = tmp_path / "configs" | ||
| 26 | + config_dir.mkdir() | ||
| 27 | + (config_dir / "metrics.yaml").write_text( | ||
| 28 | + "- symbol: m.n:o\n handler: custom_handler:record\n", | ||
| 29 | + encoding="utf-8", | ||
| 30 | + ) | ||
| 31 | + monkeypatch.setenv(SymbolConfig.ENV_CONFIG_PATH, str(config_dir)) | ||
| 32 | + | ||
| 33 | + config = SymbolConfig() | ||
| 34 | + config.load() | ||
| 35 | + | ||
| 36 | + assert config.get_user_handler_root() == str(config_dir.resolve()) | ||
| 37 | + | ||
| 38 | + | ||
| 39 | +def test_given_env_config_file_when_load_then_parent_is_external_handler_root( | ||
| 40 | + tmp_path, | ||
| 41 | + monkeypatch, | ||
| 42 | +): | ||
| 43 | + config_file = tmp_path / "metrics.yaml" | ||
| 44 | + config_file.write_text( | ||
| 45 | + "- symbol: m.n:o\n handler: custom_handler:record\n", | ||
| 46 | + encoding="utf-8", | ||
| 47 | + ) | ||
| 48 | + monkeypatch.setenv(SymbolConfig.ENV_CONFIG_PATH, str(config_file)) | ||
| 49 | + | ||
| 50 | + config = SymbolConfig() | ||
| 51 | + config.load() | ||
| 52 | + | ||
| 53 | + assert config.get_user_handler_root() == str(tmp_path.resolve()) | ||
| 54 | + | ||
| 55 | + | ||
| 56 | +def test_given_user_owned_framework_config_when_load_then_enables_its_handler_root( | ||
| 57 | + tmp_path, | ||
| 58 | +): | ||
| 59 | + config_file = tmp_path / "metrics.yaml" | ||
| 60 | + config_file.write_text( | ||
| 61 | + "- symbol: m.n:o\n handler: custom_handler:record\n", | ||
| 62 | + encoding="utf-8", | ||
| 63 | + ) | ||
| 64 | + | ||
| 65 | + config = SymbolConfig( | ||
| 66 | + user_config_path=str(config_file), | ||
| 67 | + framework_config_is_user=True, | ||
| 68 | + ) | ||
| 69 | + config.load() | ||
| 70 | + | ||
| 71 | + assert config.get_user_handler_root() == str(tmp_path.resolve()) | ||
| @@ -240,6 +240,135 @@ def test_given_disallowed_handler_module_when_import_then_raises_handler_error() | |||
| 240 | MetricHandler._import_handler("evil.module:payload") | 240 | MetricHandler._import_handler("evil.module:payload") |
| 241 | 241 | ||
| 242 | 242 | ||
| 243 | +def test_given_external_handler_root_when_from_config_then_loads_handler_without_sys_path_change( | ||
| 244 | + tmp_path, | ||
| 245 | +): | ||
| 246 | + handler_file = tmp_path / "custom_handler.py" | ||
| 247 | + handler_file.write_text( | ||
| 248 | + "def record(ctx):\n yield\n", | ||
| 249 | + encoding="utf-8", | ||
| 250 | + ) | ||
| 251 | + original_sys_path = list(__import__("sys").path) | ||
| 252 | + | ||
| 253 | + handler = MetricHandler.from_config( | ||
| 254 | + {"handler": "custom_handler:record"}, | ||
| 255 | + "module:func", | ||
| 256 | + user_handler_root=str(tmp_path), | ||
| 257 | + ) | ||
| 258 | + | ||
| 259 | + assert handler._hook_func.__name__ == "record" | ||
| 260 | + assert __import__("sys").path == original_sys_path | ||
| 261 | + | ||
| 262 | + | ||
| 263 | +def test_given_nested_external_handler_when_from_config_then_maps_dotted_module_to_file( | ||
| 264 | + tmp_path, | ||
| 265 | +): | ||
| 266 | + nested = tmp_path / "custom" / "handlers.py" | ||
| 267 | + nested.parent.mkdir() | ||
| 268 | + nested.write_text("def record(ctx):\n yield\n", encoding="utf-8") | ||
| 269 | + | ||
| 270 | + handler = MetricHandler.from_config( | ||
| 271 | + {"handler": "custom.handlers:record"}, | ||
| 272 | + "module:func", | ||
| 273 | + user_handler_root=str(tmp_path), | ||
| 274 | + ) | ||
| 275 | + | ||
| 276 | + assert handler._hook_func.__name__ == "record" | ||
| 277 | + | ||
| 278 | + | ||
| 279 | + | ||
| 280 | + "handler_path", | ||
| 281 | + [ | ||
| 282 | + "../outside:record", | ||
| 283 | + "custom/handler:record", | ||
| 284 | + ".custom:record", | ||
| 285 | + ], | ||
| 286 | +) | ||
| 287 | +def test_given_unsafe_external_module_path_when_import_then_rejects( | ||
| 288 | + tmp_path, | ||
| 289 | + handler_path, | ||
| 290 | +): | ||
| 291 | + with pytest.raises(HandlerError, match="Invalid external Handler module path"): | ||
| 292 | + MetricHandler._import_handler( | ||
| 293 | + handler_path, | ||
| 294 | + user_handler_root=str(tmp_path), | ||
| 295 | + ) | ||
| 296 | + | ||
| 297 | + | ||
| 298 | +def test_given_missing_external_handler_function_when_import_then_raises(tmp_path): | ||
| 299 | + (tmp_path / "custom.py").write_text("VALUE = 1\n", encoding="utf-8") | ||
| 300 | + | ||
| 301 | + with pytest.raises(HandlerError, match="function not found"): | ||
| 302 | + MetricHandler._import_handler( | ||
| 303 | + "custom:record", | ||
| 304 | + user_handler_root=str(tmp_path), | ||
| 305 | + ) | ||
| 306 | + | ||
| 307 | + | ||
| 308 | +def test_given_failed_external_module_load_when_file_fixed_then_retry_succeeds(tmp_path): | ||
| 309 | + handler_file = tmp_path / "custom.py" | ||
| 310 | + handler_file.write_text("raise RuntimeError('broken')\n", encoding="utf-8") | ||
| 311 | + | ||
| 312 | + with pytest.raises(HandlerError, match="Failed to load external Handler module"): | ||
| 313 | + MetricHandler._import_handler( | ||
| 314 | + "custom:record", | ||
| 315 | + user_handler_root=str(tmp_path), | ||
| 316 | + ) | ||
| 317 | + | ||
| 318 | + handler_file.write_text("def record(ctx):\n yield\n", encoding="utf-8") | ||
| 319 | + handler = MetricHandler._import_handler( | ||
| 320 | + "custom:record", | ||
| 321 | + user_handler_root=str(tmp_path), | ||
| 322 | + ) | ||
| 323 | + assert handler.__name__ == "record" | ||
| 324 | + | ||
| 325 | + | ||
| 326 | +def test_given_missing_external_handler_root_when_import_then_raises(tmp_path): | ||
| 327 | + with pytest.raises(HandlerError, match="root does not exist"): | ||
| 328 | + MetricHandler._import_handler( | ||
| 329 | + "custom:record", | ||
| 330 | + user_handler_root=str(tmp_path / "missing"), | ||
| 331 | + ) | ||
| 332 | + | ||
| 333 | + | ||
| 334 | +def test_given_external_handler_symlink_escape_when_import_then_rejects(tmp_path): | ||
| 335 | + outside = tmp_path.parent / f"{tmp_path.name}_outside.py" | ||
| 336 | + outside.write_text("def record(ctx):\n yield\n", encoding="utf-8") | ||
| 337 | + link = tmp_path / "linked.py" | ||
| 338 | + try: | ||
| 339 | + link.symlink_to(outside) | ||
| 340 | + except OSError: | ||
| 341 | + pytest.skip("Creating file symlinks is not permitted on this platform") | ||
| 342 | + | ||
| 343 | + with pytest.raises(HandlerError, match="not found under configured root"): | ||
| 344 | + MetricHandler._import_handler( | ||
| 345 | + "linked:record", | ||
| 346 | + user_handler_root=str(tmp_path), | ||
| 347 | + ) | ||
| 348 | + | ||
| 349 | + | ||
| 350 | +def test_given_different_external_roots_when_build_handlers_then_ids_are_distinct(tmp_path): | ||
| 351 | + first = tmp_path / "first" | ||
| 352 | + second = tmp_path / "second" | ||
| 353 | + first.mkdir() | ||
| 354 | + second.mkdir() | ||
| 355 | + for root in (first, second): | ||
| 356 | + (root / "custom.py").write_text("def record(ctx):\n yield\n", encoding="utf-8") | ||
| 357 | + | ||
| 358 | + first_handler = MetricHandler.from_config( | ||
| 359 | + {"handler": "custom:record"}, | ||
| 360 | + "module:func", | ||
| 361 | + user_handler_root=str(first), | ||
| 362 | + ) | ||
| 363 | + second_handler = MetricHandler.from_config( | ||
| 364 | + {"handler": "custom:record"}, | ||
| 365 | + "module:func", | ||
| 366 | + user_handler_root=str(second), | ||
| 367 | + ) | ||
| 368 | + | ||
| 369 | + assert first_handler.id != second_handler.id | ||
| 370 | + | ||
| 371 | + | ||
| 243 | def test_given_empty_config_when_from_config_then_uses_default_handler(): | 372 | def test_given_empty_config_when_from_config_then_uses_default_handler(): |
| 244 | config = {} | 373 | config = {} |
| 245 | handler = MetricHandler.from_config(config, "module:func") | 374 | handler = MetricHandler.from_config(config, "module:func") |
| @@ -42,6 +42,69 @@ def test_given_array_yaml_when_load_then_converted_to_symbol_map(tmp_path): | |||
| 42 | assert out["a.b:Cls.fn"][0]["handler"] == "ms_service_metric.handlers:default_handler" | 42 | assert out["a.b:Cls.fn"][0]["handler"] == "ms_service_metric.handlers:default_handler" |
| 43 | 43 | ||
| 44 | 44 | ||
| 45 | +def test_given_config_directory_when_load_then_reads_sorted_top_level_yaml(tmp_path): | ||
| 46 | + config_dir = tmp_path / "configs" | ||
| 47 | + config_dir.mkdir() | ||
| 48 | + (config_dir / "b.yml").write_text( | ||
| 49 | + "- symbol: framework.module:second\n metrics:\n - name: second:duration\n", | ||
| 50 | + encoding="utf-8", | ||
| 51 | + ) | ||
| 52 | + (config_dir / "a.yaml").write_text( | ||
| 53 | + "- symbol: framework.module:first\n metrics:\n - name: first:duration\n", | ||
| 54 | + encoding="utf-8", | ||
| 55 | + ) | ||
| 56 | + (config_dir / "ignored.txt").write_text("not yaml", encoding="utf-8") | ||
| 57 | + nested_dir = config_dir / "nested" | ||
| 58 | + nested_dir.mkdir() | ||
| 59 | + (nested_dir / "nested.yaml").write_text( | ||
| 60 | + "- symbol: framework.module:nested\n metrics:\n - name: nested:duration\n", | ||
| 61 | + encoding="utf-8", | ||
| 62 | + ) | ||
| 63 | + | ||
| 64 | + config = SymbolConfig(user_config_path=str(config_dir)).load() | ||
| 65 | + | ||
| 66 | + framework_symbols = [symbol for symbol in config if symbol.startswith("framework.module:")] | ||
| 67 | + assert framework_symbols == [ | ||
| 68 | + "framework.module:first", | ||
| 69 | + "framework.module:second", | ||
| 70 | + ] | ||
| 71 | + assert "framework.module:nested" not in config | ||
| 72 | + | ||
| 73 | + | ||
| 74 | +def test_given_duplicate_handlers_across_yaml_files_when_load_then_keeps_one(tmp_path): | ||
| 75 | + config_dir = tmp_path / "configs" | ||
| 76 | + config_dir.mkdir() | ||
| 77 | + content = "- symbol: framework.module:fn\n metrics:\n - name: request:duration\n" | ||
| 78 | + (config_dir / "first.yaml").write_text(content, encoding="utf-8") | ||
| 79 | + (config_dir / "second.yml").write_text(content, encoding="utf-8") | ||
| 80 | + | ||
| 81 | + config = SymbolConfig(user_config_path=str(config_dir)).load() | ||
| 82 | + | ||
| 83 | + assert len(config["framework.module:fn"]) == 1 | ||
| 84 | + | ||
| 85 | + | ||
| 86 | +def test_given_invalid_yaml_in_config_directory_when_reload_then_keeps_committed_config( | ||
| 87 | + tmp_path, | ||
| 88 | +): | ||
| 89 | + valid_path = tmp_path / "valid.yaml" | ||
| 90 | + valid_path.write_text( | ||
| 91 | + "- symbol: framework.module:valid\n metrics:\n - name: valid:duration\n", | ||
| 92 | + encoding="utf-8", | ||
| 93 | + ) | ||
| 94 | + config_dir = tmp_path / "configs" | ||
| 95 | + config_dir.mkdir() | ||
| 96 | + (config_dir / "valid.yaml").write_text(valid_path.read_text(encoding="utf-8"), encoding="utf-8") | ||
| 97 | + (config_dir / "invalid.yaml").write_text(":\n - invalid", encoding="utf-8") | ||
| 98 | + symbol_config = SymbolConfig(user_config_path=str(valid_path)) | ||
| 99 | + committed = symbol_config.load() | ||
| 100 | + | ||
| 101 | + with pytest.raises(Exception): | ||
| 102 | + symbol_config.load(config_path=str(config_dir)) | ||
| 103 | + | ||
| 104 | + assert symbol_config.get_config() == committed | ||
| 105 | + assert symbol_config._user_config_path == str(valid_path) | ||
| 106 | + | ||
| 107 | + | ||
| 45 | def test_given_env_user_config_when_load_then_env_path_takes_precedence(tmp_path, monkeypatch): | 108 | def test_given_env_user_config_when_load_then_env_path_takes_precedence(tmp_path, monkeypatch): |
| 46 | env_cfg = tmp_path / "env.yaml" | 109 | env_cfg = tmp_path / "env.yaml" |
| 47 | env_cfg.write_text("- symbol: m.n:o\n handler: ms_service_metric.handlers:default_handler\n", encoding="utf-8") | 110 | env_cfg.write_text("- symbol: m.n:o\n handler: ms_service_metric.handlers:default_handler\n", encoding="utf-8") |
| @@ -58,6 +121,24 @@ def test_given_env_user_config_when_load_then_env_path_takes_precedence(tmp_path | |||
| 58 | assert "x.y:z" not in out | 121 | assert "x.y:z" not in out |
| 59 | 122 | ||
| 60 | 123 | ||
| 124 | +def test_given_tilde_env_config_directory_when_load_then_expands_user_path(tmp_path, monkeypatch): | ||
| 125 | + config_dir = tmp_path / "custom_metrics" | ||
| 126 | + config_dir.mkdir() | ||
| 127 | + (config_dir / "metrics.yaml").write_text( | ||
| 128 | + "- symbol: user.module:fn\n handler: ms_service_metric.handlers:default_handler\n", | ||
| 129 | + encoding="utf-8", | ||
| 130 | + ) | ||
| 131 | + monkeypatch.setenv("HOME", str(tmp_path)) | ||
| 132 | + monkeypatch.setenv("USERPROFILE", str(tmp_path)) | ||
| 133 | + monkeypatch.setenv(SymbolConfig.ENV_CONFIG_PATH, "~/custom_metrics") | ||
| 134 | + | ||
| 135 | + symbol_config = SymbolConfig() | ||
| 136 | + config = symbol_config.load() | ||
| 137 | + | ||
| 138 | + assert "user.module:fn" in config | ||
| 139 | + assert symbol_config.get_user_handler_root() == str(config_dir.resolve()) | ||
| 140 | + | ||
| 141 | + | ||
| 61 | def test_given_env_handler_for_default_symbol_when_load_then_appends(tmp_path, monkeypatch): | 142 | def test_given_env_handler_for_default_symbol_when_load_then_appends(tmp_path, monkeypatch): |
| 62 | default_cfg = tmp_path / "default.yaml" | 143 | default_cfg = tmp_path / "default.yaml" |
| 63 | default_cfg.write_text( | 144 | default_cfg.write_text( |
| @@ -345,6 +345,33 @@ def test_given_handler_build_failure_when_restarting_then_keeps_existing_hooks( | |||
| 345 | assert m._updating is False | 345 | assert m._updating is False |
| 346 | 346 | ||
| 347 | 347 | ||
| 348 | +def test_given_user_yaml_and_external_handler_when_building_then_loads_from_config_root( | ||
| 349 | + tmp_path, | ||
| 350 | + monkeypatch, | ||
| 351 | + mock_symbol_cls, | ||
| 352 | +): | ||
| 353 | + from ms_service_metric.core.symbol_handler_manager import SymbolHandlerManager | ||
| 354 | + | ||
| 355 | + config_dir = tmp_path / "config" | ||
| 356 | + config_dir.mkdir() | ||
| 357 | + (config_dir / "metrics.yaml").write_text( | ||
| 358 | + "- symbol: module:func\n handler: custom_handler:record\n", | ||
| 359 | + encoding="utf-8", | ||
| 360 | + ) | ||
| 361 | + (config_dir / "custom_handler.py").write_text( | ||
| 362 | + "def record(ctx):\n yield\n", | ||
| 363 | + encoding="utf-8", | ||
| 364 | + ) | ||
| 365 | + monkeypatch.setenv("MS_SERVICE_METRIC_CONFIG_PATH", str(config_dir)) | ||
| 366 | + | ||
| 367 | + manager = SymbolHandlerManager() | ||
| 368 | + config = manager._config.load(default_config_path=str(tmp_path / "missing.yaml")) | ||
| 369 | + handlers = manager._build_target_handlers(config) | ||
| 370 | + | ||
| 371 | + assert len(handlers) == 1 | ||
| 372 | + assert next(iter(handlers.values()))._hook_func.__name__ == "record" | ||
| 373 | + | ||
| 374 | + | ||
| 348 | def test_given_reconcile_failure_when_starting_then_clears_live_state( | 375 | def test_given_reconcile_failure_when_starting_then_clears_live_state( |
| 349 | mock_symbol_cls, | 376 | mock_symbol_cls, |
| 350 | ): | 377 | ): |
| @@ -14,11 +14,14 @@ | |||
| 14 | # See the Mulan PSL v2 for more details. | 14 | # See the Mulan PSL v2 for more details. |
| 15 | # ------------------------------------------------------------------------- | 15 | # ------------------------------------------------------------------------- |
| 16 | 16 | ||
| 17 | +from unittest.mock import MagicMock | ||
| 18 | + | ||
| 17 | import pytest | 19 | import pytest |
| 18 | import yaml | 20 | import yaml |
| 19 | 21 | ||
| 20 | import ms_service_metric.adapters.vllm.adapter as adapter_module | 22 | import ms_service_metric.adapters.vllm.adapter as adapter_module |
| 21 | from ms_service_metric.adapters.vllm.adapter import VLLMMetricAdapter | 23 | from ms_service_metric.adapters.vllm.adapter import VLLMMetricAdapter |
| 24 | +from ms_service_metric.utils.exceptions import SharedMemoryError | ||
| 22 | 25 | ||
| 23 | 26 | ||
| 24 | 27 | ||
| @@ -66,6 +69,46 @@ def test_setup_dp_rank_falls_back_to_process_name(monkeypatch): | |||
| 66 | assert captured == [1] | 69 | assert captured == [1] |
| 67 | 70 | ||
| 68 | 71 | ||
| 72 | +def test_given_optional_metric_initialization_failure_when_plugin_loads_then_vllm_startup_continues( | ||
| 73 | + monkeypatch, | ||
| 74 | + caplog, | ||
| 75 | +): | ||
| 76 | + adapter = MagicMock() | ||
| 77 | + adapter.initialize.side_effect = SharedMemoryError("posix_ipc unavailable") | ||
| 78 | + monkeypatch.setattr(adapter_module, "get_vllm_adapter", lambda: adapter) | ||
| 79 | + | ||
| 80 | + adapter_module.initialize_vllm_metric() | ||
| 81 | + | ||
| 82 | + adapter.shutdown.assert_called_once_with() | ||
| 83 | + assert "vLLM startup will continue" in caplog.text | ||
| 84 | + | ||
| 85 | + | ||
| 86 | +def test_given_partial_metric_cleanup_failure_when_plugin_loads_then_exception_is_not_propagated( | ||
| 87 | + monkeypatch, | ||
| 88 | + caplog, | ||
| 89 | +): | ||
| 90 | + adapter = MagicMock() | ||
| 91 | + adapter.initialize.side_effect = RuntimeError("initialization failed") | ||
| 92 | + adapter.shutdown.side_effect = RuntimeError("cleanup failed") | ||
| 93 | + monkeypatch.setattr(adapter_module, "get_vllm_adapter", lambda: adapter) | ||
| 94 | + | ||
| 95 | + adapter_module.initialize_vllm_metric() | ||
| 96 | + | ||
| 97 | + adapter.shutdown.assert_called_once_with() | ||
| 98 | + assert "Failed to clean up partially initialized" in caplog.text | ||
| 99 | + | ||
| 100 | + | ||
| 101 | +def test_given_partial_manager_when_adapter_shutdown_then_manager_is_cleaned(): | ||
| 102 | + adapter = VLLMMetricAdapter() | ||
| 103 | + manager = MagicMock() | ||
| 104 | + adapter._manager = manager | ||
| 105 | + | ||
| 106 | + adapter.shutdown() | ||
| 107 | + | ||
| 108 | + manager.shutdown.assert_called_once_with() | ||
| 109 | + assert adapter._manager is None | ||
| 110 | + | ||
| 111 | + | ||
| 69 | def test_v1_metrics_config_contains_exception_status_hooks(): | 112 | def test_v1_metrics_config_contains_exception_status_hooks(): |
| 70 | config_path = adapter_module.os.path.join( | 113 | config_path = adapter_module.os.path.join( |
| 71 | adapter_module.os.path.dirname(adapter_module.__file__), | 114 | adapter_module.os.path.dirname(adapter_module.__file__), |
🟡 Medium Priority
变更行:
external_handler_loader.py第43行Path(candidate).expanduser().resolve(strict=True)(新增函数);symbol_config.py第194行self._path_exists(environment_path)及第579行os.path.exists(path)(未修改的已有代码)。触发条件:用户在
MS_SERVICE_METRIC_CONFIG_PATH中使用~路径(如~/my_configs)。失败模式:配置加载静默失败(返回空字典),但 handler root 却被正确设置——两条路径行为不一致,用户难以排查。建议:在
load()方法中对environment_path调用os.path.expanduser()展开~,使后续的_path_exists和_load_config_path能正确处理;或在_path_exists/_load_config_path内部展开。与resolve_external_handler_root保持一致。