diff --git a/.hermes/shared-memory/pitfalls.md b/.hermes/shared-memory/pitfalls.md index b92baeef..0d5e729e 100644 --- a/.hermes/shared-memory/pitfalls.md +++ b/.hermes/shared-memory/pitfalls.md @@ -25,9 +25,10 @@ ## 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 错误体。 -- 处理:先按 `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`。 - 关联:`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、响应解析失败、未返回图片,还是下载图片失败。 - 原因:外部 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 文件是否堆积。 - 关联:`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)”。 - 原因:该故事板会把角色图和首幕背景图作为参考图一起传给 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`,否则旧进程仍按旧超时运行。 -- 验证:分别运行 `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。 +- 处理:开局 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.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`。 ## 开局 CG 成功后又变空白要保留 profile.openingCg diff --git a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md index 5c6ab08a..d62ee632 100644 --- a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md +++ b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md @@ -67,7 +67,7 @@ spacetime sql "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 && spacetime version use `,然后重新启动 `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 日志: @@ -292,7 +292,7 @@ OpenTelemetry 现阶段默认开启 OTLP 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。 - 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。 - 本地 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 查看。 @@ -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`。 -外部 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 SELECT event_id, scope_id AS provider, metadata_json, occurred_at @@ -378,7 +378,7 @@ ORDER BY failures DESC, last_seen DESC 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 默认配置: diff --git a/server-rs/crates/api-server/src/external_api_audit.rs b/server-rs/crates/api-server/src/external_api_audit.rs index d75b3e56..78c0a40b 100644 --- a/server-rs/crates/api-server/src/external_api_audit.rs +++ b/server-rs/crates/api-server/src/external_api_audit.rs @@ -341,6 +341,8 @@ fn record_external_api_failure_otlp(failure: &ExternalApiFailureDraft) { prompt_chars = failure.prompt_chars, reference_image_count = failure.reference_image_count, 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, "外部 API 调用失败" ); @@ -394,6 +396,10 @@ mod tests { ) .with_status_code(Some(429)) .with_retryable(true) + .with_error_source(Some( + "client error (SendRequest) -> connection closed before message completed" + .to_string(), + )) .with_latency_ms(Some(1234)) .with_prompt_chars(Some(88)) .with_reference_image_count(Some(2)) @@ -414,6 +420,10 @@ mod tests { assert_eq!(metadata["promptChars"], 88); assert_eq!(metadata["referenceImageCount"], 2); 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(_))); } diff --git a/server-rs/crates/api-server/src/openai_image_generation.rs b/server-rs/crates/api-server/src/openai_image_generation.rs index 4ecca8b2..406d4ef3 100644 --- a/server-rs/crates/api-server/src/openai_image_generation.rs +++ b/server-rs/crates/api-server/src/openai_image_generation.rs @@ -424,6 +424,7 @@ pub(crate) fn map_platform_image_error(error: PlatformImageError) -> AppError { details["referenceImageCount"] = json!(audit.reference_image_count); details["imageModel"] = json!(audit.image_model); details["rawExcerpt"] = json!(audit.raw_excerpt); + details["errorSource"] = json!(audit.error_source); } AppError::from_status(status).with_details(details) diff --git a/server-rs/crates/api-server/src/puzzle/generation.rs b/server-rs/crates/api-server/src/puzzle/generation.rs index c03ad4bf..3713a653 100644 --- a/server-rs/crates/api-server/src/puzzle/generation.rs +++ b/server-rs/crates/api-server/src/puzzle/generation.rs @@ -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 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, &settings, PuzzleImageModel::GptImage2, @@ -328,7 +337,34 @@ pub(crate) async fn generate_puzzle_level_asset_bundle( Some(&puzzle_reference), ) .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(|| { AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ "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_persist_future = persist_puzzle_level_asset_image( + let scene_persist_started_at = Instant::now(); + let level_scene = persist_puzzle_level_asset_image( state, owner_user_id, session_id, @@ -347,8 +384,18 @@ pub(crate) async fn generate_puzzle_level_asset_bundle( "level_scene", "scene", 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, &http_client, &settings, @@ -362,8 +409,9 @@ pub(crate) async fn generate_puzzle_level_asset_bundle( "puzzle_ui_spritesheet_image", "ui_spritesheet", "spritesheet", - ); - let background_future = generate_and_persist_puzzle_level_asset( + ) + .await?; + let level_background = generate_and_persist_puzzle_level_asset( state, &http_client, &settings, @@ -377,14 +425,21 @@ pub(crate) async fn generate_puzzle_level_asset_bundle( "puzzle_level_background_image", "level_background", "background", - ); - let (level_scene, ui_spritesheet, level_background) = - tokio::join!(scene_persist_future, spritesheet_future, background_future); + ) + .await?; + 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 { - level_scene: level_scene?, - ui_spritesheet: ui_spritesheet?, - level_background: level_background?, + level_scene, + ui_spritesheet, + level_background, }) } @@ -403,7 +458,20 @@ async fn generate_and_persist_puzzle_level_asset( slot: &str, file_stem: &str, ) -> Result { - 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, settings, PuzzleImageModel::GptImage2, @@ -414,7 +482,36 @@ async fn generate_and_persist_puzzle_level_asset( Some(reference_image), ) .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(|| { AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ "provider": VECTOR_ENGINE_PROVIDER, @@ -427,7 +524,8 @@ async fn generate_and_persist_puzzle_level_asset( image }; - persist_puzzle_level_asset_image( + let persist_started_at = Instant::now(); + let persisted = persist_puzzle_level_asset_image( state, owner_user_id, session_id, @@ -439,7 +537,19 @@ async fn generate_and_persist_puzzle_level_asset( file_stem, 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( diff --git a/server-rs/crates/platform-image/src/vector_engine/transport.rs b/server-rs/crates/platform-image/src/vector_engine/transport.rs index 2c771f52..a40819da 100644 --- a/server-rs/crates/platform-image/src/vector_engine/transport.rs +++ b/server-rs/crates/platform-image/src/vector_engine/transport.rs @@ -11,6 +11,7 @@ pub fn build_vector_engine_image_http_client( reqwest::Client::builder() .timeout(Duration::from_millis(settings.request_timeout_ms.max(1))) .http1_only() + .pool_max_idle_per_host(0) .build() .map_err(|error| PlatformImageError::InvalidConfig { provider: VECTOR_ENGINE_PROVIDER, @@ -29,7 +30,14 @@ pub(super) fn map_reqwest_error( ) -> PlatformImageError { let is_timeout = error.is_timeout(); 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 audit = build_failure_audit( request_url, @@ -40,7 +48,7 @@ pub(super) fn map_reqwest_error( is_timeout, is_connect, message.as_str(), - source.clone(), + source_chain.clone().or_else(|| source.clone()), None, Some(latency_ms), prompt_chars, @@ -56,6 +64,8 @@ pub(super) fn map_reqwest_error( body = error.is_body(), status = error.status().map(|status| status.as_u16()).unwrap_or_default(), source = %source.clone().unwrap_or_default(), + source_chain = %source_chain.clone().unwrap_or_default(), + source_chain_depth, message = %message, elapsed_ms = latency_ms, prompt_chars, @@ -72,7 +82,62 @@ pub(super) fn map_reqwest_error( request: error.is_request(), body: error.is_body(), status_code: error.status().map(|status| status.as_u16()), - source, + source: source_chain.or(source), audit: Some(audit), } } + +fn collect_error_source_chain(error: &(dyn Error + 'static)) -> Vec { + 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>, + } + + 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()] + ); + } +}