feat(api-server): default otlp and async tracking outbox

This commit is contained in:
kdletters
2026-05-19 07:33:44 +08:00
parent fa43410c8c
commit f6292c3ad5
9 changed files with 78 additions and 19 deletions

View File

@@ -16,6 +16,22 @@
--- ---
## 2026-05-19 tracking outbox 改为 rotate 后异步 flush
- 背景:普通 route tracking 写入压力上来后,不能让 HTTP 请求线程等待 SpacetimeDB 批量入库。
- 决策:`api-server` tracking outbox 达到 `BATCH_SIZE` 时立即封存当前 active 文件并切新 activesealed 文件交给后台 worker 异步 flush`FLUSH_INTERVAL_MS` 只做长时间未满批的兜底封存;`MAX_BYTES` 只做磁盘保护阈值;成功后删除 sealed失败保留重试坏文件隔离为 `corrupt-*`
- 影响范围:`api-server` tracking outbox、埋点文档、压测口径和后续排障记忆。
- 验证方式HTTP route 请求在 SpacetimeDB 短暂不可用时仍可返回;恢复后 sealed 文件会被批量写入并清理。
- 关联文档:`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md``docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`
## 2026-05-19 OTLP 默认开启但日志本地输出保留
- 背景:生产和容器环境需要默认把 OTLP 接到本机 Collector但压测或排障时也要能显式关闭。
- 决策:生产与容器 `api-server` env 模板默认 `GENARRATIVE_OTEL_ENABLED=true`;生产 endpoint 用 `http://127.0.0.1:4318`,容器 endpoint 用 `http://otelcol:4318``OTEL_EXPORTER_OTLP_ENDPOINT` 只填 Collector HTTP base endpoint不填 gRPC `4317` 或 Rider 端口本地日志、Nginx 日志和 `GENARRATIVE_API_LOG` / `RUST_LOG` 仍保留。
- 影响范围:`deploy/env/api-server.env.example``deploy/container/api-server.env.example``docs/【开发运维】本地开发验证与生产运维-2026-05-15.md``scripts/loadtest/README.md`
- 验证方式:检查 env 模板默认值与端点口径;压测若要关闭 OTLP必须显式设置 `GENARRATIVE_OTEL_ENABLED=false`
- 关联文档:`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md``scripts/run-otelcol.mjs`
## 2026-05-17 容器化方案只作为隔离压测与预发模拟路径 ## 2026-05-17 容器化方案只作为隔离压测与预发模拟路径
- 背景Windows 本机直连极高 VU 压测会放大本地连接与发送缓冲行为,和线上 Linux + Nginx + systemd 拓扑不一致;需要一个更接近生产网络层的模拟方案,但不能扰动当前生产发布链路。 - 背景Windows 本机直连极高 VU 压测会放大本地连接与发送缓冲行为,和线上 Linux + Nginx + systemd 拓扑不一致;需要一个更接近生产网络层的模拟方案,但不能扰动当前生产发布链路。

View File

