fix: stabilize puzzle vector engine asset generation
This commit is contained in:
@@ -25,9 +25,10 @@
|
|||||||
|
|
||||||
## VectorEngine 图片生成 SendRequest 超时要按传输失败排查
|
## VectorEngine 图片生成 SendRequest 超时要按传输失败排查
|
||||||
|
|
||||||
- 现象:`external_api_call_failure` 里看到 `failureStage=request_send`、`timeout=true`、`statusCode=null`、`errorSource=client error (SendRequest)`,前端只知道图片生成失败。
|
- 现象:`external_api_call_failure` 里看到 `failureStage=request_send`、`timeout=true`、`statusCode=null`,`errorSource` 可能是 `client error (SendRequest)` 或更完整的 reqwest 底层错误链,前端只知道图片生成失败。
|
||||||
- 原因:`timeout=true` 来自 `reqwest::Error::is_timeout()`,不是业务代码固定写死;`SendRequest` 是 Hyper 发送请求阶段的错误来源标签,只说明请求未拿到可归类的 HTTP 响应,不会包含上游 JSON 错误体。
|
- 原因:`timeout=true` 来自 `reqwest::Error::is_timeout()`,不是业务代码固定写死;`SendRequest` 是 Hyper 发送请求阶段的错误来源标签,只说明请求未拿到可归类的 HTTP 响应,不会包含上游 JSON 错误体。
|
||||||
- 处理:先按 `provider/failureStage/statusClass` 聚合,再用 `user_id` / `profile_id` 和 `metadata_json.userId/profileId` 定位触发者与草稿 / 作品;`request_send + timeout=true` 优先查请求体大小、参考图数量、出口网络、代理/Nginx、VectorEngine 当时可用性和同一 request_id 日志。若记录有 `502` 或 `429 moderation_blocked`,按上游网关或审核失败另行处理,不要归到传输超时。
|
- 处理:先按 `provider/failureStage/statusClass` 聚合,再用 `user_id` / `profile_id` 和 `metadata_json.userId/profileId/requestId` 定位触发者、草稿 / 作品和同一次 HTTP 请求;`request_send + timeout=true` 优先查 provider 日志的 `source_chain`、请求体大小、参考图数量、出口网络、代理/Nginx、VectorEngine 当时可用性和同一 request_id 日志。若记录有 `502` 或 `429 moderation_blocked`,按上游网关或审核失败另行处理,不要归到传输超时。
|
||||||
|
- 拼图关卡资产生成按 `level_scene -> ui_spritesheet -> level_background` 顺序执行,每个资产会输出 `slot`、`asset_kind`、`elapsed_ms`;排查拼图草稿失败时优先看同一 request_id 下最后一个失败 slot。
|
||||||
- 验证:`cargo check -p api-server --manifest-path server-rs/Cargo.toml`;查询 `tracking_event` 时失败记录应能看到触发者 `user_id` 和可用的 `profile_id`。
|
- 验证:`cargo check -p api-server --manifest-path server-rs/Cargo.toml`;查询 `tracking_event` 时失败记录应能看到触发者 `user_id` 和可用的 `profile_id`。
|
||||||
- 关联:`server-rs/crates/api-server/src/external_api_audit.rs`、`server-rs/crates/api-server/src/openai_image_generation.rs`、`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`。
|
- 关联:`server-rs/crates/api-server/src/external_api_audit.rs`、`server-rs/crates/api-server/src/openai_image_generation.rs`、`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`。
|
||||||
|
|
||||||
@@ -365,7 +366,7 @@
|
|||||||
|
|
||||||
- 现象:VectorEngine 图片生成 / 编辑接口对前端只表现为 `502` / `504` 或“上游服务请求失败”,但难以区分是请求发送失败、上游 429/5xx、响应解析失败、未返回图片,还是下载图片失败。
|
- 现象:VectorEngine 图片生成 / 编辑接口对前端只表现为 `502` / `504` 或“上游服务请求失败”,但难以区分是请求发送失败、上游 429/5xx、响应解析失败、未返回图片,还是下载图片失败。
|
||||||
- 原因:外部 API 失败如果只靠普通日志,不一定能和 OTLP 指标、trace 与 SpacetimeDB 历史查询稳定关联;重启后也容易丢失上下文。
|
- 原因:外部 API 失败如果只靠普通日志,不一定能和 OTLP 指标、trace 与 SpacetimeDB 历史查询稳定关联;重启后也容易丢失上下文。
|
||||||
- 处理:先查 OTLP 指标 `genarrative.external_api.failures{provider,failure_stage,status_class,retryable}`,再查 `tracking_event` 中 `event_key = 'external_api_call_failure'` 的 `metadata_json`。当前通用 VectorEngine `gpt-image-2-all` 适配器会记录 provider、endpoint、operation、failureStage、statusCode、statusClass、timeout、retryable、errorMessage、latencyMs、promptChars、referenceImageCount、imageModel 和 rawExcerpt。
|
- 处理:先查 OTLP 指标 `genarrative.external_api.failures{provider,failure_stage,status_class,retryable}`,再查 `tracking_event` 中 `event_key = 'external_api_call_failure'` 的 `metadata_json`。当前通用 VectorEngine `gpt-image-2-all` 适配器会记录 provider、endpoint、operation、failureStage、statusCode、statusClass、timeout、retryable、errorMessage、errorSource、latencyMs、promptChars、referenceImageCount、imageModel、rawExcerpt 和 requestId。
|
||||||
- 验证:`SELECT event_id, scope_id AS provider, metadata_json, occurred_at FROM tracking_event WHERE event_key = 'external_api_call_failure' ORDER BY occurred_at DESC LIMIT 50;`;如果查不到同时看 tracking outbox 目录权限和 sealed 文件是否堆积。
|
- 验证:`SELECT event_id, scope_id AS provider, metadata_json, occurred_at FROM tracking_event WHERE event_key = 'external_api_call_failure' ORDER BY occurred_at DESC LIMIT 50;`;如果查不到同时看 tracking outbox 目录权限和 sealed 文件是否堆积。
|
||||||
- 关联:`server-rs/crates/api-server/src/external_api_audit.rs`、`server-rs/crates/api-server/src/openai_image_generation.rs`、`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`、`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`。
|
- 关联:`server-rs/crates/api-server/src/external_api_audit.rs`、`server-rs/crates/api-server/src/openai_image_generation.rs`、`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`、`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`。
|
||||||
|
|
||||||
@@ -778,8 +779,8 @@
|
|||||||
|
|
||||||
- 现象:RPG 结果页点击开局 CG 后,`POST /api/runtime/custom-world/opening-cg` 在较长等待后返回“开局 CG 故事板生成失败:创建图片生成任务失败:error sending request for url (https://api.vectorengine.ai/v1/images/generations)”。
|
- 现象:RPG 结果页点击开局 CG 后,`POST /api/runtime/custom-world/opening-cg` 在较长等待后返回“开局 CG 故事板生成失败:创建图片生成任务失败:error sending request for url (https://api.vectorengine.ai/v1/images/generations)”。
|
||||||
- 原因:该故事板会把角色图和首幕背景图作为参考图一起传给 VectorEngine `gpt-image-2-all`,请求体和上游生成耗时都比普通单图更大;若运行中的 `api-server` 仍沿用旧 `VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS`,或者参考图过大,会在请求发送/等待阶段被 reqwest 截断。日志里 `timeout=false connect=false request=true body=false source=client error (SendRequest)` 表示还没拿到上游 HTTP 响应,通常优先怀疑大 JSON 请求体、上游网关中断或 HTTP 协议兼容,而不是业务响应解析失败。直接请求 VectorEngine 若无效 token 可快速返回 401,不能据此判断真实生图不会超时。
|
- 原因:该故事板会把角色图和首幕背景图作为参考图一起传给 VectorEngine `gpt-image-2-all`,请求体和上游生成耗时都比普通单图更大;若运行中的 `api-server` 仍沿用旧 `VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS`,或者参考图过大,会在请求发送/等待阶段被 reqwest 截断。日志里 `timeout=false connect=false request=true body=false source=client error (SendRequest)` 表示还没拿到上游 HTTP 响应,通常优先怀疑大 JSON 请求体、上游网关中断或 HTTP 协议兼容,而不是业务响应解析失败。直接请求 VectorEngine 若无效 token 可快速返回 401,不能据此判断真实生图不会超时。
|
||||||
- 处理:开局 CG 参考图入参先压到单边 768 的 JPEG;`/v1/images/generations` 保持 reqwest 默认 HTTP 协商,只有 multipart `/v1/images/edits` 单独强制 HTTP/1.1。后端图片 helper 将 `request_body_bytes`、每张参考图 Data URL 长度、`timeout/connect/body/source/rootSource/sourceChain/endpoint` 分类写入日志和 `error.details`,前端优先展示 `details.reason`。修改 `.env.secrets.local` 后必须重启 `api-server`,`npm run dev` 终端用 `rs api-server`,否则旧进程仍按旧超时运行。
|
- 处理:开局 CG 参考图入参先压到单边 768 的 JPEG;`/v1/images/generations` 保持 reqwest 默认 HTTP 协商,只有 multipart `/v1/images/edits` 单独强制 HTTP/1.1。后端图片 helper 将 `timeout/connect/body/source/source_chain/source_chain_depth/endpoint` 分类写入日志和 `error.details`,失败审计通过 `metadata_json.errorSource/requestId` 保留底层错误链和请求标识。修改 `.env.secrets.local` 后必须重启 `api-server`,`npm run dev` 终端用 `rs api-server`,否则旧进程仍按旧超时运行。
|
||||||
- 验证:分别运行 `cargo test -p api-server custom_world_ai --manifest-path server-rs/Cargo.toml` 和 `cargo test -p api-server openai_image_generation --manifest-path server-rs/Cargo.toml`;真实联调重启后再触发开局 CG,若仍失败看返回的 `details.reason/source/rootSource/sourceChain/timeout/connect/body/endpoint` 和 `logs/api-server/` 同一 request_id。
|
- 验证:分别运行 `cargo test -p api-server custom_world_ai --manifest-path server-rs/Cargo.toml` 和 `cargo test -p api-server openai_image_generation --manifest-path server-rs/Cargo.toml`;真实联调重启后再触发开局 CG,若仍失败看返回的 `details.errorSource/source/timeout/connect/body/endpoint`、`tracking_event.metadata_json.errorSource/requestId` 和 `logs/api-server/` 同一 request_id。
|
||||||
- 关联:`server-rs/crates/api-server/src/custom_world_ai.rs`、`server-rs/crates/api-server/src/custom_world_ai/opening_cg.rs`、`server-rs/crates/api-server/src/openai_image_generation.rs`、`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`。
|
- 关联:`server-rs/crates/api-server/src/custom_world_ai.rs`、`server-rs/crates/api-server/src/custom_world_ai/opening_cg.rs`、`server-rs/crates/api-server/src/openai_image_generation.rs`、`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`。
|
||||||
|
|
||||||
## 开局 CG 成功后又变空白要保留 profile.openingCg
|
## 开局 CG 成功后又变空白要保留 profile.openingCg
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ spacetime sql <database> "SELECT * FROM puzzle_gallery_card_view LIMIT 1" --serv
|
|||||||
|
|
||||||
本地 `spacetime` CLI / standalone 版本必须和 `server-rs/Cargo.toml` 里锁定的 `spacetimedb` 版本一致;当前统一版本为 `2.3.0`。若版本错配,procedure 返回值可能在宿主侧触发 `Failed to BSATN deserialize procedure return value`,api-server 最终表现为敲木鱼等创作动作的 `SpacetimeDB procedure 调用超时`。排障时先运行 `spacetime --version`,再对照 `server-rs/Cargo.toml` 的 `spacetimedb = "..."`;需要切版本时执行 `spacetime version install <version> && spacetime version use <version>`,然后重新启动 `npm run dev:spacetime`。当前 `scripts/dev.mjs` 会在启动和复用本地 SpacetimeDB 前写入并校验 `dev-spacetime-tool-version`,避免把旧 standalone 继续带进新一轮创作。
|
本地 `spacetime` CLI / standalone 版本必须和 `server-rs/Cargo.toml` 里锁定的 `spacetimedb` 版本一致;当前统一版本为 `2.3.0`。若版本错配,procedure 返回值可能在宿主侧触发 `Failed to BSATN deserialize procedure return value`,api-server 最终表现为敲木鱼等创作动作的 `SpacetimeDB procedure 调用超时`。排障时先运行 `spacetime --version`,再对照 `server-rs/Cargo.toml` 的 `spacetimedb = "..."`;需要切版本时执行 `spacetime version install <version> && spacetime version use <version>`,然后重新启动 `npm run dev:spacetime`。当前 `scripts/dev.mjs` 会在启动和复用本地 SpacetimeDB 前写入并校验 `dev-spacetime-tool-version`,避免把旧 standalone 继续带进新一轮创作。
|
||||||
|
|
||||||
本地 `.env`、`.env.local` 或 `.env.secrets.local` 修改后必须重启 `api-server` 才会生效;若已经通过 `npm run dev` 启动完整联调,可在该终端输入 `rs api-server`。排查 RPG / 拼图 / 抓大鹅等 VectorEngine 生图链路时,确认 `VECTOR_ENGINE_BASE_URL`、`VECTOR_ENGINE_API_KEY` 和 `VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS` 只在本地或服务器密钥文件中配置,不能写入 Git。VectorEngine `gpt-image-2` 图片协议、URL / base64 响应解析、远端图片下载和 provider 侧结构化日志在 `server-rs/crates/platform-image`;`api-server` 只做配置、玩法编排、OSS / asset 持久化、计费和失败审计落库。开局 CG 故事板、首图、背景和图集都属于长耗时图片请求;后端默认会把 `VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS` 下限收口到 `1000000`,旧进程仍可能沿用重启前的短超时。若开局 CG 故事板在 `send()` 阶段失败且日志显示 `SendRequest`,先看同一 request_id 的 `request_body_bytes`、`reference_data_url_bytes`、`sourceChain` 和 `rootSource`;当前开局 CG 会把角色图与首幕背景图压到单边 768 的 JPEG 后再作为 generations `image` 数组发送,`/v1/images/generations` 使用默认 HTTP 协商,只有 multipart `/v1/images/edits` 单独强制 HTTP/1.1。
|
本地 `.env`、`.env.local` 或 `.env.secrets.local` 修改后必须重启 `api-server` 才会生效;若已经通过 `npm run dev` 启动完整联调,可在该终端输入 `rs api-server`。排查 RPG / 拼图 / 抓大鹅等 VectorEngine 生图链路时,确认 `VECTOR_ENGINE_BASE_URL`、`VECTOR_ENGINE_API_KEY` 和 `VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS` 只在本地或服务器密钥文件中配置,不能写入 Git。VectorEngine `gpt-image-2` 图片协议、URL / base64 响应解析、远端图片下载和 provider 侧结构化日志在 `server-rs/crates/platform-image`;`api-server` 只做配置、玩法编排、OSS / asset 持久化、计费和失败审计落库。开局 CG 故事板、首图、背景和图集都属于长耗时图片请求;后端默认会把 `VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS` 下限收口到 `1000000`,旧进程仍可能沿用重启前的短超时。若 VectorEngine 在 `send()` 阶段失败且日志显示 `SendRequest`,先看同一 `request_id` 的 provider 日志字段 `source`、`source_chain`、`source_chain_depth`,再查 `external_api_call_failure.metadata_json.errorSource`;当前 multipart `/v1/images/edits` 单独强制 HTTP/1.1。拼图关卡资产按 `level_scene -> ui_spritesheet -> level_background` 顺序生成,日志会带 `slot`、`asset_kind` 和 `elapsed_ms`。
|
||||||
|
|
||||||
查看本地 Rust / SpacetimeDB 日志:
|
查看本地 Rust / SpacetimeDB 日志:
|
||||||
|
|
||||||
@@ -292,7 +292,7 @@ OpenTelemetry 现阶段默认开启 OTLP traces / metrics / logs,但本地日
|
|||||||
- 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。
|
||||||
- HTTP 运行态补充发送 `genarrative.http.server.response_bodies.in_flight` 与 `genarrative.http.server.request_permits.available`,后者带低基数 `pool=default|gallery|detail|admin` label,用于区分业务 handler / 背压 permit 是否仍被占用;拼图广场热点缓存补充发送 `genarrative.puzzle_gallery.cache.*` 指标,记录 fresh hit、stale hit、未命中、后台刷新开始 / 失败、重建耗时和预序列化 data JSON 字节数。
|
- HTTP 运行态补充发送 `genarrative.http.server.response_bodies.in_flight` 与 `genarrative.http.server.request_permits.available`,后者带低基数 `pool=default|gallery|detail|admin` label,用于区分业务 handler / 背压 permit 是否仍被占用;拼图广场热点缓存补充发送 `genarrative.puzzle_gallery.cache.*` 指标,记录 fresh hit、stale hit、未命中、后台刷新开始 / 失败、重建耗时和预序列化 data JSON 字节数。
|
||||||
- 外部 API 失败统一发送 OTLP 并落库。当前 VectorEngine `gpt-image-2` 图片生成 / 编辑失败由 `platform-image` provider 输出低基数字段结构化日志,字段包括 provider、endpoint、failure_stage、status、status_class、timeout、retryable、latency_ms、prompt_chars、reference_image_count、image_model 和 raw_excerpt;`api-server` 再记录指标 `genarrative.external_api.failures{provider,failure_stage,status_class,retryable}`,并写入 `tracking_event`,`event_key = external_api_call_failure`、`module_key = external-api`、`scope_kind = module`、`scope_id = provider`。调用方能拿到身份上下文时,失败事件还会在行级 `user_id` / `owner_user_id` / `profile_id` 和 `metadata_json.userId` / `metadata_json.profileId` 中记录触发者与草稿 / 作品作用域。排障时先按 provider / failureStage 聚合,再下钻 userId / profileId,最后结合 request 日志和上游响应 excerpt 判断是限流、超时、解析失败还是未返回图片。
|
- 外部 API 失败统一发送 OTLP 并落库。当前 VectorEngine `gpt-image-2` 图片生成 / 编辑失败由 `platform-image` provider 输出低基数字段结构化日志,字段包括 provider、endpoint、failure_stage、status、source、source_chain、source_chain_depth、timeout、retryable、latency_ms、prompt_chars、reference_image_count、image_model 和 raw_excerpt;`api-server` 再记录指标 `genarrative.external_api.failures{provider,failure_stage,status_class,retryable}`,并写入 `tracking_event`,`event_key = external_api_call_failure`、`module_key = external-api`、`scope_kind = module`、`scope_id = provider`。调用方能拿到身份上下文时,失败事件还会在行级 `user_id` / `owner_user_id` / `profile_id` 和 `metadata_json.userId` / `metadata_json.profileId` / `metadata_json.requestId` / `metadata_json.errorSource` 中记录触发者、草稿 / 作品作用域、请求标识和传输错误链。排障时先按 provider / failureStage 聚合,再下钻 userId / profileId,最后结合 request 日志、errorSource 和上游响应 excerpt 判断是限流、超时、解析失败还是未返回图片。
|
||||||
- SpacetimeDB 观测分为两类:procedure / reducer 调用继续用 `genarrative.spacetime.procedure.*`,订阅本地 cache 读使用 `genarrative.spacetime.read.*`。`read=list_puzzle_gallery` 表示拼图广场当前从 `puzzle_gallery_card_view` 本地 cache 读取,不再每个 HTTP 请求调用 `list_puzzle_gallery` procedure。
|
- SpacetimeDB 观测分为两类:procedure / reducer 调用继续用 `genarrative.spacetime.procedure.*`,订阅本地 cache 读使用 `genarrative.spacetime.read.*`。`read=list_puzzle_gallery` 表示拼图广场当前从 `puzzle_gallery_card_view` 本地 cache 读取,不再每个 HTTP 请求调用 `list_puzzle_gallery` procedure。
|
||||||
- 本地 Windows 直连压测的内存高水位要结合 K6 VU / 连接数解释。250 RPS 下过高 `PREALLOCATED_VUS` 可能让 300 个本地 Established 连接把 `api-server` private memory 瞬时推到 GB 级,且 `/healthz` 小响应也能复现;若压测结束后回落、`response_bodies.in_flight` 和背压 permit 未显示业务积压,应优先按连接 / 发送链路高水位处理,而不是判断为 SpacetimeDB 或 JSON 缓存泄漏。
|
- 本地 Windows 直连压测的内存高水位要结合 K6 VU / 连接数解释。250 RPS 下过高 `PREALLOCATED_VUS` 可能让 300 个本地 Established 连接把 `api-server` private memory 瞬时推到 GB 级,且 `/healthz` 小响应也能复现;若压测结束后回落、`response_bodies.in_flight` 和背压 permit 未显示业务积压,应优先按连接 / 发送链路高水位处理,而不是判断为 SpacetimeDB 或 JSON 缓存泄漏。
|
||||||
- Rider 的 Logs 面板只展示 log event 自身字段,不会自动展开父 span 的全部 attributes;请求完成日志会直接带 `request_id`、`http.request.method`、`http.route`、`url.scheme`、`url.path`、`http.response.status_code`、`status_class`、`latency_ms` 和 `slow_request`,完整链路继续到 Traces 面板按 trace/span 查看。
|
- Rider 的 Logs 面板只展示 log event 自身字段,不会自动展开父 span 的全部 attributes;请求完成日志会直接带 `request_id`、`http.request.method`、`http.route`、`url.scheme`、`url.path`、`http.response.status_code`、`status_class`、`latency_ms` 和 `slow_request`,完整链路继续到 Traces 面板按 trace/span 查看。
|
||||||
@@ -351,7 +351,7 @@ cargo test -p platform-auth --manifest-path server-rs/Cargo.toml aliyun_send_sms
|
|||||||
|
|
||||||
个人任务首版 scope 仅支持 `user`。后台、RPG、大鱼吃小鱼、Visual Novel、Story、Combat 等特定链路按 tracking 中间件排除规则处理;作品游玩统一使用 `work_play_start`。
|
个人任务首版 scope 仅支持 `user`。后台、RPG、大鱼吃小鱼、Visual Novel、Story、Combat 等特定链路按 tracking 中间件排除规则处理;作品游玩统一使用 `work_play_start`。
|
||||||
|
|
||||||
外部 API 失败审计复用 `tracking_event`,不新增表。失败事件优先写入本机 tracking outbox,再由后台 worker 批量落库;如果 outbox 因权限、磁盘或保护阈值不可写,会回退同步直写 SpacetimeDB。`metadata_json` 包含 endpoint、operation、failureStage、statusCode、statusClass、timeout、retryable、errorMessage、latencyMs、promptChars、referenceImageCount、imageModel、rawExcerpt、userId 和 profileId;其中 `userId` 是触发生成的用户,`profileId` 是调用方传入的草稿 / 作品 / 场景作用域,入口拿不到上下文时允许为空。常用查询:
|
外部 API 失败审计复用 `tracking_event`,不新增表。失败事件优先写入本机 tracking outbox,再由后台 worker 批量落库;如果 outbox 因权限、磁盘或保护阈值不可写,会回退同步直写 SpacetimeDB。`metadata_json` 包含 endpoint、operation、failureStage、statusCode、statusClass、timeout、retryable、errorMessage、errorSource、latencyMs、promptChars、referenceImageCount、imageModel、rawExcerpt、userId、profileId 和 requestId;其中 `userId` 是触发生成的用户,`profileId` 是调用方传入的草稿 / 作品 / 场景作用域,`requestId` 用于回查同一次 HTTP 请求日志,入口拿不到上下文时允许为空。常用查询:
|
||||||
|
|
||||||
```sql
|
```sql
|
||||||
SELECT event_id, scope_id AS provider, metadata_json, occurred_at
|
SELECT event_id, scope_id AS provider, metadata_json, occurred_at
|
||||||
@@ -378,7 +378,7 @@ ORDER BY failures DESC, last_seen DESC
|
|||||||
LIMIT 100;
|
LIMIT 100;
|
||||||
```
|
```
|
||||||
|
|
||||||
VectorEngine `request_send` 且 `timeout = true` 的记录表示 `reqwest::Error::is_timeout()` 判定为超时,常见于连接、发送请求体、等待上游首包或上游长时间无响应;`errorSource = client error (SendRequest)` 是 Hyper 发送请求阶段的错误来源标签,不等于最终根因。若 `statusCode` 为空,应优先查同一时间窗口的 `api-server` request 日志、Nginx / 出口网络、VectorEngine 可用性和请求体大小;若已有 `502`、`429 moderation_blocked` 等状态码,则按上游网关或内容审核失败单独处理,不要和传输超时混为一类。
|
VectorEngine `request_send` 且 `timeout = true` 的记录表示 `reqwest::Error::is_timeout()` 判定为超时,常见于连接、发送请求体、等待上游首包或上游长时间无响应;`errorSource` 会保存 reqwest 底层错误链,若只看到 `client error (SendRequest)`,表示 Hyper 只暴露到发送请求阶段,仍不等于最终根因。若 `statusCode` 为空,应优先查同一 `requestId` 的 `api-server` request 日志、provider 日志 `source_chain`、Nginx / 出口网络、VectorEngine 可用性和请求体大小;若已有 `502`、`429 moderation_blocked` 等状态码,则按上游网关或内容审核失败单独处理,不要和传输超时混为一类。
|
||||||
|
|
||||||
tracking outbox 默认配置:
|
tracking outbox 默认配置:
|
||||||
|
|
||||||
|
|||||||
@@ -341,6 +341,8 @@ fn record_external_api_failure_otlp(failure: &ExternalApiFailureDraft) {
|
|||||||
prompt_chars = failure.prompt_chars,
|
prompt_chars = failure.prompt_chars,
|
||||||
reference_image_count = failure.reference_image_count,
|
reference_image_count = failure.reference_image_count,
|
||||||
image_model = failure.image_model,
|
image_model = failure.image_model,
|
||||||
|
request_id = %failure.request_id.as_deref().unwrap_or_default(),
|
||||||
|
error_source = %failure.error_source.as_deref().unwrap_or_default(),
|
||||||
error = %failure.error_message,
|
error = %failure.error_message,
|
||||||
"外部 API 调用失败"
|
"外部 API 调用失败"
|
||||||
);
|
);
|
||||||
@@ -394,6 +396,10 @@ mod tests {
|
|||||||
)
|
)
|
||||||
.with_status_code(Some(429))
|
.with_status_code(Some(429))
|
||||||
.with_retryable(true)
|
.with_retryable(true)
|
||||||
|
.with_error_source(Some(
|
||||||
|
"client error (SendRequest) -> connection closed before message completed"
|
||||||
|
.to_string(),
|
||||||
|
))
|
||||||
.with_latency_ms(Some(1234))
|
.with_latency_ms(Some(1234))
|
||||||
.with_prompt_chars(Some(88))
|
.with_prompt_chars(Some(88))
|
||||||
.with_reference_image_count(Some(2))
|
.with_reference_image_count(Some(2))
|
||||||
@@ -414,6 +420,10 @@ mod tests {
|
|||||||
assert_eq!(metadata["promptChars"], 88);
|
assert_eq!(metadata["promptChars"], 88);
|
||||||
assert_eq!(metadata["referenceImageCount"], 2);
|
assert_eq!(metadata["referenceImageCount"], 2);
|
||||||
assert_eq!(metadata["imageModel"], "gpt-image-2-all");
|
assert_eq!(metadata["imageModel"], "gpt-image-2-all");
|
||||||
|
assert_eq!(
|
||||||
|
metadata["errorSource"],
|
||||||
|
"client error (SendRequest) -> connection closed before message completed"
|
||||||
|
);
|
||||||
assert!(matches!(metadata["occurredAt"], Value::String(_)));
|
assert!(matches!(metadata["occurredAt"], Value::String(_)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -424,6 +424,7 @@ pub(crate) fn map_platform_image_error(error: PlatformImageError) -> AppError {
|
|||||||
details["referenceImageCount"] = json!(audit.reference_image_count);
|
details["referenceImageCount"] = json!(audit.reference_image_count);
|
||||||
details["imageModel"] = json!(audit.image_model);
|
details["imageModel"] = json!(audit.image_model);
|
||||||
details["rawExcerpt"] = json!(audit.raw_excerpt);
|
details["rawExcerpt"] = json!(audit.raw_excerpt);
|
||||||
|
details["errorSource"] = json!(audit.error_source);
|
||||||
}
|
}
|
||||||
|
|
||||||
AppError::from_status(status).with_details(details)
|
AppError::from_status(status).with_details(details)
|
||||||
|
|||||||
@@ -317,7 +317,16 @@ pub(crate) async fn generate_puzzle_level_asset_bundle(
|
|||||||
);
|
);
|
||||||
let http_client = build_puzzle_image_http_client(state, PuzzleImageModel::GptImage2)?;
|
let http_client = build_puzzle_image_http_client(state, PuzzleImageModel::GptImage2)?;
|
||||||
let puzzle_reference = build_puzzle_downloaded_image_reference(puzzle_image);
|
let puzzle_reference = build_puzzle_downloaded_image_reference(puzzle_image);
|
||||||
let scene_generated = create_puzzle_vector_engine_image_generation(
|
let bundle_started_at = Instant::now();
|
||||||
|
tracing::info!(
|
||||||
|
provider = VECTOR_ENGINE_PROVIDER,
|
||||||
|
image_model = PuzzleImageModel::GptImage2.request_model_name(),
|
||||||
|
session_id,
|
||||||
|
level_name,
|
||||||
|
"拼图关卡资产包生成开始"
|
||||||
|
);
|
||||||
|
let scene_started_at = Instant::now();
|
||||||
|
let scene_generated = match create_puzzle_vector_engine_image_generation(
|
||||||
&http_client,
|
&http_client,
|
||||||
&settings,
|
&settings,
|
||||||
PuzzleImageModel::GptImage2,
|
PuzzleImageModel::GptImage2,
|
||||||
@@ -328,7 +337,34 @@ pub(crate) async fn generate_puzzle_level_asset_bundle(
|
|||||||
Some(&puzzle_reference),
|
Some(&puzzle_reference),
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.map_err(map_puzzle_generation_endpoint_error)?;
|
.map_err(map_puzzle_generation_endpoint_error)
|
||||||
|
{
|
||||||
|
Ok(generated) => {
|
||||||
|
tracing::info!(
|
||||||
|
provider = VECTOR_ENGINE_PROVIDER,
|
||||||
|
image_model = PuzzleImageModel::GptImage2.request_model_name(),
|
||||||
|
session_id,
|
||||||
|
level_name,
|
||||||
|
slot = "level_scene",
|
||||||
|
elapsed_ms = scene_started_at.elapsed().as_millis() as u64,
|
||||||
|
"拼图关卡场景图生成完成"
|
||||||
|
);
|
||||||
|
generated
|
||||||
|
}
|
||||||
|
Err(error) => {
|
||||||
|
tracing::warn!(
|
||||||
|
provider = VECTOR_ENGINE_PROVIDER,
|
||||||
|
image_model = PuzzleImageModel::GptImage2.request_model_name(),
|
||||||
|
session_id,
|
||||||
|
level_name,
|
||||||
|
slot = "level_scene",
|
||||||
|
elapsed_ms = scene_started_at.elapsed().as_millis() as u64,
|
||||||
|
error = %error,
|
||||||
|
"拼图关卡场景图生成失败"
|
||||||
|
);
|
||||||
|
return Err(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
let scene_image = scene_generated.images.into_iter().next().ok_or_else(|| {
|
let scene_image = scene_generated.images.into_iter().next().ok_or_else(|| {
|
||||||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||||
"provider": VECTOR_ENGINE_PROVIDER,
|
"provider": VECTOR_ENGINE_PROVIDER,
|
||||||
@@ -336,7 +372,8 @@ pub(crate) async fn generate_puzzle_level_asset_bundle(
|
|||||||
}))
|
}))
|
||||||
})?;
|
})?;
|
||||||
let scene_reference = build_puzzle_downloaded_image_reference(&scene_image);
|
let scene_reference = build_puzzle_downloaded_image_reference(&scene_image);
|
||||||
let scene_persist_future = persist_puzzle_level_asset_image(
|
let scene_persist_started_at = Instant::now();
|
||||||
|
let level_scene = persist_puzzle_level_asset_image(
|
||||||
state,
|
state,
|
||||||
owner_user_id,
|
owner_user_id,
|
||||||
session_id,
|
session_id,
|
||||||
@@ -347,8 +384,18 @@ pub(crate) async fn generate_puzzle_level_asset_bundle(
|
|||||||
"level_scene",
|
"level_scene",
|
||||||
"scene",
|
"scene",
|
||||||
scene_image,
|
scene_image,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
tracing::info!(
|
||||||
|
provider = VECTOR_ENGINE_PROVIDER,
|
||||||
|
image_model = PuzzleImageModel::GptImage2.request_model_name(),
|
||||||
|
session_id,
|
||||||
|
level_name,
|
||||||
|
slot = "level_scene",
|
||||||
|
elapsed_ms = scene_persist_started_at.elapsed().as_millis() as u64,
|
||||||
|
"拼图关卡场景图持久化完成"
|
||||||
);
|
);
|
||||||
let spritesheet_future = generate_and_persist_puzzle_level_asset(
|
let ui_spritesheet = generate_and_persist_puzzle_level_asset(
|
||||||
state,
|
state,
|
||||||
&http_client,
|
&http_client,
|
||||||
&settings,
|
&settings,
|
||||||
@@ -362,8 +409,9 @@ pub(crate) async fn generate_puzzle_level_asset_bundle(
|
|||||||
"puzzle_ui_spritesheet_image",
|
"puzzle_ui_spritesheet_image",
|
||||||
"ui_spritesheet",
|
"ui_spritesheet",
|
||||||
"spritesheet",
|
"spritesheet",
|
||||||
);
|
)
|
||||||
let background_future = generate_and_persist_puzzle_level_asset(
|
.await?;
|
||||||
|
let level_background = generate_and_persist_puzzle_level_asset(
|
||||||
state,
|
state,
|
||||||
&http_client,
|
&http_client,
|
||||||
&settings,
|
&settings,
|
||||||
@@ -377,14 +425,21 @@ pub(crate) async fn generate_puzzle_level_asset_bundle(
|
|||||||
"puzzle_level_background_image",
|
"puzzle_level_background_image",
|
||||||
"level_background",
|
"level_background",
|
||||||
"background",
|
"background",
|
||||||
);
|
)
|
||||||
let (level_scene, ui_spritesheet, level_background) =
|
.await?;
|
||||||
tokio::join!(scene_persist_future, spritesheet_future, background_future);
|
|
||||||
|
|
||||||
|
tracing::info!(
|
||||||
|
provider = VECTOR_ENGINE_PROVIDER,
|
||||||
|
image_model = PuzzleImageModel::GptImage2.request_model_name(),
|
||||||
|
session_id,
|
||||||
|
level_name,
|
||||||
|
elapsed_ms = bundle_started_at.elapsed().as_millis() as u64,
|
||||||
|
"拼图关卡资产包生成完成"
|
||||||
|
);
|
||||||
Ok(GeneratedPuzzleLevelAssetBundle {
|
Ok(GeneratedPuzzleLevelAssetBundle {
|
||||||
level_scene: level_scene?,
|
level_scene,
|
||||||
ui_spritesheet: ui_spritesheet?,
|
ui_spritesheet,
|
||||||
level_background: level_background?,
|
level_background,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -403,7 +458,20 @@ async fn generate_and_persist_puzzle_level_asset(
|
|||||||
slot: &str,
|
slot: &str,
|
||||||
file_stem: &str,
|
file_stem: &str,
|
||||||
) -> Result<GeneratedPuzzleLevelAssetResponse, AppError> {
|
) -> Result<GeneratedPuzzleLevelAssetResponse, AppError> {
|
||||||
let generated = create_puzzle_vector_engine_image_generation(
|
let started_at = Instant::now();
|
||||||
|
tracing::info!(
|
||||||
|
provider = VECTOR_ENGINE_PROVIDER,
|
||||||
|
image_model = PuzzleImageModel::GptImage2.request_model_name(),
|
||||||
|
session_id,
|
||||||
|
level_name,
|
||||||
|
slot,
|
||||||
|
asset_kind,
|
||||||
|
size,
|
||||||
|
prompt_chars = prompt.chars().count(),
|
||||||
|
reference_image_bytes = reference_image.bytes_len,
|
||||||
|
"拼图关卡资产生成请求开始"
|
||||||
|
);
|
||||||
|
let generated = match create_puzzle_vector_engine_image_generation(
|
||||||
http_client,
|
http_client,
|
||||||
settings,
|
settings,
|
||||||
PuzzleImageModel::GptImage2,
|
PuzzleImageModel::GptImage2,
|
||||||
@@ -414,7 +482,36 @@ async fn generate_and_persist_puzzle_level_asset(
|
|||||||
Some(reference_image),
|
Some(reference_image),
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.map_err(map_puzzle_generation_endpoint_error)?;
|
.map_err(map_puzzle_generation_endpoint_error)
|
||||||
|
{
|
||||||
|
Ok(generated) => {
|
||||||
|
tracing::info!(
|
||||||
|
provider = VECTOR_ENGINE_PROVIDER,
|
||||||
|
image_model = PuzzleImageModel::GptImage2.request_model_name(),
|
||||||
|
session_id,
|
||||||
|
level_name,
|
||||||
|
slot,
|
||||||
|
asset_kind,
|
||||||
|
elapsed_ms = started_at.elapsed().as_millis() as u64,
|
||||||
|
"拼图关卡资产生成请求完成"
|
||||||
|
);
|
||||||
|
generated
|
||||||
|
}
|
||||||
|
Err(error) => {
|
||||||
|
tracing::warn!(
|
||||||
|
provider = VECTOR_ENGINE_PROVIDER,
|
||||||
|
image_model = PuzzleImageModel::GptImage2.request_model_name(),
|
||||||
|
session_id,
|
||||||
|
level_name,
|
||||||
|
slot,
|
||||||
|
asset_kind,
|
||||||
|
elapsed_ms = started_at.elapsed().as_millis() as u64,
|
||||||
|
error = %error,
|
||||||
|
"拼图关卡资产生成请求失败"
|
||||||
|
);
|
||||||
|
return Err(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
let image = generated.images.into_iter().next().ok_or_else(|| {
|
let image = generated.images.into_iter().next().ok_or_else(|| {
|
||||||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||||
"provider": VECTOR_ENGINE_PROVIDER,
|
"provider": VECTOR_ENGINE_PROVIDER,
|
||||||
@@ -427,7 +524,8 @@ async fn generate_and_persist_puzzle_level_asset(
|
|||||||
image
|
image
|
||||||
};
|
};
|
||||||
|
|
||||||
persist_puzzle_level_asset_image(
|
let persist_started_at = Instant::now();
|
||||||
|
let persisted = persist_puzzle_level_asset_image(
|
||||||
state,
|
state,
|
||||||
owner_user_id,
|
owner_user_id,
|
||||||
session_id,
|
session_id,
|
||||||
@@ -439,7 +537,19 @@ async fn generate_and_persist_puzzle_level_asset(
|
|||||||
file_stem,
|
file_stem,
|
||||||
image,
|
image,
|
||||||
)
|
)
|
||||||
.await
|
.await?;
|
||||||
|
tracing::info!(
|
||||||
|
provider = VECTOR_ENGINE_PROVIDER,
|
||||||
|
image_model = PuzzleImageModel::GptImage2.request_model_name(),
|
||||||
|
session_id,
|
||||||
|
level_name,
|
||||||
|
slot,
|
||||||
|
asset_kind,
|
||||||
|
elapsed_ms = persist_started_at.elapsed().as_millis() as u64,
|
||||||
|
"拼图关卡资产持久化完成"
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(persisted)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn make_puzzle_ui_spritesheet_image_transparent(
|
pub(crate) fn make_puzzle_ui_spritesheet_image_transparent(
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ pub fn build_vector_engine_image_http_client(
|
|||||||
reqwest::Client::builder()
|
reqwest::Client::builder()
|
||||||
.timeout(Duration::from_millis(settings.request_timeout_ms.max(1)))
|
.timeout(Duration::from_millis(settings.request_timeout_ms.max(1)))
|
||||||
.http1_only()
|
.http1_only()
|
||||||
|
.pool_max_idle_per_host(0)
|
||||||
.build()
|
.build()
|
||||||
.map_err(|error| PlatformImageError::InvalidConfig {
|
.map_err(|error| PlatformImageError::InvalidConfig {
|
||||||
provider: VECTOR_ENGINE_PROVIDER,
|
provider: VECTOR_ENGINE_PROVIDER,
|
||||||
@@ -29,7 +30,14 @@ pub(super) fn map_reqwest_error(
|
|||||||
) -> PlatformImageError {
|
) -> PlatformImageError {
|
||||||
let is_timeout = error.is_timeout();
|
let is_timeout = error.is_timeout();
|
||||||
let is_connect = error.is_connect();
|
let is_connect = error.is_connect();
|
||||||
let source = error.source().map(ToString::to_string);
|
let source_chain_parts = collect_error_source_chain(&error);
|
||||||
|
let source = source_chain_parts.first().cloned();
|
||||||
|
let source_chain_depth = source_chain_parts.len();
|
||||||
|
let source_chain = if source_chain_parts.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(source_chain_parts.join(" -> "))
|
||||||
|
};
|
||||||
let message = format!("{context}:{error}");
|
let message = format!("{context}:{error}");
|
||||||
let audit = build_failure_audit(
|
let audit = build_failure_audit(
|
||||||
request_url,
|
request_url,
|
||||||
@@ -40,7 +48,7 @@ pub(super) fn map_reqwest_error(
|
|||||||
is_timeout,
|
is_timeout,
|
||||||
is_connect,
|
is_connect,
|
||||||
message.as_str(),
|
message.as_str(),
|
||||||
source.clone(),
|
source_chain.clone().or_else(|| source.clone()),
|
||||||
None,
|
None,
|
||||||
Some(latency_ms),
|
Some(latency_ms),
|
||||||
prompt_chars,
|
prompt_chars,
|
||||||
@@ -56,6 +64,8 @@ pub(super) fn map_reqwest_error(
|
|||||||
body = error.is_body(),
|
body = error.is_body(),
|
||||||
status = error.status().map(|status| status.as_u16()).unwrap_or_default(),
|
status = error.status().map(|status| status.as_u16()).unwrap_or_default(),
|
||||||
source = %source.clone().unwrap_or_default(),
|
source = %source.clone().unwrap_or_default(),
|
||||||
|
source_chain = %source_chain.clone().unwrap_or_default(),
|
||||||
|
source_chain_depth,
|
||||||
message = %message,
|
message = %message,
|
||||||
elapsed_ms = latency_ms,
|
elapsed_ms = latency_ms,
|
||||||
prompt_chars,
|
prompt_chars,
|
||||||
@@ -72,7 +82,62 @@ pub(super) fn map_reqwest_error(
|
|||||||
request: error.is_request(),
|
request: error.is_request(),
|
||||||
body: error.is_body(),
|
body: error.is_body(),
|
||||||
status_code: error.status().map(|status| status.as_u16()),
|
status_code: error.status().map(|status| status.as_u16()),
|
||||||
source,
|
source: source_chain.or(source),
|
||||||
audit: Some(audit),
|
audit: Some(audit),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn collect_error_source_chain(error: &(dyn Error + 'static)) -> Vec<String> {
|
||||||
|
let mut chain = Vec::new();
|
||||||
|
let mut next = error.source();
|
||||||
|
while let Some(source) = next {
|
||||||
|
chain.push(source.to_string());
|
||||||
|
next = source.source();
|
||||||
|
}
|
||||||
|
chain
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use std::fmt;
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
struct TestError {
|
||||||
|
message: &'static str,
|
||||||
|
source: Option<Box<TestError>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Display for TestError {
|
||||||
|
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
formatter.write_str(self.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Error for TestError {
|
||||||
|
fn source(&self) -> Option<&(dyn Error + 'static)> {
|
||||||
|
self.source
|
||||||
|
.as_deref()
|
||||||
|
.map(|source| source as &(dyn Error + 'static))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn collect_error_source_chain_keeps_nested_causes() {
|
||||||
|
let error = TestError {
|
||||||
|
message: "top",
|
||||||
|
source: Some(Box::new(TestError {
|
||||||
|
message: "middle",
|
||||||
|
source: Some(Box::new(TestError {
|
||||||
|
message: "bottom",
|
||||||
|
source: None,
|
||||||
|
})),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
collect_error_source_chain(&error),
|
||||||
|
vec!["middle".to_string(), "bottom".to_string()]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user