本文档描述 Pineapple 0.5.0 起稳定支持的可插拔观测模型:内建 /stats 原子统计始终可用,外部监控系统通过各运行时对等的 Provider 抽象接入。pine-go 是规范参考实现,其它运行时按字节级 parity 对齐。
pine-go/pkg/metrics/metrics.gopine-go/pkg/metrics/nop.gopine-go/pine.gopine-go/internal/runtime/engine_metrics.gopine-go/internal/runtime/scheduler.gopine-go/internal/runtime/stats.gopine-go/internal/types/operator.gopine-go/operators/lua/lua.gopine-go/operators/lua/pool.gopine-go/pkg/server/server.gopine-go/pkg/server/http_metrics.gopine-go/pkg/metrics/collector.go— 资源级指标聚合Collector(/stats.resources数据源)pine-go/pkg/metrics/tee.go— fan-outTee(Provider...),把同一指标写入多个下游 Provider
pine-cpp/include/pine/metrics.hpp—Provider/Counter/Gauge/Histogram接口pine-cpp/src/runtime/metrics_nop.cpp—nop_provider()单例pine-cpp/include/pine/metrics_collector.hpp—metrics::Collector+metrics::TeeProviderpine-cpp/src/runtime/metrics_collector.cpp— Collector 聚合快照 + Tee fan-out 实现pine-cpp/src/server/server.hpp—MiddlewareContext/Middleware/ServerConfig::middlewarespine-cpp/src/server/http_metrics.cpp—http_metrics_middleware(provider)工厂pine-cpp/include/pine/pine.hpp—EngineOptions::metrics_provider、Engine::peak_concurrency()
- pine-java: 参考
Registry/metrics/Provider.java模块。指标名、Help 文案与 histogram 桶边界由scripts/check-metrics-help-parity.py守着(接make lint,纯文本扫描全部三方声明点,桶按 metric 名比对)。 注意scripts/cross-validate/13-metrics-parity.sh不覆盖这些:它读/stats,由runtime.Stats供给, 与metrics.Provider是分离的两套机制,断言的是「算子从启动即可见且计数为零」这类Stats行为(issue #193) metrics/MetricsCollector.java— 资源级指标聚合 Collectormetrics/TeeProvider.java— fan-out Provider
Pineapple 把生产可观测性拆成两条稳定通道:
-
进程内原子统计
- 零配置启用
- 为
/stats提供数据 - 不依赖外部监控后端
-
外部 Provider metrics
- 由调用方按需注入
- 面向 Prometheus 等采集系统
- 不让 Pineapple core 直接依赖具体监控 SDK
该拆分允许 Pineapple 默认保持轻依赖,同时让接入方在自己的项目中实现约 80 行级别的 Prometheus adapter。
pine-go/pkg/metrics/metrics.go 定义的稳定接口只有三类 metric 和一个 provider:
CounterGaugeHistogramProvider
共同约束:
- metric handle 支持
With(labelValues ...string)派生带标签实例 MetricOpts/HistogramOpts描述名称、帮助文案、标签名和 histogram bucketsDurationSeconds(time.Duration)把 Go duration 转成秒,供时长 histogram 使用
Pineapple 只依赖这些接口,不导入 prometheus/client_golang。
metrics.Nop() 返回默认 no-op provider:
- 丢弃全部观测
- 不分配真实后端对象
- 作为
pine.NewEngine和server.Config.Metrics的默认值
因此未启用外部 metrics 时,Pineapple 仍保留 /stats 诊断能力,但不会把 Prometheus 等依赖强加给所有使用者。
引擎构建支持 option 注入,当前稳定项为:
pine.WithMetrics(provider)pine.WithLogPrefix(prefix)
行为约束:
- 未提供 provider 时自动回退到
metrics.Nop() Engine在构建期预创建EngineMetrics- 引擎在构建期(
NewEngine/Engine.create()/Engine()初始化末尾)对所有已编译算子名调用PreInitOperators,确保 Prometheus 等 metrics backend 从启动即暴露零值时间序列;同时 Stats 也预注册算子名,使/stats在首次请求前即可返回完整算子列表(各计数器初始为 0) - 同一 provider 会被传给所有实现
MetricsAware的算子实例 - 日志前缀可来自 JSON 根级
log_prefix或pine.WithLogPrefix(...) - 当两者同时存在时,
pine.WithLogPrefix(...)优先 - 前缀作用于引擎实例私有的
*log.Logger(Ldate|Ltime|Lshortfileflags,输出含file:line),经LoggerAware注入到算子与 scheduler 诊断路径;进程全局log包不受影响(issue #172)
对单个算子实例,引擎在构建期按以下固定顺序调用可选接口:
MetadataAwareDebugAwareLoggerAwareMetricsAware
(Go 实际路径;Java 在 Metrics 之后还有 ResourceAware,C++ 的 Metadata/Debug 经 init(op_cfg) 携带、随后同样是 Logger → Metrics → Resource。完整对照见 llmdoc/architecture/dag-engine.md 不变量 11。)该顺序是承载性的。像 pine-go/operators/lua/lua.go 这样的实现依赖 DebugHolder.OperatorName() 已先被注入,以便把 operator 实例名作为 metric label 使用。
pine-go/internal/runtime/stats.go 始终维护:
- 每算子执行次数、跳过次数、错误次数
- 每算子总耗时、最大耗时、平均耗时
- 调度器
run_count - 调度器
peak_concurrency
这些统计通过以下 API 暴露:
Engine.Stats()Engine.SchedulerStats()Engine.OperatorCustomStats()(聚合实现StatsProvider的算子)
pine-go/internal/runtime/engine_metrics.go 在引擎创建时预声明并缓存 metric handle。当前稳定指标名为:
算子级指标:
pine_scheduler_runs_totalpine_operator_activepine_operator_exec_total{operator=...}pine_operator_exec_duration_seconds{operator=...}pine_operator_skip_total{operator=...}pine_operator_error_total{operator=...}
DAG 级指标(0.6.6 起):
pine_dag_executions_total{status=success|error}— DAG 执行总次数,按成功/失败分标签pine_dag_execution_duration_seconds— 单次 DAG 执行端到端耗时pine_dag_operators_executed— 每次 DAG 执行中实际运行(非跳过、非取消)的算子数(histogram,桶边界以pine-go/internal/runtime/engine_metrics.go为准,并由scripts/check-metrics-help-parity.py跨运行时守护)
DAG 级指标在 scheduler.Run() 结束时统一记录:计时覆盖从调度开始到所有算子完成的完整区间;status 标签由是否有 fatal error 决定;operators_executed 只计入 !Skipped 的 trace 条目。
scheduler.go 在热路径中同时更新两类观测:
- 调度开始时记录 scheduler run
- 算子开始/结束时维护 active gauge 与 peak concurrency
- 算子成功、跳过、失败时分别记录对应计数
- 成功和失败都记录执行时长 histogram
- DAG 执行结束时记录 DAG 级执行次数、耗时和实际执行算子数
这里的关键不是二选一,而是同一执行事实被写入两种消费面:
/stats面向人类诊断与零配置自检- provider metrics 面向 Prometheus/Grafana 等系统
这两条通道不是互为备份,各有对方没有的东西,写清是为了避免读者以为其中一条冗余:
通道一 runtime.Stats |
通道二 metrics Provider |
|
|---|---|---|
| 服务对象 | 内建 /stats |
外部后端 |
| 默认是否工作 | 是,开箱即用 | 否,默认 nop |
| 跨运行时校验 | 有(13-metrics-parity.sh) |
有(check-metrics-help-parity.py,覆盖指标名、Help 与桶边界) |
| 提供的信息 | 总耗时、最大耗时、平均耗时 | count + sum(出厂);分位数(下游注入后) |
两条硬边界,不写下来就会被误解:
- 出厂部署下通道二不留任何观测。 三方捆绑服务器都是「注入的 provider,否则 nop」
(
pine-go/pkg/server/server.go、pine-java/.../PineServer.java、pine-cpp/src/server/server.cpp), 而Collector/MetricsCollector只交给 ResourceManager,engine metrics 被刻意排除在/stats.resources之外。所以不注入自定义 Provider 时,pine_operator_exec_duration_seconds连一次观测都没有。 - 出厂 collector 只能给 count + sum,拿不到分位数。 三方 cell 结构一致(Go
histCell、 JavaHistCell、C++HistCell都只有 count 与 sum_ns),只够算平均值,没有直方图结构。 要 P50/P99 必须自己实现 Provider 并接真正的直方图后端;HistogramOpts.buckets是给那个 实现的建议值,出厂实现不消费它。
实现 Provider 前必须读的契约(并发、单位、桶的性质、label 生命周期)在
pine-go/pkg/metrics/metrics.go 的 package doc,那是唯一权威副本,pine-java / pine-cpp 的接口
注释与 design_doc/08_observability.md 都只留指针、不复述。
pine-go/pkg/server/server.go 支持通过 server.Config.Metrics 注入 provider,也支持通过 server.Config.Middlewares 在 HTTP 边界层包装整个 handler 链。
该 provider 会同时用于:
- 初始
pine.NewEngine(configData, pine.WithMetrics(provider)) - 后续
reloadConfig(...)重建引擎时的pine.WithMetrics(provider) - server 自身的 config reload 指标
- 内置 HTTP 请求指标中间件
pine-go/pkg/server/http_metrics.go 提供内置的 httpMetricsMiddleware,作为最内层中间件自动应用于所有 HTTP 路由,测量 handler 处理耗时(不含用户 middleware 开销)。
指标名:
pine_http_requests_total{method, path, status}— HTTP 请求总数pine_http_request_duration_seconds{method, path}— HTTP 请求处理耗时
行为约定:
path标签只输出已知路径(内置/execute、/health、/stats、/dag,加上server.Config.Routes注册的自定义路由路径),未知路径归一化为_other,防止高基数标签爆炸。known-path 集合在启动时由validateRoutes一次性构建,运行期不增长status标签按 HTTP 状态码桶化为2xx、3xx、4xx、5xx、other- 该中间件在用户
Middlewares之内、http.ServeMux路由之外包装,因此用户 middleware 的处理时间不会被计入request_duration - 当
server.Config.Metrics为 nil 时,使用metrics.Nop()作为 provider,此时中间件仍执行但观测被丢弃,开销可忽略
C++ Server::run() 现在与 Go/Java 一致,无条件注入 http_metrics_middleware(NopProvider 兜底当 metrics_provider 为 nullptr),用户无需显式 push_back。middleware 同时写入外部 Provider 与内置 HttpStats 原子累加器(数据流入 /stats.http 子树)。MiddlewareContext::status 通过 send_response 内的 thread_local 指针写回。Provider* 由调用方持有,必须保证生命周期覆盖 server 运行期。
Middlewares 与 metrics 注入是正交能力:middleware 包装发生在内部路由注册完成之后、ListenAndServe 启动之前,对 /health、/execute、/stats、/dag 一并生效,但不改变 /stats 数据来源、reload 计数逻辑或引擎内的 metrics provider 传递。
当前 server 级外部指标名:
pine_config_reload_totalpine_config_reload_errors_totalpine_config_reload_duration_seconds
同时,server 还维护原子 reload 统计:
reloadCountreloadErrorCountlastReloadDurationNs
GET /stats 返回组合响应,字段稳定分为:
operators— 来自Engine.Stats()的每算子累计统计scheduler— 来自Engine.SchedulerStats()的调度器统计server— 配置热加载相关统计operator_detail— 仅当至少一个算子实现StatsProvider时出现http(0.6.7 起,三运行时一致):requests_total:map<"<METHOD> <path> <status_bucket>", int64>request_duration_seconds:map<"<METHOD> <path>", {count: int64, sum_ns: int64}>- key 字典序输出;duration bucket 字段顺序 count -> sum_ns
- 由内置 http_metrics middleware 实时填充,与外部 Provider Counter/Histogram 平行,构成"双通道观测模型"
resources(三运行时一致):资源级指标的 metric-centric 快照{指标名: {标签值组合: 值}}- counter/gauge 为标量,histogram 为
{count, sum_ns}(整数纳秒) - 每层键字典序排序,保证三运行时字节级一致
- 该键恒存在(Collector 随 ResourceManager 一起无条件创建);无资源指标时为
{} - 数据源是 fan-out 路由中的专用 Collector,详见下文"资源级指标 fan-out 路由"
- counter/gauge 为标量,histogram 为
这意味着:
/stats不要求 provider/stats与 Prometheus export 不是同一个接口层- 自定义算子若只想支持
/stats,实现StatsProvider即可 - 自定义算子若还想接入外部指标,再额外实现
MetricsAware
定义于 pine-go/internal/types/operator.go 和 pine-cpp/include/pine/operator.hpp,用于把算子内部累计统计暴露给 /stats:
- 方法:
OperatorStats() map[string]int64(C++ 返回std::map<std::string, int64_t>) - 数据应是稳定、便于诊断的累计数值
- 推荐用于 pool、cache、worker、重试等内部子系统状态
定义于 pine-go/internal/types/operator.go 和 pine-cpp/include/pine/operator.hpp,用于接收外部 provider:
- 方法:
SetMetricsProvider(p metrics.Provider)(C++ 为set_metrics_provider(metrics::Provider*)) - 适合在算子内部预创建或缓存自己的 metric handle
- 实现必须接受 no-op provider 为正常情况
Lua runtime 现在同时实现两种扩展接口:
StatsProviderMetricsAware
pine-go/operators/lua/pool.go (与 pine-cpp/src/lua/lua_pool.cpp)通过原子计数维护:
borrow_count— 累计借用次数return_count— 累计归还次数create_count— 实际新建 state 的次数(pool miss)reuse_count— 命中池内闲置 state 的次数(pool hit,0.9.7 起新增三运行时一致)active_count— 当前借出未归还的 state 数
reuse_count + create_count == borrow_count 恒成立(borrow 要么命中已有 state 计入 reuse,要么走 newState 计入 create)。运维可由此直接计算命中率 reuse_count / borrow_count,无需额外采样。
pine-go/operators/lua/lua.go 的 OperatorStats()(C++ 为 TransformByLuaOp::operator_stats())将这些计数暴露到 /stats.operator_detail[operatorName]。
Lua pool 还会创建以下 provider metrics,并绑定 operator label:
pine_lua_pool_borrow_totalpine_lua_pool_return_totalpine_lua_pool_create_totalpine_lua_pool_active
label 值取自 DebugHolder.OperatorName(),所以 Lua 算子的 metrics 注入依赖固定顺序:MetadataAware → DebugAware →(LoggerAware →)MetricsAware,Debug 信息先于 Metrics 就位。
内置 redis_connection 资源在 metrics_name 非空时发出资源级指标(标签名 name 取该参数值):
连接池/探针指标(资源生命周期):
| 指标 | 类型 | 说明 |
|---|---|---|
pine_redis_pool_total_conns |
Gauge(name) | 连接池总连接数(空闲 + 使用中) |
pine_redis_pool_idle_conns |
Gauge(name) | 连接池空闲连接数 |
pine_redis_ping_duration_seconds |
Histogram(name) | 后台 PING 探针往返耗时 |
pine_redis_up |
Gauge(name) | 最近一次 PING 成功为 1,失败为 0 |
Per-command 指标(每次业务命令执行,0.10.10 起):
| 指标 | 类型 | 说明 |
|---|---|---|
pine_redis_command_duration_seconds |
Histogram(name, command, status) | 单次命令执行耗时(秒) |
pine_redis_command_total |
Counter(name, command, status) | 单次命令执行计数 |
metrics_name 为空(默认)时不发出任何指标,也不启动探针线程。探针在资源 Start() 时立即跑一次(probe-once-then-tick),随后固定 15s 一跳,三运行时一致——因此首个请求前指标即已就绪。
status 标签四态枚举,按"业务可用性 + 失败原因可区分性"切分:
| status | 含义 |
|---|---|
ok |
命令成功(含 redis.Nil cache miss——业务路径上是合法成功) |
timeout |
命令在 read_timeout_ms / write_timeout_ms 超时(pine-go 走 context.DeadlineExceeded / go-redis timeout error) |
pool_timeout |
pool 获取连接 pool_timeout_ms 内无空闲(pine-go 走 go-redis pool wait error) |
error |
其它错误(连接被拒、协议错误、未分类异常) |
pine-cpp 已知缺口:当前 cpp Redis client 抛出单一 std::runtime_error 类型,没有 timeout / pool-timeout / generic 的类型分层;run_command<T>(...) 模板内只能落 error 桶,timeout 与 pool-timeout 计数被合并到 error。这是 known follow-up——cpp client 需要引入错误类型分层(拆 TimeoutError / PoolTimeoutError / generic)才能修齐 status taxonomy。
per-command 指标只覆盖业务命令(GET / SET / DEL / EXPIRE / EXISTS / HGET / ...),不覆盖 lifecycle / probe 命令(HELLO / CLIENT / PING / AUTH / SELECT)。三运行时的实现路径不同但终态对齐:
- pine-go:通过
redis.Hook在客户端层自动包裹所有命令——hook 内对HELLO / CLIENT / PING / AUTH / SELECT做 name-based 过滤后才计数。这个名单是健康/握手命令,与业务命令分摊到同一指标会污染 status 分布 - pine-java:通过
RedisConnectionResource.runCommand(name, action)facade 包裹业务命令;client 内部的 lifecycle 命令不经过 facade,自然不计入指标 - pine-cpp:通过
Client::run_command<T>(name, fn)模板包裹业务命令;client 内部 AUTH / SELECT / PING 同样不经过模板
cross-validate scripts/cross-validate/16-resource-metrics.sh 强断三运行时业务命令名集合一致;/stats.resources 子树中 pine_redis_command_* cells 因 facade 插桩位置(client 内 vs client 外)不同需在比较前剥离 cmd_* 标签——形状对齐由 section 16 维护,命名集合是契约的一部分。
资源级指标走 fan-out(Tee) 路由,与引擎指标解耦。bundled server 给 ResourceManager 注入的不是裸 Provider,而是 Tee(注入的Provider, 专用Collector):
- 每条资源指标同时写入调用方注入的 Provider(如 Prometheus 适配器)和专用 Collector;
- 引擎指标仍直接走注入的 Provider,不进入 Collector,因此
/stats.resources子树天然只含资源级指标(当前 redis 连接池/探针 + per-command 两组),不掺入引擎/服务级指标——scope 隔离靠"谁通过 Tee 写入"实现,而非在 Collector 内按名字过滤; /stats.resources读取 Collector 的聚合快照,无需外部 Prometheus 后端即可观测;- 下游无需任何改动即可从
/stats.resources读到新指标;已接 Prometheus 的下游照常经注入 Provider 导出,两条路径并存。
Collector 与 Tee 随 ResourceManager 在热重载时一同原子替换,且生命周期长于它(资源持有指向 Tee 的裸指针)。
- 聚合快照形状与
/stats.http同构:{指标名: {标签值组合: 值}},counter/gauge 为标量、histogram 为{count, sum_ns}(整数纳秒)。 - histogram 纳秒取整:Go
int64(math.Round(value*1e9))、JavaMath.round(value*1e9)、C++static_cast<int64_t>(std::llround(value*1e9))。 - 每层键字典序排序,保证三运行时字节级一致。
- 即使指标被声明但从未 observe,也以空对象出现(
{"pine_redis_up": {}}),便于跨运行时形状断言。
三运行时实现(Go pkg/metrics Collector+Tee、Java MetricsCollector+TeeProvider、C++ metrics::Collector+metrics::TeeProvider)行为字节级对齐,由 cross-validate resource-metrics section(scripts/cross-validate/16-resource-metrics.sh)锁定。
Pineapple 仓库内不提供 Prometheus 具体实现。推荐模式是:
- 在业务项目里实现一个
pkg/metrics.Provider适配器 - 在适配器内部使用
prometheus/client_golang注册CounterVec、GaugeVec、HistogramVec - 应用启动时把适配器传给
pine.WithMetrics(...)或server.Config.Metrics
这样可保持 Pineapple core 的后端无关性,也避免把注册表、命名冲突和 exporter 路由绑定到核心库中。
cross-validate metrics-parity section(scripts/cross-validate/13-metrics-parity.sh)验证各运行时的 pre-init 行为和 /stats 数值一致性,覆盖:
- zero-traffic pre-init:引擎启动后、无请求时
/stats已暴露全部算子 - operator names match:各运行时的算子名集合一致
- exec_count match:执行计数一致
- skip_count match:跳过计数一致
- error_count match:错误计数一致
- scheduler.run_count match:调度器运行计数一致
- http.requests_total 中
POST /execute 2xx计数三方一致 - http.request_duration_seconds 中
POST /execute的 count 字段三方一致(sum_ns 因 host load 差异不强制) /stats.http子树 schema shape:三方都有requests_total+request_duration_seconds两 key,每个 duration bucket 含count+sum_ns字段
C++ 端是否参与某次比对取决于该 section 中对 CPP_SERVER 的引用以及 scripts/cross-validate/_prebuild.sh 是否成功构建 cpp 二进制。
cross-validate resource-metrics section(scripts/cross-validate/16-resource-metrics.sh)锁定 /stats.resources 子树的形状与正确性,需要真实 redis-server(缺失时整段干净跳过):
- 正例(
metrics_name="cache"):- Go 正确性——连接池/探针指标齐全、
pine_redis_up.cache == 1、pingcount >= 1、histogram cell 含sum_ns、per-command 业务命令名集合(GET / SET / DEL / EXPIRE / EXISTS / HGET / ...,参见脚本内业务命令名清单) - Go vs Java:指标名 + 标签 key 集合一致、
pine_redis_up数值一致、ping count 均 ≥1;pine_redis_command_*命令名集合一致(cmd_*cells 因 facade 插桩位置不同在比较前剥离) - Go vs C++:同上形状 + 正确性 parity
- Go 正确性——连接池/探针指标齐全、
- 负例(无
metrics_name):三运行时resources == {}(恒存在键的干净负断言)
本文档描述的可插拔 metrics / observability 模型自 Pineapple 0.5.0 起稳定存在。0.6.6 新增内置 HTTP 请求指标中间件和 DAG 级执行指标。涉及的公开入口包括:
pine.WithMetricspine.WithLogPrefixserver.Config.Metrics/stats的scheduler与operator_detail组合语义
- 接口定义:
pine-go/pkg/metrics/metrics.go、pine-cpp/include/pine/metrics.hpp - no-op 默认实现:
pine-go/pkg/metrics/nop.go、pine-cpp/src/runtime/metrics_nop.cpp - 引擎入口与 option:
pine-go/pine.go、pine-cpp/include/pine/pine.hpp(EngineOptions::metrics_provider/log_prefix) - 引擎级 metric 预创建:
pine-go/internal/runtime/engine_metrics.go - 调度热路径记录:
pine-go/internal/runtime/scheduler.go - 原子统计:
pine-go/internal/runtime/stats.go;C++ peak concurrency 与 operator_custom_stats:pine-cpp/src/runtime/engine.cpp的 atomic CAS 路径 +Engine::peak_concurrency()/Engine::operator_custom_stats() - 算子扩展接口:
pine-go/internal/types/operator.go、pine-cpp/include/pine/operator.hpp - Lua 参考实现:
pine-go/operators/lua/lua.go、pine-go/operators/lua/pool.go、pine-cpp/src/lua/lua_pool.hpp - HTTP
/stats与 reload 指标:pine-go/pkg/server/server.go - 内置 HTTP 请求指标中间件:
pine-go/pkg/server/http_metrics.go、pine-cpp/src/server/http_metrics.cpp - C++ middleware 注入:
pine-cpp/src/server/server.hpp(Middleware/MiddlewareContext/ServerConfig::middlewares)