diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..d54fa2a --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,191 @@ +# AGENTS.md + +本文件是给 AI coding agent(Claude Code / opencode / Codex 等)在本仓库工作时的通用指引, +不绑定任何单一工具。人类读者请看 [README.md](README.md) 与各语言 README。 + +## 仓库是什么 + +opensourceways 微服务的**可观测薄封装 SDK monorepo**:把「结构化 JSON 日志 + Prometheus 指标」两个能力, +按各语言官方库做一层装配(中间件 / 通用字段注入 / 命名对齐),**不自研 instrumentation**。 +输出格式由 [spec/](spec/README.md) 契约层统一约束,四个语言实现必须对齐。 + +需求:[backlog#1938](https://github.com/opensourceways/backlog/issues/1938) · 子任务:[backlog#2061](https://github.com/opensourceways/backlog/issues/2061) + +## 铁律(改代码前必读) + +1. **`spec/` 是唯一事实来源**。涉及日志/指标字段名、取值、命名的改动,先改 spec,再改四个语言实现。实现与 spec 不一致时以 spec 为准。 +2. **薄封装,不自研 instrumentation**。指标底层一律引用官方库:Go→`client_golang`、Python→`prometheus-client`、Java→`micrometer`+Actuator、Node→`prom-client`。SDK 只做装配。 +3. **首期只做 log + metrics,不含 trace**。`trace_id` / `span_id` 是**预留注入位**:字段可写可透传、有值才输出,首期不落 span。改动时不要顺手"补全"成真 trace。 +4. **契约改动必须四语言同步**。改一个字段就要动 `spec/` + `go/` `python/` `node/` `java/`,不要只改一处。社区枚举(`community`)例外——它只是文档,随上游刷新即可。 +5. **日志只支持 kv 传参,禁止 printf 风格**。`msg` 必须是常量短语,可变数据走键值对。理由:msg 内嵌值会让每行都不同,无法聚合计数,告警规则会静默失效。 +6. **高基数值禁止当 metrics label**(`request_id` / `trace_id` / `span_id` 等),会撑爆时序基数。 +7. **`community` 只在可信判定点解析**(路由前缀 / 认证主体 / 白名单),**禁止裸读 URL / Header**。 + +## 目录结构 + +``` +spec/ 契约层:log-format / metrics-format / common-fields / community-values(唯一权威定义) +go/ obs-sdk-go —— log / metrics / sdkctx / middleware(+ginmw) +python/ obs-sdk-python —— obs_sdk/{log,metrics,_context,middleware} +node/ obs-sdk-node —— lib/{log,metrics,context,middleware} +java/ obs-sdk-java —— io.opensourceways.obssdk.{ObsSdkConfig,ObsMetrics,log,context,middleware} +``` + +各语言子目录自管版本(`go.mod` / `pyproject.toml` / `pom.xml` / `package.json`),Git tag 按语言加前缀区分。 +**Go 模块的 tag 必须是 `<子目录>/v<版本>` 形式**(如 `go/v1.0.0`)—— Go 工具链按模块根所在子目录解析 tag, +写成 `go-v1.0.0` 这种连字符形式 `go get` 拉不到该模块;其余语言无此约束。 + +## 契约速查 + +**日志字段顺序**:`time / level / msg / service / env / instance / community / request_id / trace_id / span_id / logger / error` + 业务字段(扁平,`snake_case`)。 +Go 与 Java 按此顺序输出;Python / Node 业务字段在前,但字段名与取值一致。 + +**`time`**:固定毫秒精度(3 位小数)UTC,以 `Z` 结尾(**不是** `+00:00`,**不是**纳秒)。 +**`level`**:小写 `debug` / `info` / `warn` / `error`。 +**`logger`**:调用位置 `文件:行号`(调试定位用)。Go/Java 输出,Python/Node 暂不输出(契约中该字段可选)。 +**`error`**:错误信息。Go 输出 `err.Error()` 文本(低基数);Python 等有异常上下文的语言**可含完整 traceback**(多行,JSON 转义为 `\n`,仍是单行 JSON)。**该字段不适合聚合**(取值逐次不同),按错误类型聚合请用 `msg` 常量 + 业务字段。 + +**community 双层注入**(四种语言同一语义,贯穿日志与指标): +- `service` / `env` / `instance`:**部署级** const,来自 Init 配置或 `OBS_SERVICE` / `OBS_ENV` / `OBS_INSTANCE` / `OBS_COMMUNITY` 环境变量。 +- `community`:普通**可变** label/字段 —— 请求上下文覆盖优先,未覆盖回退部署默认。中心化多社区服务靠它按请求区分。 +- `community` 取值枚举见 [spec/community-values.md](spec/community-values.md),来源是 `opensourceways/infrastructure` 仓的 `service.yaml`(不是值就先去那里查,别自己编)。 + +## 各语言:怎么加日志与指标 + +### Go(`go/`) + +```go +import ( + obslog "github.com/opensourceways/obs-sdk/go/log" + obsmetrics "github.com/opensourceways/obs-sdk/go/metrics" + obshttpmw "github.com/opensourceways/obs-sdk/go/middleware" + "github.com/opensourceways/obs-sdk/go/sdkctx" +) + +// 启动时 Init 一次,之后全进程用包级函数(kratos v3 形状,无需在每个调用点绑定 logger) +obslog.Init(obslog.Config{Service: "review", Env: "test", Instance: "pod-1", Community: "openEuler"}) + +// 日志:msg 常量 + kv 交替(禁止 printf) +obslog.Info("job done", "event", "release", "issue", "2061") +obslog.ErrorContext(ctx, "get account failed", "user_id", uid, "error", err) // ctx 里的请求字段自动附加 + +// 指标:注册一次;community label 自动排首位,取请求覆盖或回退默认 +m := obsmetrics.New(obsmetrics.Config{Service: "review", Env: "test", Instance: "pod-1", Community: "openEuler"}) +built := m.NewCounterVec("built_releases", "发布的构建数", "kind") +built.Inc("tag") +built.IncWithContext(ctx, "tag") // 请求内 → 该条 series 的 community 取 ctx 覆盖值 + +// 中间件:注入 request_id + 可信判定点解析 community + 记 obs_http_server_* +h := obshttpmw.New(obshttpmw.Options{ + Metrics: m, + ResolveCommunity: func(r *http.Request) string { /* "/mindspore" → "mindspore" */ return "" }, +}).Then(myHandler) +// gin:r.Use(ginmw.Middleware(ginmw.Options{Metrics: m})) + +// 请求内覆盖(trace_id/span_id 为二期预留) +ctx = sdkctx.WithCommunity(ctx, "mindspore") +ctx = sdkctx.WithRequestID(ctx, "req-123") +``` + +指标注册:`NewCounterVec` / `NewGaugeVec` / `NewHistogramVec` / `NewHistogramVecWithBuckets`,名称为**基础名**(不带 `_total` / `_seconds`);暴露用 `m.Handler()`。 + +### Python(`python/`) + +```python +from obs_sdk import log, metrics +from obs_sdk import _context + +log.init(service="review", env="test", instance="pod-1", community="openEuler") # 进程内幂等 +logger = log.get_logger(__name__) +logger.info("job done", extra={"event": "release", "issue": "2061"}) # 业务字段走 extra= +log.info("job done", extra={"issue": "2061"}) # 或便捷函数 + +metrics.init(service="review", env="test", instance="pod-1", community="openEuler") +built = metrics.counter("built_releases", "发布的构建数", ["kind"]) # community 自动补 +built.inc(1, kind="tag") + +# 请求上下文:中间件之外也可手工 bind(community 必须在可信判定点解析) +with _context.bind(community="mindspore", request_id="req-1"): + logger.info("scoped") # 自动带 community/request_id + built.inc(1, kind="tag") # 该 series community 被覆盖 + +# /metrics 暴露 +# return Response(content=metrics.generate_text(), media_type=metrics.content_type()) +``` + +框架适配:`middleware.fastapi_wrap(app, resolver=...)`、`flask_middleware(app, resolver=...)`、`DjangoMiddleware`(MIDDLEWARE 列表加 `obs_sdk.middleware.DjangoMiddleware`)。 +多注册表场景直接 `metrics.Metrics(...)` 而非模块级单例。 + +### Node(`node/`) + +```js +const obs = require('obs-sdk-node'); // { log, metrics, context, middleware } + +obs.log.init({ service: 'review', env: 'test', instance: 'pod-1', community: 'openEuler' }); +obs.log.info('job done', { event: 'release', issue: '2061' }); // 业务字段走第二个对象参数 + +const m = new obs.metrics.Metrics({ service: 'review', env: 'test', instance: 'pod-1', community: 'openEuler' }); +const built = m.counter('built_releases_total', '发布的构建数', ['kind']); // prom-client 要最终名,SDK 不改名 +built.inc(1, { kind: 'tag' }); + +// 中间件:注入 request_id + 解析 community + 记 obs_http_server_* +const { makeMiddleware, metricsRouteHandler } = obs.middleware; +app.use(makeMiddleware({ metrics: m, resolveCommunity: (req) => undefined })); +app.get('/metrics', metricsRouteHandler(m)); + +// 请求内覆盖 +obs.context.bindRequest({ community: 'mindspore', requestId: 'req-1' }, () => { + obs.log.info('scoped'); +}); +``` + +### Java(`java/`) + +```java +ObsSdkConfig cfg = ObsSdkConfig.builder() + .service("review").env("test").instance("pod-1").community("openEuler") + .build(); // 生产用 ObsSdkConfig.fromEnvironment() 读 OBS_* + +ObsLogging.init(cfg); // 部署级字段写入 MDC +ObsMetrics m = ObsMetrics.of(cfg); + +ObsMetrics.CounterVec built = m.counter("built_releases", "发布的构建数", "kind"); +built.inc("tag"); // 基础名(不带 _total/_seconds),Micrometer 自动补后缀 + +// 请求处理:可信判定点解析后 push(try-with-resources) +try (RequestContext.Scope scope = RequestContext.push("mindspore", "req-1", null)) { + log.info("job done"); // SLF4J;MDC 里的请求字段由 JSON encoder 输出 + built.inc("tag"); // 该 series community 取覆盖值 +} +``` + +日志 JSON 输出**必须**用 [java/examples/logback-json.xml](java/examples/logback-json.xml):它只挂 SDK 的 +`ObsJsonProvider`,由 SDK 保证字段名/顺序/时间格式/级别小写/异常堆栈。**不要退回 encoder 自带 provider**—— +`` 只能输出大写、字段名不可配、且不配 `` 会整条丢弃 throwable。 +该 provider 需要 `logstash-logback-encoder` + `jackson-core`(SDK 内为 `provided`,接入服务运行时提供)。 + +Servlet 接入(可选):`new ObsFilter(req -> resolveCommunity(req))`,或 Spring Boot 注册 `FilterRegistrationBean`。 +Java 的**服务端指标**不重复埋点——走 Spring Boot Actuator + Micrometer 官方 server instrumentation,把 +`m.meterRegistry()` 暴露成 bean 由 Actuator 托管。 + +## 构建与测试 + +```bash +cd go && go vet ./... && go test -race ./... +cd python && pytest # 需 .venv 内已装 prometheus_client 等依赖 +cd node && npm install && npm test +cd java && mvn test # 需 JDK 17 + Maven +``` + +工具链版本对齐 CI([.github/workflows/ci.yml](.github/workflows/ci.yml)):**Go 的版本以 `go/go.mod` 的 `go` 指令为准** +(CI 用 `go-version-file` 读它,不要在文档里硬编码具体版本)、Python 3.10、Node 20、Java 17。 +本机没有系统级 JDK/Maven 时,可把 `JAVA_HOME` / `PATH` 指向自装工具链;Java 的最终验证以 CI 为准。 + +## 改动约定 + +- **改了契约就四语言同步**,并在各语言补 UT。断言要落在**真实输出**上,不要只断言 MDC / 中间变量—— + Java 的字段名/级别/时区问题(以及 throwable 被丢弃)正是因为只测了 MDC 才漏到合入后的。 +- Java 的 `ObsJsonProviderTest` 会**直接加载 `examples/logback-json.xml`**(测试工作目录是 `java/`), + 改动样例配置会反映到测试里。 +- 新增/改动 `community` 取值前先同步 [spec/community-values.md](spec/community-values.md),其来源与重新同步命令见该文件。 +- 发现代码里的 bug:**指出来,但不要顺手修**(超出当前任务范围的改动先问)。 diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..ad24891 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,6 @@ +# CLAUDE.md + +本仓库的 agent 指引统一维护在 [AGENTS.md](AGENTS.md)(跨工具通用,Claude Code / opencode / Codex 等均适用)。 +此处只做导入,避免同一份内容两处维护。 + +@AGENTS.md diff --git a/README.md b/README.md index 70efc3c..fb05f54 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,9 @@ - [java/](java/README.md) — obs-sdk-java(logback + logstash JSON encoder + micrometer/prometheus registry) - [node/](node/README.md) — obs-sdk-node(JSON serializer + prom-client + express 中间件) -各语言子目录自管版本(go.mod / pyproject.toml / pom.xml / package.json),Git tag 用 `go-v1.0.0` 等前缀区分。 +各语言子目录自管版本(go.mod / pyproject.toml / pom.xml / package.json),Git tag 按语言加前缀区分。 +Go 模块的 tag 必须是 `<子目录>/v<版本>` 形式(如 `go/v1.0.0`)—— Go 工具链按模块根所在子目录解析 tag, +写成 `go-v1.0.0` 这种连字符形式 `go get` 拉不到该模块;其余语言无此约束。 ## 通用能力(四种语言对齐)