@@ -22,6 +22,22 @@
- 验证:拼图入口测试仍可通过,且新组件可通过不同页面复用而不需要复制上传卡实现。 - 验证:拼图入口测试仍可通过,且新组件可通过不同页面复用而不需要复制上传卡实现。
- 关联:`src/components/common/CreativeImageInputPanel.tsx``src/components/puzzle-agent/PuzzleAgentWorkspace.tsx` - 关联:`src/components/common/CreativeImageInputPanel.tsx``src/components/puzzle-agent/PuzzleAgentWorkspace.tsx`
## OTLP 端点只填 Collector HTTP base endpoint
- 现象:生产或容器 env 里把 `OTEL_EXPORTER_OTLP_ENDPOINT` 填成 `4317`、Rider 端口或别的非 HTTP base endpoint 后api-server 发不出 OTLP或者链路被错误转发。
- 原因api-server 当前走 OTLP HTTP不是 gRPCCollector 才是接收和转发边界。
- 处理:生产模板用 `http://127.0.0.1:4318`,容器模板用 `http://otelcol:4318`;需要关闭时显式设 `GENARRATIVE_OTEL_ENABLED=false`,不要通过改 endpoint 绕开 Collector 语义。
- 验证:检查 env 模板和运行态配置都指向 Collector HTTP base endpoint日志仍通过 `journalctl` / 文件日志保留。
- 关联:`deploy/env/api-server.env.example``deploy/container/api-server.env.example``docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`
## tracking outbox 到批量阈值后先封存再异步 flush
- 现象route tracking 高峰时如果主请求线程要等 SpacetimeDB 批量入库,接口延迟会被 outbox 写入链路拖长。
- 原因outbox 的职责是把普通 HTTP route tracking 从请求线程切走,不能把 flush 结果回写成同步阻塞。
- 处理:达到 `BATCH_SIZE` 立即封存 active 文件并切新 active`FLUSH_INTERVAL_MS` 只做兜底封存,后台 worker 异步 flush sealed 文件;成功删文件,失败保留重试,坏文件隔离为 `corrupt-*``MAX_BYTES` 只做磁盘保护。
- 验证:普通 route 请求在 SpacetimeDB 不可用时仍能返回,恢复后 sealed 文件会继续被清理。
- 关联:`server-rs/crates/api-server/src/tracking_outbox.rs``docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`
## 汪汪声浪入口不要再回到独立配置阶段 ## 汪汪声浪入口不要再回到独立配置阶段
- 现象:汪汪声浪入口如果继续切换到独立配置阶段,会和拼图、抓大鹅的创作页内嵌结构不一致,用户会感觉入口跳页。 - 现象:汪汪声浪入口如果继续切换到独立配置阶段,会和拼图、抓大鹅的创作页内嵌结构不一致,用户会感觉入口跳页。

View File

@@ -18,7 +18,7 @@ GENARRATIVE_TRACKING_OUTBOX_BATCH_SIZE=500
GENARRATIVE_TRACKING_OUTBOX_FLUSH_INTERVAL_MS=1000 GENARRATIVE_TRACKING_OUTBOX_FLUSH_INTERVAL_MS=1000
GENARRATIVE_TRACKING_OUTBOX_MAX_BYTES=268435456 GENARRATIVE_TRACKING_OUTBOX_MAX_BYTES=268435456
GENARRATIVE_OTEL_ENABLED=false GENARRATIVE_OTEL_ENABLED=true
OTEL_SERVICE_NAME=genarrative-api OTEL_SERVICE_NAME=genarrative-api
OTEL_EXPORTER_OTLP_ENDPOINT=http://otelcol:4318 OTEL_EXPORTER_OTLP_ENDPOINT=http://otelcol:4318
OTEL_RESOURCE_ATTRIBUTES=deployment.environment=container,service.namespace=genarrative OTEL_RESOURCE_ATTRIBUTES=deployment.environment=container,service.namespace=genarrative

View File

@@ -16,7 +16,7 @@ GENARRATIVE_TRACKING_OUTBOX_DIR=/var/lib/genarrative/tracking-outbox
GENARRATIVE_TRACKING_OUTBOX_BATCH_SIZE=500 GENARRATIVE_TRACKING_OUTBOX_BATCH_SIZE=500
GENARRATIVE_TRACKING_OUTBOX_FLUSH_INTERVAL_MS=1000 GENARRATIVE_TRACKING_OUTBOX_FLUSH_INTERVAL_MS=1000
GENARRATIVE_TRACKING_OUTBOX_MAX_BYTES=268435456 GENARRATIVE_TRACKING_OUTBOX_MAX_BYTES=268435456
GENARRATIVE_OTEL_ENABLED=false GENARRATIVE_OTEL_ENABLED=true
OTEL_SERVICE_NAME=genarrative-api OTEL_SERVICE_NAME=genarrative-api
OTEL_EXPORTER_OTLP_ENDPOINT=http://127.0.0.1:4318 OTEL_EXPORTER_OTLP_ENDPOINT=http://127.0.0.1:4318
OTEL_RESOURCE_ATTRIBUTES=deployment.environment=production,service.namespace=genarrative OTEL_RESOURCE_ATTRIBUTES=deployment.environment=production,service.namespace=genarrative

