diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a5933ea..db22054 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -30,11 +30,10 @@ jobs: - uses: actions/setup-python@v5 with: python-version: '3.10' - - name: install sdk - run: pip install -e python - - name: install framework + test deps - # httpx2:starlette.testclient(FastAPI 中间件单测用)的传输依赖,缺了会 RuntimeError - run: pip install 'fastapi>=0.100' 'flask>=2.0' 'django>=4.0' 'pytest>=8.0' 'httpx2>=2.0.0' + - name: install sdk + test deps + # [test] extra 含 pytest / httpx2 与三个 Web 框架。框架必须硬装:中间件单测用 + # pytest.importorskip,缺框架会静默跳过 —— CI 全绿但中间件零覆盖。 + run: pip install -e 'python[test]' - name: pytest working-directory: python run: python -m pytest -q diff --git a/AGENTS.md b/AGENTS.md index 9e51b07..a99cf9d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -84,7 +84,7 @@ built := m.NewCounterVec("built_releases", "发布的构建数", "kind") built.Inc("tag") built.IncWithContext(ctx, "tag") // 请求内 → 该条 series 的 community 取 ctx 覆盖值 -// 中间件:注入 request_id + 可信判定点解析 community + 记 obs_http_server_* +// 中间件:注入 request_id + 可信判定点解析 community + 记 http_server_* h := obshttpmw.New(obshttpmw.Options{ Metrics: m, ResolveCommunity: func(r *http.Request) string { /* "/mindspore" → "mindspore" */ return "" }, @@ -137,7 +137,7 @@ const m = new obs.metrics.Metrics({ service: 'review', env: 'test', instance: 'p const built = m.counter('built_releases_total', '发布的构建数', ['kind']); // prom-client 要最终名,SDK 不改名 built.inc(1, { kind: 'tag' }); -// 中间件:注入 request_id + 解析 community + 记 obs_http_server_* +// 中间件:注入 request_id + 解析 community + 记 http_server_* const { makeMiddleware, metricsRouteHandler } = obs.middleware; app.use(makeMiddleware({ metrics: m, resolveCommunity: (req) => undefined })); app.get('/metrics', metricsRouteHandler(m)); diff --git a/README.md b/README.md index fb05f54..e83eb4c 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,7 @@ Go 模块的 tag 必须是 `<子目录>/v<版本>` 形式(如 `go/v1.0.0`) | community 双层注入 | `service/env/instance` 部署级 const;`community` 可变 label/字段:请求级可信判定点覆盖,未覆盖回退部署默认(`OBS_*` 环境变量) | | 请求上下文 | Go `sdkctx`(context.Context)、Python `contextvars`、Node `AsyncLocalStorage`、Java `RequestContext`(ThreadLocal) | | trace_id 预留 | 字段可写可透传,首期不落 span | -| 服务端指标 | Go/Python/Node 由 SDK 中间件埋 `obs_http_server_*`;Java 走 Actuator + Micrometer 官方 server instrumentation(不重复埋点) | +| 服务端指标 | Go/Python/Node 由 SDK 中间件埋 `http_server_*`;Java 走 Actuator + Micrometer 官方 server instrumentation(不重复埋点) | ## 验证 diff --git "a/docs/\345\276\256\346\234\215\345\212\241\345\217\257\350\247\202\346\265\213\346\200\247\345\273\272\350\256\276\346\212\200\346\234\257\350\256\276\350\256\241.md" "b/docs/\345\276\256\346\234\215\345\212\241\345\217\257\350\247\202\346\265\213\346\200\247\345\273\272\350\256\276\346\212\200\346\234\257\350\256\276\350\256\241.md" index ced0341..06609cf 100644 --- "a/docs/\345\276\256\346\234\215\345\212\241\345\217\257\350\247\202\346\265\213\346\200\247\345\273\272\350\256\276\346\212\200\346\234\257\350\256\276\350\256\241.md" +++ "b/docs/\345\276\256\346\234\215\345\212\241\345\217\257\350\247\202\346\265\213\346\200\247\345\273\272\350\256\276\346\212\200\346\234\257\350\256\276\350\256\241.md" @@ -356,7 +356,7 @@ flowchart LR - **命名**:全小写 `snake_case`,单位后缀遵循 Prometheus 约定(`_total` / `_seconds` / `_bytes`)。 - 业务指标强制前缀 `_`(服务短名)。例:`review_http_requests_total`。 - - 共享中间件公共指标用 SDK 保留前缀 **`obs_`**(不按 service 名开头——同一条 series 已带 `service` label,查询按 label 过滤)。例:`obs_http_server_requests_total`。 + - 共享中间件公共指标**不加任何前缀**(不按 service 名开头,也不加 SDK 保留前缀——同一条 series 已带 `service` label,查询按 label 过滤)。例:`http_server_requests_total`。 - **通用 label 集**(每条 series 必须带):`service` / `env` / `instance` / `community`。 - 前三个是 const label;**`community` 是唯一可动态的公共 label**。 - **community 双层注入的指标实现**:对需要区分社区的指标,注册时把 `community` 声明为**普通可变 label**(service/env/instance 仍作 const label),打点时由 SDK 从上下文取覆盖值。**注册一次,单社区场景填默认值、多社区场景填覆盖值,两用。** @@ -382,7 +382,7 @@ flowchart LR | Init | `log.Init(cfg)` / `metrics.New(cfg)` | `log.init(...)` / `metrics.init(...)` | `ObsLogging.init(cfg)` / `ObsMetrics.of(cfg)` | `log.init(opts)` / `new Metrics(opts)` | | 请求上下文 | `sdkctx`(`context.Context`) | `_context`(contextvars) | `RequestContext`(MDC + ThreadLocal) | `context`(AsyncLocalStorage) | | 中间件 | `middleware`(net/http)+ `ginmw`(gin) | `fastapi_wrap` / `flask_middleware` / `DjangoMiddleware` | `ObsFilter`(Servlet Filter) | `makeMiddleware`(express) | -| 服务端指标 | `obs_http_server_*`(中间件) | 同上 | 走 Actuator + Micrometer 官方 server instrumentation(不重复埋点) | `obs_http_server_*`(中间件) | +| 服务端指标 | `http_server_*`(中间件) | 同上 | 走 Actuator + Micrometer 官方 server instrumentation(不重复埋点) | `http_server_*`(中间件) | | UT 数量 | 31 | 15 | 19 | 10 | | 已发版 | ✅ `go/v1.0.0` | ❌ 版本号 `0.1.0`,未打 tag | ❌ 版本号 `0.1.0`,未打 tag | ❌ 版本号 `0.1.0`,未打 tag | | CI | `go vet` + `go test` | `pytest`(Py3.10) | `mvn test`(JDK 17) | `npm test`(Node 20) | @@ -481,7 +481,7 @@ from obs_sdk.middleware import fastapi_wrap # Flask: flask_middleware log.init(service="") # env/community 由 SDK 读 OBS_*,进程内幂等 metrics.init(service="") -fastapi_wrap(app, resolver=resolve_community) # 注入 request_id + 可信判定点解析 community + 记 obs_http_server_* +fastapi_wrap(app, resolver=resolve_community) # 注入 request_id + 可信判定点解析 community + 记 http_server_* logger = log.get_logger(__name__) logger.info("job done", extra={"event": "release", "issue": "2061"}) diff --git a/go/README.md b/go/README.md index fdd887d..14a3d89 100644 --- a/go/README.md +++ b/go/README.md @@ -8,7 +8,7 @@ opensourceways 微服务可观测薄封装 SDK 的 Go 实现,契约见 [spec/] - `error` 值统一序列化为 `err.Error()` 文本(`encoding/json` 会把多数错误渲染成 `{}`) - **指标**:`metrics` package —— `client_golang` 薄封装(counter/gauge/histogram),label 规范见 spec/metrics-format.md - **请求上下文**:`sdkctx` —— `context.Context` 承载 `community/request_id/trace_id/span_id` -- **中间件**:`middleware`(net/http)+ `middleware/ginmw`(gin)—— 注入 request_id、可信判定点解析 community、记 `obs_http_server_*` 指标 +- **中间件**:`middleware`(net/http)+ `middleware/ginmw`(gin)—— 注入 request_id、可信判定点解析 community、记 `http_server_*` 指标 - **community 双层注入**:`service/env/instance` 部署级 const label;`community` 普通可变 label —— 请求上下文覆盖,未覆盖回退部署默认(`OBS_*` 环境变量) ## 目录 diff --git a/go/middleware/ginmw/ginmw.go b/go/middleware/ginmw/ginmw.go index 6603dd8..6608cff 100644 --- a/go/middleware/ginmw/ginmw.go +++ b/go/middleware/ginmw/ginmw.go @@ -1,7 +1,8 @@ // Package ginmw 提供 gin 框架的观测中间件(obs-sdk-go)。 // // 与 net/http 的 middleware 职责一致(注入 request_id / community、可选记录 -// obs_http_server_* 服务器指标),只是适配 gin.HandlerFunc。gin 用户按此接入: +// http_server_* 服务器指标 —— 不加前缀,见 spec/metrics-format.md), +// 只是适配 gin.HandlerFunc。gin 用户按此接入: // // import "github.com/opensourceways/obs-sdk/go/middleware/ginmw" // diff --git a/go/middleware/middleware.go b/go/middleware/middleware.go index 40cc4c2..4f84dfc 100644 --- a/go/middleware/middleware.go +++ b/go/middleware/middleware.go @@ -4,7 +4,8 @@ // - 为请求注入 request_id(无则生成);有可信入站头(X-Request-Id)则沿用; // - 可选从可信来源解析 community 写入 context(由解析函数提供,SDK 不裸透传); // - 若配置绑定了 *metrics.Metrics,则记录 HTTP 服务器指标 -// obs_http_server_requests_total / obs_http_server_request_duration_seconds。 +// http_server_requests_total / http_server_request_duration_seconds(不加前缀, +// 同一条 series 已带 service label,见 spec/metrics-format.md)。 // // 用法(net/http): // @@ -55,7 +56,7 @@ type Middleware struct { func New(opts Options) *Middleware { md := &Middleware{opts: opts} if m := opts.Metrics; m != nil { - // 服务器公共指标用 obs_ 前缀(spec:不以 service 名开头,按 label 过滤)。 + // 服务器公共指标不加任何前缀(spec:同一条 series 已带 service label,按 label 过滤)。 md.reqTotal = m.NewCounterVec("http_server_requests_total", "HTTP requests handled", "method", "path", "status_code") md.reqDuration = m.NewHistogramVec("http_server_request_duration_seconds", diff --git a/java/src/main/java/io/opensourceways/obssdk/ObsMetrics.java b/java/src/main/java/io/opensourceways/obssdk/ObsMetrics.java index 166ef98..a04ea13 100644 --- a/java/src/main/java/io/opensourceways/obssdk/ObsMetrics.java +++ b/java/src/main/java/io/opensourceways/obssdk/ObsMetrics.java @@ -25,7 +25,8 @@ *
  • service/env/instance 三个部署级字段注册为 common tags(const label);
  • *
  • community 建模为普通可变 label:值取请求上下文覆盖(可信判定点显式写入), * 无覆盖时回退部署默认 —— 「注册一次两用」,单社区/多社区共用同一注册点。
  • - *
  • {@code namespace} 可选:给指标名加前缀(跨服务共享 SDK 时用)。
  • + *
  • {@code namespace} 可选:给指标名加前缀(如用 {@code service} 拼业务指标的 + * {@code _} 前缀);SDK 不为中间件公共指标定义前缀,别用它补前缀。
  • * * *

    注意:本 SDK 不重复造 HTTP 服务端指标 —— Java 服务通常走 Spring Boot Actuator + diff --git a/java/src/main/java/io/opensourceways/obssdk/ObsSdkConfig.java b/java/src/main/java/io/opensourceways/obssdk/ObsSdkConfig.java index 07c683b..5f85c76 100644 --- a/java/src/main/java/io/opensourceways/obssdk/ObsSdkConfig.java +++ b/java/src/main/java/io/opensourceways/obssdk/ObsSdkConfig.java @@ -70,7 +70,13 @@ public String community() { return community; } - /** 可选命名空间前缀(跨服务共享 SDK 埋点时用 obs_ 等前缀区分,见 spec/metrics-format.md)。 */ + /** + * 可选命名空间前缀,拼在指标名之前(如 {@code "review"} → {@code review_built_releases})。 + * + *

    默认不设 —— 业务指标按 spec 应以 {@code _} 开头,接入服务可用它把 + * service 前缀一并交给 SDK 拼;**不要**用来补 SDK 保留前缀,spec 不为中间件公共指标 + * 定义任何前缀(同一条 series 已带 {@code service} label)。

    + */ public String namespace() { return namespace; } diff --git a/node/README.md b/node/README.md index 4e0cdc7..fdbb232 100644 --- a/node/README.md +++ b/node/README.md @@ -5,7 +5,7 @@ opensourceways 微服务可观测薄封装 SDK 的 Node 实现,契约见 [spec - **日志**:`lib/log` —— 单行 JSON 写 stream(默认 stdout) - **指标**:`lib/metrics` —— prom-client 薄封装(counter/gauge/histogram) - **请求上下文**:`lib/context` —— `AsyncLocalStorage` 承载 `community/request_id/trace_id/span_id`(后两者为二期 trace 预留位) -- **中间件**:`lib/middleware` —— Express/通用 HTTP 中间件(注入 request_id + 可信判定点解析 community + 记 `obs_http_server_*`) +- **中间件**:`lib/middleware` —— Express/通用 HTTP 中间件(注入 request_id + 可信判定点解析 community + 记 `http_server_*`) - **community 双层注入**:`service/env/instance` 常驻;`community` 可变 —— 请求上下文覆盖,未覆盖回退部署默认(`OBS_*` 环境变量) ## 用法 @@ -32,7 +32,7 @@ const mw = makeMiddleware({ // community 必须在可信判定点解析(路由前缀/认证主体/白名单),见 spec/community-values.md resolveCommunity: (req) => (req.url.startsWith('/mindspore') ? 'mindspore' : undefined), }); -// 业务 app 里 use(mw) 即可:注入 request_id + push 请求上下文 + 请求结束记 obs_http_server_* 指标 +// 业务 app 里 use(mw) 即可:注入 request_id + push 请求上下文 + 请求结束记 http_server_* 指标 // /metrics 暴露:app.get('/metrics', metricsRouteHandler(m)); ``` diff --git a/node/lib/middleware.js b/node/lib/middleware.js index c54c210..694e1d2 100644 --- a/node/lib/middleware.js +++ b/node/lib/middleware.js @@ -4,7 +4,8 @@ // // 职责(薄装配,见 spec/common-fields.md):注入 request_id(沿用可信入站头 // X-Request-Id 或生成);可选从可信判定点解析 community;可选记录服务器指标 -// obs_http_server_requests_total / obs_http_server_request_duration_seconds。 +// http_server_requests_total / http_server_request_duration_seconds(不加前缀 —— +// 同一条 series 已带 service label,见 spec/metrics-format.md)。 const { randomUUID } = require('crypto'); const context = require('./context'); diff --git a/python/README.md b/python/README.md index 6ba0a98..2e63e4c 100644 --- a/python/README.md +++ b/python/README.md @@ -5,7 +5,7 @@ opensourceways 微服务可观测薄封装 SDK 的 Python 实现,契约见 [sp - **日志**:`obs_sdk.log` —— 结构化 JSON(root logger 挂唯一 JsonHandler,字段规范见 spec/log-format.md) - **指标**:`obs_sdk.metrics` —— prometheus-client 薄封装,自带独立 CollectorRegistry - **请求上下文**:`obs_sdk._context` —— `contextvars` 承载 `community/request_id/trace_id/span_id`(后两者为二期 trace 预留位) -- **框架适配**:`obs_sdk.middleware` —— FastAPI / Flask / Django 中间件(注入 request_id + 可信判定点解析 community) +- **框架适配**:`obs_sdk.middleware` —— FastAPI / Flask / Django 中间件(注入 request_id + 可信判定点解析 community + 记 HTTP 服务端指标) - **community 双层注入**:`service/env/instance` 常驻 const;`community` 可变 —— 请求上下文覆盖(`_context.bind`),未覆盖回退部署默认(`OBS_*` 环境变量) ## 日志用法 @@ -14,7 +14,8 @@ opensourceways 微服务可观测薄封装 SDK 的 Python 实现,契约见 [sp import logging from obs_sdk import log -# 字段空则回退 OBS_SERVICE / OBS_ENV / OBS_INSTANCE / OBS_COMMUNITY;进程内幂等 +# 三级解析:显式参数 > OBS_SERVICE / OBS_ENV / OBS_INSTANCE / OBS_COMMUNITY > 内置默认 +# 可重复调用,最后一次生效(重建 handler);请在进程启动时调用一次 log.init(service="review", env="test", instance="pod-1", community="openeuler") logger = log.get_logger(__name__) # 命名 logger,propagate 到 root 的 JSON handler @@ -41,7 +42,11 @@ metrics.histogram("review_duration", "评审耗时", ["api"]).observe(0.2) # return Response(content=metrics.generate_text(), media_type=metrics.content_type()) ``` -`metrics.init()` 默认单例读 `OBS_*` 环境变量;多注册表场景直接 `Metrics(...)`。 +`metrics.init()` 与 `log.init()` 语义一致:可重复调用、最后一次生效(重建实例与注册表); +字段同样走三级解析,`Metrics(...)` 不传参数也不会留下空 label。多注册表场景直接 `Metrics(...)`。 + +> 重建会换掉底层 `CollectorRegistry`:重建前注册的指标随之作废,且先前取到的 `_Vec` 句柄仍指向旧注册表。 +> 所以请在进程启动时调用一次;测试里可用它重置状态。 ## 请求上下文 / 中间件(community 双层注入) @@ -55,18 +60,35 @@ from obs_sdk.middleware import fastapi_wrap, flask_middleware, DjangoMiddleware app = fastapi_wrap(app, resolver=lambda req: "mindspore" if req.url.path.startswith("/mindspore") else None) # Flask -flask_middleware(app, resolver=lambda: "openeuler") # 单社区可不传 resolver +flask_middleware(app, resolver=lambda req: "openeuler") # 单社区也可直接给常量 -# Django(MIDDLEWARE 加 ObsMiddleware,子类里可覆写 resolve_community) +# Django(MIDDLEWARE 加 DjangoMiddleware,子类里可覆写 resolve_community) MIDDLEWARE = [..., "obs_sdk.middleware.DjangoMiddleware"] ``` 中间件会注入 `request_id`(沿用 `X-Request-Id` 或生成)并 push 请求上下文;请求内日志 / 指标自动带覆盖值, 处理结束上下文还原。 +同时记 HTTP 服务端指标(spec/metrics-format.md): + +| 指标 | label | +| --- | --- | +| `http_server_requests_total` | `method` `path` `status_code` | +| `http_server_request_duration_seconds` | 同上(直方图,桶边界与 Go / Node 对齐) | + +`path` 取**路由模板**(`/items/{item_id}`),不是原始 URL —— 原始路径带 ID 会撑爆时序基数; +404 / 未匹配归到 `unmatched`。`service/env/instance/community` 由 SDK 自动补齐。 + +服务已有等价 instrumentation(如自挂 prometheus-fastapi-instrumentator)时,传 +`collect_server_metrics=False` 关掉,避免同一指标被两处记录(`DjangoMiddleware` 则在子类里把 +`collect_server_metrics` 置 `False`)。 + ## 验证 ```bash -pip install -e "python[test,fastapi,flask,django]" # 或 virtualenv 装 obs_sdk + 框架 -cd python && pytest +pip install -e '.[test]' # 在 python/ 下;[test] 已含 pytest + 三个 Web 框架 +pytest ``` + +框架用 `pytest.importorskip` 跳过 —— 不装就会「静默跳过」,本地看着全绿而中间件实际零覆盖, +所以 `[test]` 里把 fastapi / flask / django 一起钉上了。 diff --git a/python/obs_sdk/log.py b/python/obs_sdk/log.py index eec9ffe..cd35e38 100644 --- a/python/obs_sdk/log.py +++ b/python/obs_sdk/log.py @@ -40,6 +40,10 @@ # 标识本 SDK 挂在 logger 上的 handler。 _SDK_HANDLER_NAME = "obs-sdk-json" +# SDK 自己的 logger 名。log.info(...) 这类便捷函数走它,而不是 root —— root 是 +# 宿主应用的全局开关,SDK 不去改它的 level(见 get_logger 注释)。 +_SDK_LOGGER_NAME = "obs_sdk" + class JsonFormatter(logging.Formatter): """把日志记录格式化为单行 JSON。""" @@ -130,7 +134,6 @@ def init(*, service: Optional[str] = None, env: Optional[str] = None, _log_level = getattr(logging, level.upper(), logging.INFO) root = logging.getLogger() - root.setLevel(logging.DEBUG) # 过滤交给 formatter 层 SDK 自己的 handler 级别控制 # 移除旧 SDK handler,挂新配置的。 for h in list(root.handlers): @@ -142,11 +145,23 @@ def init(*, service: Optional[str] = None, env: Optional[str] = None, def get_logger(name: Optional[str] = None) -> logging.Logger: - """返回一个 logger(命名或 root)。命名 logger 经 propagate 落到 root 的 - JSON handler,单条日志只输出一次。""" + """返回一个可直接打点的 logger。 + + 命名 logger 经 propagate 落到 root 上 SDK 挂的 JSON handler,单条日志只输出一次。 + 不传名字时返回 SDK 自己的 logger(**不是 root**)。 + + 级别只设在 SDK 交出的这个 logger 上,不碰 root:root 是宿主应用的全局开关, + 把它的 level 压到 DEBUG(此前行为)会让应用自己挂在 root 上的 handler 也开始 + 收到 DEBUG 记录 —— 一个 SDK 不该改动宿主的全局日志级别。代价是第三方库 + (uvicorn / werkzeug 等)的日志级别由应用自己的配置决定,不再被 SDK 放宽。 + + 返回的 logger 级别由 init(level=...) 决定;应用如需另行调整,自行 setLevel 即可。 + """ if _defaults is None: init() - return logging.getLogger(name) + logger = logging.getLogger(_SDK_LOGGER_NAME if name is None else name) + logger.setLevel(_log_level) + return logger # --- 便捷函数(命名 = 调用方模块名) --- diff --git a/python/obs_sdk/metrics.py b/python/obs_sdk/metrics.py index 2de0956..ed3867d 100644 --- a/python/obs_sdk/metrics.py +++ b/python/obs_sdk/metrics.py @@ -11,9 +11,9 @@ from obs_sdk import metrics metrics.init(service="meeting-center") # 或读 OBS_* 环境变量 - c = metrics.counter("meeting_created_total", "created meetings", "kind") + c = metrics.counter("meeting_created_total", "created meetings", ["kind"]) c.inc(kind="scheduled") # community = ctx 覆盖或部署默认 - c.inc(2, kind="cancelled") # 业务 label 走关键字/位置均可 + c.inc(2, kind="cancelled") # 业务 label 按关键字传 # /metrics 文本: from obs_sdk import metrics @@ -67,12 +67,18 @@ def observe(self, value: float, **labelvalues: str) -> None: class Metrics: """装配入口:持有独立 CollectorRegistry,避免全局注册表相互污染。""" - def __init__(self, *, service: str, env: str, instance: str, - community: str, namespace: str = "") -> None: - self._common = {"service": service, "env": env, "instance": instance, - "community": community} + def __init__(self, *, service: Optional[str] = None, env: Optional[str] = None, + instance: Optional[str] = None, community: Optional[str] = None, + namespace: str = "") -> None: + # 构造时即完成三级解析(显式参数 > OBS_* > 内置默认),与 Go/Java 一致: + # 空 label 不可接受 —— 不设兜底就会出现 service="",同一条 series 与其它语言 + # 对不上,按 label 过滤时静默漏数。见 spec/common-fields.md。 + self._common = {"service": _env.service(service), "env": _env.env(env), + "instance": _env.instance(instance), + "community": _env.community(community)} self._namespace = namespace self._registry = CollectorRegistry(auto_describe=True) + self._http_server: Optional["_HttpServer"] = None @property def registry(self) -> CollectorRegistry: @@ -113,6 +119,50 @@ def text(self) -> bytes: """输出该注册表 Prometheus text 格式。""" return generate_latest(self._registry) + def http_server(self) -> "_HttpServer": + """中间件 HTTP 服务端公共指标句柄(懒注册,同一实例只注册一次)。 + + 注册必须只发生一次:prometheus_client 对同名指标的重复注册会直接抛 + ValueError,所以缓存放在这里,三个框架适配器共用。 + """ + if self._http_server is None: + self._http_server = _HttpServer(self) + return self._http_server + + +# 中间件公共指标名(spec/metrics-format.md「默认暴露的中间件指标」): +# 不加任何前缀 —— 同一条 series 已带 service label,查询按 label 过滤,名字里再编 +# 前缀是重复信息,且会让跨服务的统一查询失效。Go / Node 用同一套名字。 +SERVER_REQUESTS_TOTAL = "http_server_requests_total" +SERVER_REQUEST_DURATION_SECONDS = "http_server_request_duration_seconds" + +# 显式钉住桶边界:与 Go(client_golang 默认)/ Node(DEFAULT_METRIC_BUCKETS)对齐。 +# prometheus_client 自带默认多了 .075/.75/7.5 三个点,不钉就会与另两个语言不一致。 +SERVER_DURATION_BUCKETS = [0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10] + + +class _HttpServer: + """HTTP 服务端公共指标:请求计数 + 时延(秒)。 + + label 除四个公共维度外为 method / path / status_code(counter 与 histogram 都带, + spec 明示:要支持「按状态码看时延」)。**path 必须传路由模板(如 `/items/{item_id}`) + 而不是原始 URL** —— 原始路径带 ID 会撑爆时序基数。拿不到模板时由调用方传 "unmatched"。 + """ + + def __init__(self, m: "Metrics") -> None: + labels = ["method", "path", "status_code"] + self._total = m.counter(SERVER_REQUESTS_TOTAL, "HTTP requests handled", labels) + self._duration = m.histogram(SERVER_REQUEST_DURATION_SECONDS, + "HTTP request latency", labels, + buckets=SERVER_DURATION_BUCKETS) + + def observe_request(self, *, method: str, path: str, + status_code: int, seconds: float) -> None: + """记一次请求。必须在请求上下文仍绑定时调用 —— community label 取自上下文。""" + labels = {"method": method, "path": path, "status_code": str(status_code)} + self._total.inc(1, **labels) + self._duration.observe(seconds, **labels) + # 进程级便捷单例(默认读 OBS_* 环境变量)。 _default: Optional[Metrics] = None @@ -121,16 +171,15 @@ def text(self) -> bytes: def init(*, service: Optional[str] = None, env: Optional[str] = None, instance: Optional[str] = None, community: Optional[str] = None, namespace: str = "") -> Metrics: - """初始化(进程内单例)。空字段读 OBS_* 环境变量。""" + """初始化进程级单例。空字段读 OBS_* 环境变量,未设置回退内置默认。 + + 与 `log.init` 语义一致:**可重复调用,最后一次生效**(重建实例与注册表)。 + 重建会换掉底层 CollectorRegistry —— 重建前注册的指标随之作废,且先前取到的 + _Vec 句柄仍指向旧注册表。所以请在进程启动时调用一次;测试里可用它重置状态。 + """ global _default - if _default is None: - _default = Metrics( - service=_env.service(service), - env=_env.env(env), - instance=_env.instance(instance), - community=_env.community(community), - namespace=namespace, - ) + _default = Metrics(service=service, env=env, instance=instance, + community=community, namespace=namespace) return _default diff --git a/python/obs_sdk/middleware.py b/python/obs_sdk/middleware.py index c6b95e6..8d56875 100644 --- a/python/obs_sdk/middleware.py +++ b/python/obs_sdk/middleware.py @@ -1,11 +1,18 @@ -"""框架适配器:在入口把可信解析的请求级字段 bind 进 context,并挂 /metrics。 +"""框架适配器:在入口把可信解析的请求级字段 bind 进 context,并记 HTTP 服务端指标。 设计约束(spec/common-fields.md):community 请求覆盖必须来自可信判定点 (路由前缀 / 认证主体 / 白名单)。SDK 提供 bind 入口,由业务提供 community_resolver;SDK 不裸透传外部入参。 -三个适配器共享同一套「取入站 request_id + 可选 community」逻辑,区别仅在 -框架挂载 API。框架未安装时导入对应函数会 ImportError,业务按其实际依赖 +服务端指标(spec/metrics-format.md「默认暴露的中间件指标」):三个适配器都会记 +`http_server_requests_total` / `http_server_request_duration_seconds`, +与 Go / Node 中间件**同名同 label**。**path label 取路由模板**(如 `/items/{item_id}`), +不是原始 URL —— 原始路径带 ID 会撑爆时序基数;拿不到模板时记为 `unmatched`。 +不需要 SDK 记这两个指标时传 `collect_server_metrics=False`(服务自己已有 +等价 instrumentation 的情况)。 + +三个适配器共享同一套「取入站 request_id + 可选 community + 记服务端指标」逻辑, +区别仅在框架挂载 API。框架未安装时导入对应函数会 ImportError,业务按其实际依赖 选装(见 pyproject optional-dependencies)。 /metrics 路由不在本文件实现:业务按各自框架加一条返回 @@ -14,10 +21,11 @@ from __future__ import annotations +import time import uuid from typing import Callable, Optional -from . import _context +from . import _context, metrics as obs_metrics # 可信入站请求 ID 头(沿用 Go 版常量)。 HEADER_REQUEST_ID = "X-Request-Id" @@ -25,6 +33,9 @@ # community 解析函数:入参为各框架 request 对象,返回社区字符串或 None。 Resolver = Callable[[object], Optional[str]] +# 路由模板取不到时的 path label 取值(404 / 未匹配)。 +UNMATCHED_PATH = "unmatched" + def make_request_id(inbound: Optional[str]) -> str: """沿用入站 request_id,否则生成。""" @@ -40,12 +51,72 @@ def bind_request(request, community: Optional[str]) -> "_context._GeneratorConte return _context.bind(community=community, request_id=make_request_id(rid)) +# --------------------------------------------------------------------------- +# 服务端指标装配(三个适配器共用) +# --------------------------------------------------------------------------- + + +def _http_server(metrics, collect: bool) -> Optional["obs_metrics._HttpServer"]: + """取 HTTP 服务端指标句柄。 + + metrics 为 None 时用进程级单例(与 log/metrics 模块级函数的懒初始化语义一致), + 这样按文档 `fastapi_wrap(app, resolver=...)` 直接接就能拿到服务端指标。 + """ + if not collect: + return None + target = obs_metrics.default() if metrics is None else metrics + return target.http_server() + + +def _route_template(request) -> str: + """取路由模板(低基数)。三个框架的取法不同,统一在这里兜一遍。 + + 顺序无所谓——每种框架只会命中其中一种;都取不到(如 404)时返回 unmatched。 + """ + # Starlette / FastAPI:router 匹配后把 route 写进 scope(call_next 返回后可见)。 + scope = getattr(request, "scope", None) + if isinstance(scope, dict): + route = scope.get("route") + template = getattr(route, "path", None) + if template: + return template + + # Flask:url_rule.rule 即模板(/items/)。 + rule = getattr(getattr(request, "url_rule", None), "rule", None) + if rule: + return rule + + # Django:resolver_match.route 即模板(items//)。 + route = getattr(getattr(request, "resolver_match", None), "route", None) + if route: + return route + + return UNMATCHED_PATH + + +def _record(server, request, status_code: int, start: float) -> None: + """记一次请求。 + + 必须在请求上下文仍绑定时调用:community label 由 _Vec 从当前上下文取, + 上下文一 reset 就只剩部署默认值了。 + """ + if server is None: + return + server.observe_request( + method=request.method, + path=_route_template(request), + status_code=status_code, + seconds=time.perf_counter() - start, + ) + + # --------------------------------------------------------------------------- # FastAPI(ASGI,基于 starlette BaseHTTPMiddleware)。 # --------------------------------------------------------------------------- -def fastapi_wrap(app, resolver: Optional[Resolver] = None): +def fastapi_wrap(app, resolver: Optional[Resolver] = None, metrics=None, + collect_server_metrics: bool = True): """为 FastAPI app 挂载观测中间件,返回原 app(已加中间件)。 用法: @@ -62,23 +133,34 @@ def metrics_route(): """ from starlette.middleware.base import BaseHTTPMiddleware + server = _http_server(metrics, collect_server_metrics) + class ObsMiddleware(BaseHTTPMiddleware): async def dispatch(self, request, call_next): community = resolver(request) if resolver else None + start = time.perf_counter() with bind_request(request, community): - return await call_next(request) + try: + response = await call_next(request) + except Exception: + # 视图抛异常:无响应对象,按 500 记一次再原样抛出。 + _record(server, request, 500, start) + raise + _record(server, request, response.status_code, start) + return response app.add_middleware(ObsMiddleware) return app # --------------------------------------------------------------------------- -# Flask(WSGI,before/teardown)。 +# Flask(WSGI,before/after/teardown)。 # --------------------------------------------------------------------------- -def flask_middleware(app, resolver: Optional[Resolver] = None): - """为 Flask app 挂载观测 before/after 钩子。 +def flask_middleware(app, resolver: Optional[Resolver] = None, metrics=None, + collect_server_metrics: bool = True): + """为 Flask app 挂载观测钩子。 用法: @@ -94,15 +176,34 @@ def metrics_route(): """ import flask + server = _http_server(metrics, collect_server_metrics) + + def record_once(status_code: int) -> None: + # 没走过 before_request(如更早的钩子直接返回)时 _obs_start 不存在,跳过。 + start = getattr(flask.g, "_obs_start", None) + if start is None or getattr(flask.g, "_obs_recorded", True): + return + flask.g._obs_recorded = True + _record(server, flask.request, status_code, start) + @app.before_request def _obs_before(): - community = resolver(flask.request) if resolver else None - ctx = bind_request(flask.request, community) + ctx = bind_request(flask.request, resolver(flask.request) if resolver else None) flask.g._obs_ctx = ctx + flask.g._obs_start = time.perf_counter() + flask.g._obs_recorded = False ctx.__enter__() + @app.after_request + def _obs_after(response): + record_once(response.status_code) + return response + @app.teardown_request def _obs_teardown(exc=None): + if exc is not None: + # 视图抛异常时 after_request 不执行,在这里补记 500,避免漏计。 + record_once(500) ctx = getattr(flask.g, "_obs_ctx", None) if ctx is not None: ctx.__exit__(None, None, None) @@ -111,27 +212,39 @@ def _obs_teardown(exc=None): # --------------------------------------------------------------------------- -# Django(WSGI middleware)。 +# Django(WSGI / ASGI middleware)。 # --------------------------------------------------------------------------- class DjangoMiddleware: - """Django 观测中间件(请求级字段 bind + request_id 注入)。 + """Django 观测中间件(请求级字段 bind + request_id 注入 + 服务端指标)。 用法(settings.MIDDLEWARE 追加): "obs_sdk.middleware.DjangoMiddleware", 多社区中心化服务覆写 resolve_community(request) 按可信来源返回社区。 + 不需要 SDK 记服务端指标时,子类里把 collect_server_metrics 置 False。 """ + #: 子类可覆写为 False 关闭 SDK 的服务端指标(服务已有等价 instrumentation 时)。 + collect_server_metrics = True + def __init__(self, get_response: Callable): self.get_response = get_response + self._server = _http_server(None, self.collect_server_metrics) def __call__(self, request): community = self.resolve_community(request) + start = time.perf_counter() with bind_request(request, community): - return self.get_response(request) + try: + response = self.get_response(request) + except Exception: + _record(self._server, request, 500, start) + raise + _record(self._server, request, response.status_code, start) + return response def resolve_community(self, request) -> Optional[str]: """覆写点:按可信来源返回社区;默认不覆盖(用部署级默认)。""" diff --git a/python/pyproject.toml b/python/pyproject.toml index b52e6b6..a2f6cf6 100644 --- a/python/pyproject.toml +++ b/python/pyproject.toml @@ -21,6 +21,12 @@ test = [ "pytest>=8.0", # starlette.testclient(FastAPI 中间件单测)的传输依赖;不装则 testclient 抛 RuntimeError "httpx2>=2.0.0", + # 三个框架必须一起装:中间件单测用 pytest.importorskip 跳过缺失的框架, + # 不装就会「静默跳过」——本地看着全绿,实际中间件零覆盖。CI 里也是单独装它们, + # 这里补上后 `pip install -e ".[test]"` 一条命令就能跑全套。 + "fastapi>=0.100", + "flask>=2.0", + "django>=4.0", ] [tool.setuptools.packages.find] diff --git a/python/tests/test_env.py b/python/tests/test_env.py new file mode 100644 index 0000000..b4691d7 --- /dev/null +++ b/python/tests/test_env.py @@ -0,0 +1,62 @@ +"""_env 单测:静态字段三级解析(显式参数 > OBS_* > 内置默认)。 + +这层此前没有测试覆盖,而 Java SDK 的同类缺陷(缺兜底 → 字段落成 null → +日志里整条消失)正是从无覆盖的地方漏到合入后的,所以这里把三级逐级钉住。 +""" + +import socket + +import pytest + +from obs_sdk import _env + +# 必然未设置的环境变量名,用于验证「环境变量缺失 → 兜底」这一级。 +UNSET = "OBS_SDK_TEST_KEY_THAT_IS_NEVER_SET" + + +def test_explicit_wins_over_env_and_default(monkeypatch): + # 该环境变量确实有值时,显式参数仍优先。 + monkeypatch.setenv(UNSET, "from-env") + assert _env._resolve("explicit", UNSET, "unknown") == "explicit" + assert _env.service("review") == "review" + assert _env.env("test") == "test" + assert _env.community("openeuler") == "openeuler" + assert _env.instance("pod-1") == "pod-1" + + +def test_env_wins_over_default(monkeypatch): + monkeypatch.setenv("OBS_SERVICE", "from-env") + monkeypatch.delenv("OBS_SDK_TEST_KEY", raising=False) + assert _env.service(None) == "from-env" + + +def test_empty_string_treated_as_unset(monkeypatch): + # 空串若被当成有效值,日志里就会出现 "service":"",等价于字段缺失。 + monkeypatch.setenv("OBS_SERVICE", "") + monkeypatch.setenv(UNSET, "") + assert _env.service(None) == "unknown" + assert _env._resolve("", UNSET, "unknown") == "unknown" + + +def test_falls_back_to_contract_defaults(monkeypatch): + for key in ("OBS_SERVICE", "OBS_ENV", "OBS_COMMUNITY"): + monkeypatch.delenv(key, raising=False) + assert _env.service(None) == "unknown" + assert _env.env(None) == "unknown" + assert _env.community(None) == "unknown" + + +def test_instance_falls_back_to_hostname(monkeypatch): + monkeypatch.delenv("OBS_INSTANCE", raising=False) + assert _env.instance(None) == socket.gethostname() + monkeypatch.setenv("OBS_INSTANCE", "pod-1") + assert _env.instance(None) == "pod-1" + + +def test_all_fields_never_empty(monkeypatch): + # 契约:四个部署级字段取值恒非空。任一为空 → 日志 JSON 里该键整条消失、 + # 指标 label 被跳过,采集侧静默漏数。 + for key in ("OBS_SERVICE", "OBS_ENV", "OBS_INSTANCE", "OBS_COMMUNITY"): + monkeypatch.delenv(key, raising=False) + for value in (_env.service(), _env.env(), _env.instance(), _env.community()): + assert value diff --git a/python/tests/test_log.py b/python/tests/test_log.py index f15b177..49b89e9 100644 --- a/python/tests/test_log.py +++ b/python/tests/test_log.py @@ -88,6 +88,31 @@ def test_level_filter(): assert lines[0]["msg"] == "kept" +def test_init_does_not_touch_root_level(): + # root 是宿主应用的全局开关:SDK 把它压到 DEBUG,应用自己挂在 root 上的 + # handler 也会开始收 DEBUG 记录。SDK 只在自己交出的 logger 上设级别。 + root = logging.getLogger() + original = root.level + root.setLevel(logging.WARNING) + try: + buf = _capture(level="info") + assert root.level == logging.WARNING + + logger = log.get_logger("t") + assert logger.level == logging.INFO + # 不传名字时返回 SDK 自己的 logger,而不是 root。 + assert log.get_logger().name == log._SDK_LOGGER_NAME + + # root 停在 WARNING 也不影响记录落盘:级别门限在命名 logger 上, + # 命中后经 propagate 由 root 上 SDK handler 输出(handler 级别决定)。 + logger.info("kept") + lines = _lines(buf) + assert len(lines) == 1 + assert lines[0]["msg"] == "kept" + finally: + root.setLevel(original) + + def test_trace_id_reserved_inject(): from obs_sdk import _context buf = _capture(community="openeuler") diff --git a/python/tests/test_metrics.py b/python/tests/test_metrics.py index 043e81d..184437e 100644 --- a/python/tests/test_metrics.py +++ b/python/tests/test_metrics.py @@ -74,10 +74,49 @@ def test_gauge_and_histogram(): assert count[0].endswith(" 2.0") -def test_metrics_singleton_env(): - # 便捷单例 init 幂等。 - metrics.init(service="srv", community="ascend") - a = metrics.default() - metrics.init(service="ignored") # 已建不重建 - assert metrics.default() is a - assert metrics.default().default_community == "ascend" +def test_metrics_init_always_rebuilds(monkeypatch): + # 与 log.init 统一:可重复调用,最后一次生效(重建实例与注册表)。 + for key in ("OBS_SERVICE", "OBS_ENV", "OBS_INSTANCE", "OBS_COMMUNITY"): + monkeypatch.delenv(key, raising=False) + first = metrics.init(service="srv", community="ascend") + assert metrics.default() is first + second = metrics.init(service="srv", community="mindspore") + assert second is not first + assert metrics.default() is second + assert metrics.default().default_community == "mindspore" + + +def test_fields_fallback_when_not_configured(monkeypatch): + # 同 Java:构造时即完成三级解析,空 label 不可接受 —— 空值会被注册表跳过, + # 该 series 与其它语言对不上,按 label 过滤时静默漏数。 + for key in ("OBS_SERVICE", "OBS_ENV", "OBS_INSTANCE", "OBS_COMMUNITY"): + monkeypatch.delenv(key, raising=False) + m = metrics.Metrics() # 一个参数都不给 + m.counter("events_total", "events", ["kind"]).inc(kind="pr") + + rows = [l for l in m.text().decode().splitlines() + if re.match(r"^events_total\{", l)] + assert len(rows) == 1 + for label in ("service", "env", "instance", "community"): + assert f'{label}=""' not in rows[0], rows[0] + assert f'{label}="' in rows[0], rows[0] + + +def test_http_server_handle_registered_once(): + # http_server() 幂等:prometheus_client 同名重复注册会抛 ValueError, + # 缓存句柄是中间件能在多请求下存活的唯一保障。 + m = _new() + server = m.http_server() + assert m.http_server() is server + + server.observe_request(method="GET", path="/items/{item_id}", + status_code=200, seconds=0.01) + text = m.text().decode() + assert "http_server_requests_total" in text + assert "http_server_request_duration_seconds_bucket" in text + # 路由模板原样落成 label —— 中间件传什么就是什么。 + assert 'path="/items/{item_id}"' in text + # 桶边界显式固定,含 prometheus_client 默认没有的 .005 / 10,且不含其默认多出的 .075。 + assert 'le="0.005"' in text, text + assert 'le="10.0"' in text, text + assert 'le="0.075"' not in text, text diff --git a/python/tests/test_middleware.py b/python/tests/test_middleware.py index 2482d28..0b5938e 100644 --- a/python/tests/test_middleware.py +++ b/python/tests/test_middleware.py @@ -1,18 +1,24 @@ -"""middleware 适配器单测:入口 bind community / request_id,请求内日志可见。""" +"""middleware 适配器单测。 + +覆盖两件事: + 1. 入口 bind community / request_id,请求内日志可见; + 2. 记 HTTP 服务端指标 http_server_*(spec/metrics-format.md), + 且 path label 取**路由模板**而不是原始 URL —— 原始路径带 ID 会撑爆时序基数。 +""" import io import json +import logging from typing import Dict, List import pytest -from obs_sdk import _context, log, middleware as mw +from obs_sdk import log, metrics as obs_metrics, middleware as mw def _fresh_log(buf) -> None: log._defaults = None log._log_level = 0 # INFO 实际由 init 重设 - import logging root = logging.getLogger() for h in list(root.handlers): if getattr(h, "name", None) == log._SDK_HANDLER_NAME: @@ -25,6 +31,22 @@ def _captured(buf: io.StringIO) -> List[Dict]: if l.strip()] +def _new_metrics() -> obs_metrics.Metrics: + """独立的 Metrics 实例:每个用例互不共享注册表。""" + return obs_metrics.Metrics(service="review", env="test", instance="pod-1", + community="openeuler") + + +def _rows(m: obs_metrics.Metrics, family: str) -> List[str]: + return [l for l in m.text().decode().splitlines() + if l.startswith(family + "{")] + + +def _reset_singleton() -> None: + """Django 中间件由框架实例化、拿不到 metrics 参数,只能用进程级单例。""" + obs_metrics._default = None + + # --- FastAPI --- fastapi = pytest.importorskip("fastapi") @@ -33,12 +55,13 @@ def _captured(buf: io.StringIO) -> List[Dict]: def test_fastapi_middleware_binds_context(): buf = io.StringIO() _fresh_log(buf) + m = _new_metrics() from fastapi import FastAPI from starlette.testclient import TestClient app = FastAPI() - mw.fastapi_wrap(app, resolver=lambda req: "openeuler") + mw.fastapi_wrap(app, resolver=lambda req: "openeuler", metrics=m) @app.get("/ping") def ping(): @@ -55,6 +78,72 @@ def ping(): assert lines[0]["request_id"] == "req-fastapi" +def test_fastapi_records_server_metrics_with_route_template(): + buf = io.StringIO() + _fresh_log(buf) + m = _new_metrics() + + from fastapi import FastAPI + from starlette.testclient import TestClient + + app = FastAPI() + mw.fastapi_wrap(app, resolver=lambda req: "mindspore", metrics=m) + + @app.get("/items/{item_id}") + def item(item_id: str): + return {"id": item_id} + + client = TestClient(app) + assert client.get("/items/12345").status_code == 200 + assert client.get("/nope").status_code == 404 + + rows = _rows(m, "http_server_requests_total") + by_path = {r.split('path="')[1].split('"')[0]: r for r in rows} + # 动态段归一为路由模板;未匹配(404)归到 unmatched,都不进原始路径。 + assert "/items/{item_id}" in by_path, rows + assert "unmatched" in by_path, rows + assert not any("12345" in r for r in rows), rows + + matched = by_path["/items/{item_id}"] + assert 'status_code="200"' in matched + assert 'method="GET"' in matched + # community 取请求上下文覆盖值(记在 bind 作用域内)。 + assert 'community="mindspore"' in matched + assert 'service="review"' in matched + + # 时延直方图同 label 维度,且桶边界与 Go/Node 对齐(钉住 .005 / 10)。 + text = m.text().decode() + assert "http_server_request_duration_seconds_bucket" in text + assert 'le="0.005"' in text, text + assert 'le="10.0"' in text, text + # prometheus_client 自带默认里多出来的桶不应出现(否则与另两个语言对不上)。 + assert 'le="0.075"' not in text, text + + +def test_fastapi_records_500_when_view_raises(): + buf = io.StringIO() + _fresh_log(buf) + m = _new_metrics() + + from fastapi import FastAPI + from starlette.testclient import TestClient + + app = FastAPI() + mw.fastapi_wrap(app, metrics=m) + + @app.get("/boom") + def boom(): + raise RuntimeError("boom") + + client = TestClient(app, raise_server_exceptions=False) + assert client.get("/boom").status_code == 500 + + rows = _rows(m, "http_server_requests_total") + assert rows, "视图抛异常也必须记一次,否则漏计" + assert 'status_code="500"' in rows[0] + assert 'path="/boom"' in rows[0] + + # --- Flask --- flask = pytest.importorskip("flask") @@ -63,9 +152,10 @@ def ping(): def test_flask_middleware_binds_context(): buf = io.StringIO() _fresh_log(buf) + m = _new_metrics() app = flask.Flask(__name__) - mw.flask_middleware(app, resolver=lambda req: "mindspore") + mw.flask_middleware(app, resolver=lambda req: "mindspore", metrics=m) @app.get("/ping") def ping(): @@ -82,43 +172,94 @@ def ping(): assert lines[0]["request_id"] == "req-flask" +def test_flask_records_server_metrics_with_route_template(): + buf = io.StringIO() + _fresh_log(buf) + m = _new_metrics() + + app = flask.Flask(__name__) + mw.flask_middleware(app, metrics=m) + + @app.get("/items/") + def item(item_id): + return "ok" + + with app.test_client() as c: + assert c.get("/items/12345").status_code == 200 + assert c.get("/nope").status_code == 404 + + rows = _rows(m, "http_server_requests_total") + by_path = {r.split('path="')[1].split('"')[0]: r for r in rows} + assert "/items/" in by_path, rows + assert "unmatched" in by_path, rows + assert not any("12345" in r for r in rows), rows + + # --- Django --- django = pytest.importorskip("django") +from django.http import HttpResponse # noqa: E402 (上方的 importorskip 保证 django 已装) +from django.urls import path # noqa: E402 -def test_django_middleware_binds_context(): - buf = io.StringIO() - _fresh_log(buf) +def _ping(request, **kwargs): # 参数化路由会传 pk + log.get_logger("t").info("inside django view") + return HttpResponse("ok") + + +urlpatterns = [path("ping", _ping), path("items/", _ping)] + + +def _django_setup() -> None: from django.conf import settings if not settings.configured: settings.configure( DEBUG=True, + # 仅测试用;不设的话 Django 渲染调试页时会抛 ImproperlyConfigured, + # 把真正的失败原因盖掉。 + SECRET_KEY="test-only-not-a-secret", ALLOWED_HOSTS=["testserver"], ROOT_URLCONF=__name__, MIDDLEWARE=["obs_sdk.middleware.DjangoMiddleware"], ) - import django - django.setup() + import django as _django + _django.setup() - from django.http import HttpResponse - def ping(request): - log.get_logger("t").info("inside django view") - return HttpResponse("ok") +def test_django_middleware_binds_context(): + buf = io.StringIO() + _fresh_log(buf) + _reset_singleton() + _django_setup() - from django.urls import path, resolve - from django.test import RequestFactory + from django.test import Client - req = RequestFactory().get("/ping", HTTP_X_REQUEST_ID="req-django") - # 直接经中间件调用视图。 - get_response = lambda r: ping(r) # noqa: E731 - mw_instance = mw.DjangoMiddleware(get_response) - resp = mw_instance(req) + client = Client() + resp = client.get("/ping", HTTP_X_REQUEST_ID="req-django") assert resp.status_code == 200 lines = [l for l in _captured(buf) if l.get("msg") == "inside django view"] assert len(lines) == 1 assert lines[0]["request_id"] == "req-django" assert lines[0]["community"] == "openeuler" # 未覆写 → 部署默认 + + +def test_django_records_server_metrics_with_route_template(): + buf = io.StringIO() + _fresh_log(buf) + _reset_singleton() + _django_setup() + + from django.test import Client + + client = Client() + assert client.get("/items/12345").status_code == 200 + assert client.get("/nope").status_code == 404 + + m = obs_metrics.default() # DjangoMiddleware 由框架实例化,用的是单例 + rows = _rows(m, "http_server_requests_total") + by_path = {r.split('path="')[1].split('"')[0]: r for r in rows} + assert "items/" in by_path, rows + assert "unmatched" in by_path, rows + assert not any("12345" in r for r in rows), rows diff --git a/spec/metrics-format.md b/spec/metrics-format.md index 46abcc7..97949a2 100644 --- a/spec/metrics-format.md +++ b/spec/metrics-format.md @@ -7,10 +7,12 @@ Prometheus 指标命名规则基础上追加组织约束: - 指标名全小写,`snake_case`,单位后缀遵循 Prometheus 约定(`_total` / `_seconds` / `_bytes` / `_count` / `_sum`)。 -- **强制前缀**:`_`(service 短名,服务唯一)。例:`robot-universal-review_http_requests_total` → 简写 `review_http_requests_total`。 +- **业务指标强制前缀**:`_`(service 短名,服务唯一)。例:`robot-universal-review_http_requests_total` → 简写 `review_http_requests_total`。 - 也可按服务内子系统细分:`___`。 -- 若部署跨多个服务的共享 SDK 中间件统一暴露公共指标,则用 SDK 保留前缀 `obs_`(见 middleware 指标),避免与服务业务指标混名。例:`obs_http_server_requests_total`、`obs_http_server_request_duration_seconds`。 - - 中间件指标前缀统一 `obs_`,**不以 service 名开头**,因为同一条时间序列已带 `service` label;查询按 label 过滤。 +- **中间件公共指标不加任何前缀**,直接用 `http_server_*` 这类通用名(见下节表)。既不加 `_`,也不加 SDK 保留前缀(如 `obs_`): + - 同一条时间序列已带 `service` label,查询按 label 过滤 —— 名字里再编一遍前缀是重复信息; + - 带上前缀会让「同一指标名下的跨服务查询」失效,每个服务得各写一套名字; + - Actuator / Micrometer 等**官方 instrumentation 的默认名不带这类前缀**(如 `http_server_requests_seconds`),自造前缀需要额外做一层名字映射,与「薄封装、不自研 instrumentation」冲突。 ## 通用 label 集(每个时间序列必须带) @@ -40,14 +42,27 @@ SDK 统一为所有注册的指标自动附加以下 **const label**(值来自 | 指标 | 类型 | label | | --- | --- | --- | -| `obs_http_server_requests_total` | Counter | service, env, instance, community, method, path(可选,注意基数), status_code | -| `obs_http_server_request_duration_seconds` | Histogram | service, env, instance, community, method, path(可选) | -| `obs_log_entries_total` | Counter | service, env, instance, community, level | - -> 不强制:服务只要保证**自己注册的业务指标**带通用 label 即可;中间件公共指标(若启用)用 `obs_` 前缀,且**路径不入 label 或按低基数路由模板入 label**。 +| `http_server_requests_total` | Counter | service, env, instance, community, method, path(可选,注意基数), status_code | +| `http_server_request_duration_seconds` | Histogram | service, env, instance, community, method, path(可选), status_code | +| `log_entries_total` | Counter | service, env, instance, community, level | + +> 不强制:服务只要保证**自己注册的业务指标**带通用 label 即可;中间件公共指标(若启用)用上述通用名, +> 且**路径不入 label 或按低基数路由模板入 label**(`/items/{id}`,不是 `/items/123`)。 +> +> `status_code` 在 counter 与 histogram 上**都带**:按状态码看时延(如只看 5xx 的 P99)是常见诉求, +> 只在 counter 上带会让这个查询无法表达。 +> +> 同一指标在**各语言 SDK 间名字必须一致**;Java 走 Actuator 时 Micrometer 的默认名为 +> `http_server_requests_seconds`(label 用 `status` / `uri`,无 `_total`),与上表不同 —— 这是官方 +> instrumentation 的既定形状,SDK 不做名字映射(见「服务要求」的多语言同构边界)。 ## 服务要求 - 每个服务暴露一个统一 `/metrics` HTTP 端点(Prometheus text exposition)。 - 抓取协议:HTTP `GET /metrics`,官方库默认 behavior,content-type `text/plain; version=0.0.4`。 - 多语言同构:四个语言 SDK 都要能输出**语义等价**的 `service/env/instance/community` 组合,供 ServiceMonitor 统一抓取、AOM/Cortex 统一查询。 + - 同构的**边界**:`service/env/instance/community` 这套通用 label 与「不重复埋点」的官方 instrumentation 是强约束; + 服务端指标的**名字与其余 label** 则由各语言官方库的既定形状决定,SDK 只做装配、不改名。 + 因此 Java(Actuator + Micrometer)的 `http_server_requests_seconds` / `status` / `uri` 与 + Go / Python / Node 的 `http_server_requests_total` / `status_code` / `path` 并不逐字相同。 + 跨语言统一查询请对齐**通用 label**,不要把服务端指标名写进跨语言的告警规则。