已合并
[Metrics] 支持用户单目录多 YAML 及外部 Handler #439
[Metrics] 支持用户单目录多 YAML 及外部 Handler #439
已合并
ChaseChe77创建于 8月11日
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.md117 - Tracer: zh/cpp_api/trace_data_monitoring/Tracer.md
118 - Unattach: zh/cpp_api/trace_data_monitoring/Unattach.md118 - 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.md137 - 开发者指南: zh/developer_guide/development_guide.md
121 - 社区扩展:138 - 社区扩展:
122 - 扩展列表: community_extension_docs/community_extensions.md139 - 扩展列表: community_extension_docs/community_extensions.md
@@ -131,3 +148,4 @@ nav:
131 - 设计文档:148 - 设计文档:
132 - 26.0.0 特性设计: design/MindStudio Service Profiler 26.0.0 特性设计说明书.md149 - 26.0.0 特性设计: design/MindStudio Service Profiler 26.0.0 特性设计说明书.md
133 - ms-service-metric 监控指标设计: design/ms_service_metric_Monitoring_Metrics_Design.md150 - 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` 生效,修改
450Handler Python 代码仍需重启服务进程。450Handler 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 return107 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 = False113 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_metric291 >>> 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)
61from ms_service_metric.core.handler import MetricHandler61from ms_service_metric.core.handler import MetricHandler
62+from ms_service_metric.core.external_handler_loader import resolve_external_handler_root
62from ms_service_metric.utils.exceptions import ConfigError63from ms_service_metric.utils.exceptions import ConfigError
63from ms_service_metric.utils.import_security import is_allowed_handler_module64from ms_service_metric.utils.import_security import is_allowed_handler_module
64from ms_service_metric.utils.logger import get_logger65from 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: bool93 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_version128 self._current_version = current_version
127 self._provider_registry = provider_registry129 self._provider_registry = provider_registry
128 self._framework_config_is_user = framework_config_is_user130 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 replaces191 # 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_path196 framework_path = None if self._path_exists(environment_path) else effective_framework_path
197+ user_handler_root = resolve_external_handler_root(
atomgit-bot
atomgit-botatomgit-bot8月11日

🟡 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 保持一致。

likedislike
不准确?
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_user269 self._framework_config_is_user = effective_framework_config_is_user
260 self._default_config_path = effective_default_path270 self._default_config_path = effective_default_path
261 self._user_config_path = effective_framework_path271 self._user_config_path = effective_framework_path
272+ self._user_handler_root = user_handler_root
262 self._config = merged_config273 self._config = merged_config
263 self._allowed_handler_module_prefixes = allowed_handler_module_prefixes274 self._allowed_handler_module_prefixes = allowed_handler_module_prefixes
264 self._allowed_symbol_module_prefixes = allowed_symbol_module_prefixes275 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 @staticmethod616 @staticmethod
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_prefixes945 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_prefixes953 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_path977 self._user_config_path = state.user_config_path
932 self._default_config_path = state.default_config_path978 self._default_config_path = state.default_config_path
933 self._framework_config_is_user = state.framework_config_is_user979 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_prefixes981 self._allowed_handler_module_prefixes = state.allowed_handler_module_prefixes
935 self._allowed_symbol_module_prefixes = state.allowed_symbol_module_prefixes982 self._allowed_symbol_module_prefixes = state.allowed_symbol_module_prefixes
936 self._active_provider_names = state.active_provider_names983 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
66from enum import Enum66from enum import Enum
67from typing import Callable, ContextManager, Dict, List, Optional, Sequence, Tuple67from 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
69from ms_service_metric.metrics.metrics_manager import MetricConfig, MetricType70from ms_service_metric.metrics.metrics_manager import MetricConfig, MetricType
70from ms_service_metric.utils.exceptions import HandlerError71from ms_service_metric.utils.exceptions import HandlerError
71from ms_service_metric.utils.import_security import is_allowed_handler_module72from 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 = None452 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 @classmethod486 @classmethod
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 error276 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+@pytest.mark.parametrize(
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+ 
243def test_given_empty_config_when_from_config_then_uses_default_handler():372def 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+ 
45def test_given_env_user_config_when_load_then_env_path_takes_precedence(tmp_path, monkeypatch):108def 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 out121 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+ 
61def test_given_env_handler_for_default_symbol_when_load_then_appends(tmp_path, monkeypatch):142def 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 False345 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+ 
348def test_given_reconcile_failure_when_starting_then_clears_live_state(375def 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+ 
17import pytest19import pytest
18import yaml20import yaml
19 21 
20import ms_service_metric.adapters.vllm.adapter as adapter_module22import ms_service_metric.adapters.vllm.adapter as adapter_module
21from ms_service_metric.adapters.vllm.adapter import VLLMMetricAdapter23from ms_service_metric.adapters.vllm.adapter import VLLMMetricAdapter
24+from ms_service_metric.utils.exceptions import SharedMemoryError
22 25 
23 26 
24@pytest.mark.parametrize(27@pytest.mark.parametrize(
@@ -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+ 
69def test_v1_metrics_config_contains_exception_status_hooks():112def 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__),