View File

@@ -602,7 +602,7 @@ npm run check:server-rs-ddd
- Rust 结构体:`TrackingEvent` - Rust 结构体:`TrackingEvent`
- 源码:`server-rs/crates/spacetime-module/src/runtime/profile.rs` - 源码:`server-rs/crates/spacetime-module/src/runtime/profile.rs`
- 写入:关键业务埋点同步调用单条 procedure普通 HTTP route tracking 由 `api-server` 本机 outbox 批量调用 `record_tracking_events_and_return``event_id` 必须稳定且全局唯一,批量重试时用唯一索引做幂等跳过。 - 写入:关键业务埋点同步调用单条 procedure普通 HTTP route tracking 由 `api-server` 本机 outbox 批量调用 `record_tracking_events_and_return`outbox 到达批量阈值时先封存 active 文件并切新 active后台 worker 异步 flush sealed 文件HTTP 请求线程不等待 SpacetimeDB。`FLUSH_INTERVAL_MS` 只负责兜底封存长时间未满批的 active 文件,`MAX_BYTES` 只做磁盘保护阈值。`event_id` 必须稳定且全局唯一,批量重试时用唯一索引做幂等跳过。
### `treasure_record` ### `treasure_record`

View File

@@ -177,12 +177,12 @@ npm run container:down
容器方案默认暴露 `http://127.0.0.1:18080``api-server` 在容器内监听 `0.0.0.0:8082`Nginx 通过 `api-server:8082` upstream 反代 `/api/``/admin/api/`。SpacetimeDB 也纳入 compose容器内由 `spacetimedb:3101` 提供服务,宿主机通过 `http://127.0.0.1:13101` 进行模块发布Collector 镜像使用 `otel/opentelemetry-collector-contrib:0.151.0`。生产 provision 侧则通过 Jenkins 构建机准备的 `provision-tools/otelcol-contrib` 安装本机 `otelcol-contrib.service`真实库名、token 和外部服务密钥只写本地 `deploy/container/api-server.env`,不提交 Git。完整拓扑、端口、k6 参数和 OTLP debug exporter 使用方法见 `deploy/container/README.md` 容器方案默认暴露 `http://127.0.0.1:18080``api-server` 在容器内监听 `0.0.0.0:8082`Nginx 通过 `api-server:8082` upstream 反代 `/api/``/admin/api/`。SpacetimeDB 也纳入 compose容器内由 `spacetimedb:3101` 提供服务,宿主机通过 `http://127.0.0.1:13101` 进行模块发布Collector 镜像使用 `otel/opentelemetry-collector-contrib:0.151.0`。生产 provision 侧则通过 Jenkins 构建机准备的 `provision-tools/otelcol-contrib` 安装本机 `otelcol-contrib.service`真实库名、token 和外部服务密钥只写本地 `deploy/container/api-server.env`,不提交 Git。完整拓扑、端口、k6 参数和 OTLP debug exporter 使用方法见 `deploy/container/README.md`
`npm run container:config` 默认只做 quiet 校验,避免把本地 env 中的 token 展开到终端;确需排查完整 compose 时再传 `-- --print` `npm run container:config` 默认只做 quiet 校验,避免把本地 env 中的 token 展开到终端;确需排查完整 compose 时再传 `-- --print`
OpenTelemetry 现阶段可选 OTLP traces / metrics / logs但本地日志与 Nginx 文件日志仍保留: OpenTelemetry 现阶段默认开启 OTLP traces / metrics / logs但本地日志与 Nginx 文件日志仍保留:
- 默认 `GENARRATIVE_OTEL_ENABLED=false`,未开启时 api-server 不依赖 Collector - 生产与容器 `api-server` env 模板默认 `GENARRATIVE_OTEL_ENABLED=true`;压测、排障或短期要关闭 OTLP 时,必须显式设置 `GENARRATIVE_OTEL_ENABLED=false`
- Collector 使用官方 `otelcol-contrib`,只监听 `127.0.0.1:4317/4318`;本地用 `npm run otel:debug` 启动 debug exporter`npm run otel:rider` 转发到 Rider再接 Jaeger、Tempo、Prometheus、Grafana 或托管平台。 - Collector 使用官方 `otelcol-contrib`安装与启用仍由 `ENABLE_OTELCOL` / provision 控制,只监听 `127.0.0.1:4317/4318`;本地用 `npm run otel:debug` 启动 debug exporter`npm run otel:rider` 转发到 Rider再接 Jaeger、Tempo、Prometheus、Grafana 或托管平台。
- api-server 开启时使用 `OTEL_SERVICE_NAME=genarrative-api``OTEL_EXPORTER_OTLP_ENDPOINT=http://127.0.0.1:4318` - api-server 发送 OTLP HTTP 时,生产模板使用 `OTEL_SERVICE_NAME=genarrative-api``OTEL_EXPORTER_OTLP_ENDPOINT=http://127.0.0.1:4318`,容器模板使用 `OTEL_EXPORTER_OTLP_ENDPOINT=http://otelcol:4318`
- api-server 当前发 OTLP HTTP`OTEL_EXPORTER_OTLP_ENDPOINT` 指向 Collector HTTP base endpoint不要改到 gRPC `4317` Rider 端口Rider 由 Collector 通过 `RIDER_OTLP_GRPC_ENDPOINT` 转发。 - `OTEL_EXPORTER_OTLP_ENDPOINT` 必须指向 Collector HTTP base endpoint不要 gRPC `4317`,也不要直接填 Rider 端口Rider 由 Collector 通过 `RIDER_OTLP_GRPC_ENDPOINT` 转发。
- 应用日志仍通过 `journalctl -u genarrative-api.service` 查看Nginx 日志仍写文件;日志等级继续用 `GENARRATIVE_API_LOG` / `RUST_LOG` 控制,例如 `info,tower_http=info,spacetime_client=info` - 应用日志仍通过 `journalctl -u genarrative-api.service` 查看Nginx 日志仍写文件;日志等级继续用 `GENARRATIVE_API_LOG` / `RUST_LOG` 控制,例如 `info,tower_http=info,spacetime_client=info`
- debug exporter / Rider 转发都会同时接收 traces、metrics 和 logs。 - debug exporter / Rider 转发都会同时接收 traces、metrics 和 logs。
- api-server 会随 metrics 发送进程级指标:`process.memory.usage``process.memory.virtual``process.cpu.time``genarrative.process.cpu.usage_percent``process.thread.count``genarrative.process.memory.private`Windows 额外发送 `process.windows.handle.count`Linux 额外发送 `process.unix.file_descriptor.count`。这些指标只描述当前进程,不携带请求、用户或作品 label。 - api-server 会随 metrics 发送进程级指标:`process.memory.usage``process.memory.virtual``process.cpu.time``genarrative.process.cpu.usage_percent``process.thread.count``genarrative.process.memory.private`Windows 额外发送 `process.windows.handle.count`Linux 额外发送 `process.unix.file_descriptor.count`。这些指标只描述当前进程,不携带请求、用户或作品 label。
@@ -252,7 +252,7 @@ GENARRATIVE_TRACKING_OUTBOX_FLUSH_INTERVAL_MS=1000
GENARRATIVE_TRACKING_OUTBOX_MAX_BYTES=268435456 GENARRATIVE_TRACKING_OUTBOX_MAX_BYTES=268435456
``` ```
outbox 采用 NDJSON 文件保存原始事件。达到 `BATCH_SIZE` `FLUSH_INTERVAL_MS` 任一阈值后,当前 active 文件会被原子切换为 sealed 文件并进入批量 flushSpacetimeDB 批量 procedure 返回成功后删除 sealed 文件,失败则保留文件并重试。`MAX_BYTES` 是磁盘保护阈值,不是 flush 阈值;超过后低价值 route tracking 可以被丢弃并记录日志 / 指标关键同步事件不进入该丢弃路径。sealed 文件若出现无法解析的坏行,会重命名为 `corrupt-*` 隔离并记录 `genarrative.tracking_outbox.files.corrupt` 指标,避免一个坏文件阻塞后续批量入库。该机制提供至少一次投递语义,依赖 `tracking_event.event_id` 幂等跳过重复事件。 outbox 采用 NDJSON 文件保存原始事件。达到 `BATCH_SIZE` 时会立刻把当前 active 文件原子封存为 sealed 文件,并马上切到新的 active 继续写入;后台 worker 异步 flush sealed 文件HTTP 请求线程不等待 SpacetimeDB。`FLUSH_INTERVAL_MS` 只负责兜底封存长时间未满批的 active 文件。SpacetimeDB 批量 procedure 返回成功后删除 sealed 文件,失败则保留文件并重试。`MAX_BYTES` 是磁盘保护阈值,不是 flush 阈值;超过后低价值 route tracking 可以被丢弃并记录日志 / 指标关键同步事件不进入该丢弃路径。sealed 文件若出现无法解析的坏行,会重命名为 `corrupt-*` 隔离并记录 `genarrative.tracking_outbox.files.corrupt` 指标,避免一个坏文件阻塞后续批量入库。该机制提供至少一次投递语义,依赖 `tracking_event.event_id` 幂等跳过重复事件。
常用检查思路: 常用检查思路:

View File

@@ -32,7 +32,7 @@ pipeline {
string(name: 'API_PORT', defaultValue: '8082', description: 'api-server 本机监听端口') string(name: 'API_PORT', defaultValue: '8082', description: 'api-server 本机监听端口')
choice(name: 'NGINX_CONFIG_MODE', choices: ['none', 'production-https', 'development-http'], description: 'Nginx 配置模式;开发服无域名时选 development-httprelease 正式入口选 production-https') choice(name: 'NGINX_CONFIG_MODE', choices: ['none', 'production-https', 'development-http'], description: 'Nginx 配置模式;开发服无域名时选 development-httprelease 正式入口选 production-https')
booleanParam(name: 'ENABLE_SERVICES', defaultValue: true, description: '启用并启动 spacetimedb 与 api-server systemd 服务') booleanParam(name: 'ENABLE_SERVICES', defaultValue: true, description: '启用并启动 spacetimedb 与 api-server systemd 服务')
booleanParam(name: 'ENABLE_OTELCOL', defaultValue: true, description: '安装并启用本机 OpenTelemetry Collectorapi-server 是否发送 OTLP 仍由环境变量控制') booleanParam(name: 'ENABLE_OTELCOL', defaultValue: true, description: '安装并启用本机 OpenTelemetry Collectorapi-server 模板默认开启 OTLP如需关闭请在 API_ENV_FILE 中将 GENARRATIVE_OTEL_ENABLED 改为 false')
string(name: 'OTELCOL_VERSION', defaultValue: '0.151.0', description: 'otelcol-contrib 版本') string(name: 'OTELCOL_VERSION', defaultValue: '0.151.0', description: 'otelcol-contrib 版本')
} }

View File

@@ -247,7 +247,7 @@ sudo journalctl -u genarrative-api.service -f
sudo journalctl -u spacetimedb.service -f sudo journalctl -u spacetimedb.service -f
``` ```
api-server 的 OpenTelemetry 默认关闭。需要验证 OTLP traces / metrics / logs 时,先在服务器本机启动只监听 `127.0.0.1``otelcol-contrib` debug exporter api-server 的 OpenTelemetry 在生产与容器模板里默认开启。需要临时关闭时,显式把 `GENARRATIVE_OTEL_ENABLED=false`需要验证 OTLP traces / metrics / logs 时,先在服务器本机启动只监听 `127.0.0.1``otelcol-contrib` debug exporter
```bash ```bash
npm run otel:debug npm run otel:debug

View File

@@ -11,7 +11,7 @@ use spacetime_client::{SpacetimeClient, SpacetimeClientError};
use tokio::{ use tokio::{
fs::{self, File, OpenOptions}, fs::{self, File, OpenOptions},
io::{AsyncBufReadExt, AsyncWriteExt, BufReader}, io::{AsyncBufReadExt, AsyncWriteExt, BufReader},
sync::Mutex, sync::{Mutex, Notify},
time::sleep, time::sleep,
}; };
use tracing::{debug, warn}; use tracing::{debug, warn};
@@ -31,6 +31,7 @@ pub struct TrackingOutbox {
max_bytes: u64, max_bytes: u64,
spacetime_client: SpacetimeClient, spacetime_client: SpacetimeClient,
inner: Arc<Mutex<TrackingOutboxInner>>, inner: Arc<Mutex<TrackingOutboxInner>>,
flush_notify: Arc<Notify>,
} }
struct TrackingOutboxInner { struct TrackingOutboxInner {
@@ -81,6 +82,7 @@ impl TrackingOutbox {
total_bytes, total_bytes,
last_sealed_at: Instant::now(), last_sealed_at: Instant::now(),
})), })),
flush_notify: Arc::new(Notify::new()),
}; };
crate::telemetry::update_tracking_outbox_pending_bytes(total_bytes); crate::telemetry::update_tracking_outbox_pending_bytes(total_bytes);
Some(Arc::new(outbox)) Some(Arc::new(outbox))
@@ -129,6 +131,7 @@ impl TrackingOutbox {
if inner.active_count >= self.batch_size { if inner.active_count >= self.batch_size {
self.seal_active_locked(&mut inner, "batch_size").await?; self.seal_active_locked(&mut inner, "batch_size").await?;
self.flush_notify.notify_one();
} }
Ok(TrackingOutboxEnqueueOutcome::Enqueued) Ok(TrackingOutboxEnqueueOutcome::Enqueued)
@@ -137,12 +140,20 @@ impl TrackingOutbox {
pub fn spawn_worker(self: Arc<Self>) { pub fn spawn_worker(self: Arc<Self>) {
tokio::spawn(async move { tokio::spawn(async move {
loop { loop {
sleep(self.flush_interval).await; tokio::select! {
if let Err(error) = self.seal_active_if_due().await { _ = sleep(self.flush_interval) => {
warn!(error = %error, "tracking outbox 定时封存 active 文件失败"); if let Err(error) = self.seal_active_if_due().await {
} warn!(error = %error, "tracking outbox 定时封存 active 文件失败");
if let Err(error) = self.flush_sealed_files_once().await { }
warn!(error = %error, "tracking outbox 批量写入 SpacetimeDB 失败,将保留 sealed 文件等待重试"); if let Err(error) = self.flush_sealed_files_once().await {
warn!(error = %error, "tracking outbox 批量写入 SpacetimeDB 失败,将保留 sealed 文件等待重试");
}
}
_ = self.flush_notify.notified() => {
if let Err(error) = self.flush_sealed_files_once().await {
warn!(error = %error, "tracking outbox 批量写入 SpacetimeDB 失败,将保留 sealed 文件等待重试");
}
}
} }
} }
}); });
@@ -502,7 +513,7 @@ mod tests {
} }
#[tokio::test] #[tokio::test]
async fn enqueue_seals_active_file_when_batch_size_reached() { async fn enqueue_seals_active_file_when_batch_size_reached_and_rotates_active() {
let dir = test_dir("batch"); let dir = test_dir("batch");
let outbox = test_outbox(dir.clone(), 2, 1024 * 1024); let outbox = test_outbox(dir.clone(), 2, 1024 * 1024);
@@ -522,6 +533,22 @@ mod tests {
.count(); .count();
assert_eq!(sealed_count, 1); assert_eq!(sealed_count, 1);
outbox.enqueue(sample_event("event-3")).await.unwrap();
let active_contents = std::fs::read_to_string(dir.join(ACTIVE_FILE_NAME)).unwrap();
assert!(active_contents.contains("event-3"));
let sealed_count_after_rotate = std::fs::read_dir(&dir)
.unwrap()
.filter_map(Result::ok)
.filter(|entry| {
entry
.file_name()
.to_str()
.is_some_and(|name| name.starts_with(SEALED_FILE_PREFIX))
})
.count();
assert_eq!(sealed_count_after_rotate, 1);
let _ = std::fs::remove_dir_all(dir); let _ = std::fs::remove_dir_all(dir);
} }