Forráskód Böngészése

修复OSS和飞书通知功能

lkatzey 1 hete
szülő
commit
15ab7635c9

+ 13 - 0
.env.example

@@ -55,5 +55,18 @@ OSS_ACCESS_KEY_SECRET=
 FEISHU_WEBHOOK_URL=
 # FEISHU_WEBHOOK_SECRET=
 
+# --- Logging ---
+# Log level for the pipeline / TTS / publish loggers (debug|info|warn|error).
+# Logs are written to stdout/stderr and mirrored into docker compose logs and
+# the per-job "日志" panel. Default info.
+# LOG_LEVEL=info
+
+# --- Container DNS ---
+# Public resolvers used by the container (the host's resolver can fail to
+# resolve some public domains, e.g. open.feishu.cn behind a VPN). Defaults to
+# AliDNS / 114DNS. Set to your internal resolver if you need private hostnames.
+# DNS_SERVER_1=223.5.5.5
+# DNS_SERVER_2=114.114.114.114
+
 # --- Service port (host port mapped to container 3000) ---
 PORT=13000

+ 16 - 0
apps/cli/src/commands/render.ts

@@ -156,6 +156,22 @@ export const renderCommand = new Command("render")
     console.log(`\nPipeline: generating ${template} video for ${platforms.join(", ")}`);
     console.log(`Input: ${text.length} chars${flags.length ? " | Flags: " + flags.join(", ") : ""}\n`);
 
+    // Surface publish resolution so a misconfigured OSS/Feishu is obvious.
+    if (noPublish) {
+      console.log("Publish: skipped (--no-publish)");
+    } else if (publish) {
+      console.log(`Publish: oss=${publish.oss ? "on" : "off"} feishu=${publish.feishu ? "on" : "off"}`);
+      if (!publish.oss) {
+        const partial = ["OSS_BUCKET", "OSS_ACCESS_KEY_ID", "OSS_ACCESS_KEY_SECRET", "OSS_REGION", "OSS_ENDPOINT", "OSS_PATH"]
+          .some((k) => process.env[k]);
+        if (partial) {
+          console.log("  (OSS off: set OSS_REGION + OSS_BUCKET + OSS_ACCESS_KEY_ID + OSS_ACCESS_KEY_SECRET to enable upload)");
+        }
+      }
+    } else {
+      console.log("Publish: skipped (no OSS/Feishu configured)");
+    }
+
     const job = await runPipeline(
       {
         text,

+ 52 - 2
apps/web/src/app/api/render/route.ts

@@ -76,18 +76,35 @@ export async function POST(request: Request) {
     env: { ...process.env },
   });
 
+  const jobIdShort = job.id.slice(0, 8);
+  const linePrefix = `[job ${jobIdShort}] `;
+  const fwdOut = makePrefixer(linePrefix, (s) => process.stdout.write(s));
+  const fwdErr = makePrefixer(linePrefix, (s) => process.stderr.write(s));
+
   let stdout = "";
   let stderr = "";
+  let logBuf = "";
 
+  // Capture for parsing/UI AND mirror to the container's stdout/stderr so
+  // `docker compose logs` shows every stage / TTS retry / publish event live.
   child.stdout.on("data", (data: Buffer) => {
-    stdout += data.toString();
+    const s = data.toString();
+    stdout += s;
+    logBuf += s;
+    fwdOut.push(s);
   });
 
   child.stderr.on("data", (data: Buffer) => {
-    stderr += data.toString();
+    const s = data.toString();
+    stderr += s;
+    logBuf += s;
+    fwdErr.push(s);
   });
 
   child.on("close", (code: number) => {
+    fwdOut.flush();
+    fwdErr.flush();
+    const log = capLog(logBuf);
     if (code === 0) {
       const mp4Matches = [...stdout.matchAll(/-> (.+\.mp4)/g)];
       const filePaths = mp4Matches.map(m => m[1].trim());
@@ -114,21 +131,54 @@ export async function POST(request: Request) {
         outputFiles: outputFiles.length > 0 ? outputFiles : undefined,
         ossUrls: ossUrls.length > 0 ? ossUrls : undefined,
         publishError,
+        log,
       });
     } else {
       updateJob(job.id, {
         status: "failed",
         error: stderr.trim() || `Process exited with code ${code}`,
+        log,
       });
     }
   });
 
   child.on("error", (err: Error) => {
+    fwdOut.flush();
+    fwdErr.flush();
     updateJob(job.id, {
       status: "failed",
       error: err.message,
+      log: capLog(logBuf),
     });
   });
 
   return NextResponse.json({ jobId: job.id, status: "started" });
 }
+
+// Keep the per-job log bounded so jobs.json doesn't grow without limit.
+const LOG_CAP = 200_000;
+function capLog(buf: string): string {
+  if (buf.length <= LOG_CAP) return buf;
+  return `...(truncated ${buf.length - LOG_CAP} bytes from the top)\n` + buf.slice(-LOG_CAP);
+}
+
+// Line-buffered forwarder: writes each complete line (prefixed with the job id)
+// to the container's stdout/stderr, holding back a trailing partial line until
+// the next chunk or flush. Lets `docker compose logs` attribute lines to jobs.
+function makePrefixer(prefix: string, write: (s: string) => void) {
+  let pending = "";
+  return {
+    push(chunk: string) {
+      pending += chunk;
+      const lines = pending.split("\n");
+      pending = lines.pop() ?? "";
+      for (const line of lines) write(prefix + line + "\n");
+    },
+    flush() {
+      if (pending.length > 0) {
+        write(prefix + pending + "\n");
+        pending = "";
+      }
+    },
+  };
+}

+ 23 - 0
apps/web/src/app/jobs/[id]/page.tsx

@@ -27,6 +27,7 @@ interface Job {
   outputFiles?: OutputFile[];
   ossUrls?: string[];
   publishError?: string;
+  log?: string;
 }
 
 const STATUS_BADGE: Record<string, string> = {
@@ -201,6 +202,28 @@ export default function JobDetailPage({ params }: { params: Promise<{ id: string
         </div>
       )}
 
+      {/* Render log (captured CLI stdout+stderr) */}
+      {job.log && (
+        <div className="card" style={{ marginBottom: 24 }}>
+          <h3 style={{ marginBottom: 16 }}>日志</h3>
+          <pre style={{
+            fontSize: 12,
+            lineHeight: 1.55,
+            whiteSpace: "pre-wrap",
+            wordBreak: "break-word",
+            maxHeight: 460,
+            overflow: "auto",
+            background: "var(--bg)",
+            padding: 12,
+            borderRadius: 8,
+            color: "var(--text-muted)",
+            margin: 0,
+          }}>
+            {job.log}
+          </pre>
+        </div>
+      )}
+
       {/* Input text */}
       <div className="card">
         <div style={{ fontSize: 12, color: "var(--text-muted)", marginBottom: 8 }}>Input Text</div>

+ 2 - 0
apps/web/src/lib/job-store.ts

@@ -25,6 +25,8 @@ export interface JobData {
   ossUrls?: string[];
   /** Failure detail from the publish stage (OSS upload / Feishu notify). */
   publishError?: string;
+  /** Captured CLI stdout+stderr for this job (truncated). Visible in the UI. */
+  log?: string;
   createdAt: number;
   updatedAt: number;
 }

+ 7 - 0
docker-compose.yml

@@ -32,6 +32,13 @@ services:
     volumes:
       - ./output:/app/output
       - pipeline-jobs:/tmp/pipeline-jobs
+    # Use public DNS resolvers directly. The container inherits the host's
+    # resolver, which can silently fail to resolve some public domains (e.g.
+    # open.feishu.cn behind a VPN/proxy) and break the Feishu webhook. Override
+    # via DNS_SERVER_1/DNS_SERVER_2 in .env if you need an internal resolver.
+    dns:
+      - ${DNS_SERVER_1:-223.5.5.5}
+      - ${DNS_SERVER_2:-114.114.114.114}
     restart: unless-stopped
 
 volumes:

+ 19 - 6
docs/DEPLOYMENT.md

@@ -97,6 +97,7 @@ docker compose up --build -d        # 改完代码后重新构建
 | `OSS_REGION` `OSS_BUCKET` `OSS_ACCESS_KEY_ID` `OSS_ACCESS_KEY_SECRET` | 否 | 阿里云 OSS;四项齐全才启用上传 |
 | `OSS_ENDPOINT` `OSS_PATH` `OSS_PUBLIC_DOMAIN` `OSS_SECURE` | 否 | OSS 自定义端点 / key 前缀 / 资源链接域名(如 CDN) / 是否 HTTPS |
 | `FEISHU_WEBHOOK_URL` `FEISHU_WEBHOOK_SECRET` | 否 | 飞书自定义机器人 webhook 及签名密钥 |
+| `LOG_LEVEL` | 否 | 日志级别 `debug\|info\|warn\|error`,默认 `info` |
 | `PORT` | 否 | 宿主机映射端口,默认 13000 |
 
 > 也可在 WebUI **Settings** 页面填写并保存到 `.env`(敏感值自动脱敏显示)。
@@ -132,7 +133,19 @@ TTS provider 选择、模型、对齐(whisper)、模板配色、采集源(
 
 ---
 
-## 7. 本地开发(非 Docker)
+## 7. 日志与可观测性
+
+排查问题看三处:
+
+1. **容器日志(实时)**:`docker compose logs -f pipeline`。CLI 子进程的每阶段进度、TTS 重试、publish(OSS 上传 / 飞书发送)日志都会**镜像**到这里,每行带 `[job <id前8位>]` 前缀与时间戳,便于区分并发任务。
+2. **WebUI 任务详情页「日志」面板**:任务结束后展示完整捕获的 CLI 输出(上限 ~200KB,超出截断头部)。失败任务同样可看。
+3. **失败任务的 `error` / `publishError`**:任务卡片与详情页直接显示。
+
+日志格式:`<ISO 时间> [LEVEL] [scope] message`,scope 含 `pipeline` / `tts` / `publish`。用 `LOG_LEVEL`(`debug|info|warn|error`,默认 `info`)调节详细程度。
+
+> 说明:渲染由 Web 服务 spawn CLI 子进程执行;CLI 的 stdout/stderr 默认被管道捕获。现在这些输出既会镜像到容器 stdout/stderr(供 `docker compose logs`),也会存入任务(供 UI)。TTS 的瞬时 502/网络重试会以 `[WARN] [tts] HTTP 502, retrying …` 形式出现;OSS/飞书失败会以 `[ERROR] [publish] …` 出现,不再被静默吞掉。
+
+## 8. 本地开发(非 Docker)
 
 ```bash
 pnpm install
@@ -156,7 +169,7 @@ pnpm build           # 全量构建
 
 ---
 
-## 8. CLI 用法
+## 9. CLI 用法
 
 ```bash
 node apps/cli/dist/index.js render <input> -t <template> [options]
@@ -175,7 +188,7 @@ node apps/cli/dist/index.js render <input> -t <template> [options]
 
 ---
 
-## 9. HTTP API(服务化调用)
+## 10. HTTP API(服务化调用)
 
 ### 发起渲染
 
@@ -223,7 +236,7 @@ curl http://localhost:13000/api/jobs/<jobId>
 
 ---
 
-## 10. 数据持久化(卷)
+## 11. 数据持久化(卷)
 
 | 挂载 | 容器路径 | 用途 |
 | --- | --- | --- |
@@ -234,7 +247,7 @@ curl http://localhost:13000/api/jobs/<jobId>
 
 ---
 
-## 11. 排错
+## 12. 排错
 
 | 现象 | 排查 |
 | --- | --- |
@@ -249,7 +262,7 @@ curl http://localhost:13000/api/jobs/<jobId>
 
 ---
 
-## 12. 目录速查
+## 13. 目录速查
 
 ```
 apps/cli/            CLI 入口(Commander)

+ 37 - 1
packages/core/src/pipeline.ts

@@ -7,6 +7,7 @@ import type {
   ExportFile,
 } from "@pipeline/shared";
 import { PLATFORM_PRESETS } from "@pipeline/shared";
+import { createLogger } from "@pipeline/shared/node";
 import { parseText } from "./stages/parse.js";
 import { generateTTS } from "./stages/tts.js";
 import { resolveAssets } from "./stages/assets.js";
@@ -73,13 +74,18 @@ export async function runPipeline(
   callbacks?: PipelineCallbacks
 ): Promise<PipelineJob> {
   const jobId = randomUUID();
+  const log = createLogger("pipeline");
+  const t0 = Date.now();
   const workDir = join(config.output.dir, "tmp", jobId);
   await mkdir(workDir, { recursive: true });
 
   // Opportunistic cache sweep: prune outputs older than retentionDays.
   // Runs on every job (service jobs are low-frequency); never throws.
   if (config.output.retentionDays && config.output.retentionDays > 0) {
-    await cleanupExpiredOutput(config.output.dir, config.output.retentionDays);
+    const cleaned = await cleanupExpiredOutput(config.output.dir, config.output.retentionDays);
+    log.debug(
+      `cleanup scanned=${cleaned.scanned} removed=${cleaned.removed} freed=${Math.round(cleaned.bytesFreed / 1024 / 1024)}MB`
+    );
   }
 
   const platforms = input.platforms;
@@ -91,19 +97,27 @@ export async function runPipeline(
     updatedAt: Date.now(),
   };
 
+  log.info(
+    `job ${jobId} start template=${input.template} platforms=${platforms.join(",")} ` +
+      `chars=${input.text.length} skipLlm=${!!config.skipLlm} skipTts=${!!config.skipTts}`
+  );
+
   try {
     // Stage 1: Parse (shared across all platforms)
     callbacks?.onStageStart?.("parse");
+    const tParse = Date.now();
     const parsed = await parseText(input.text, input.template, {
       llm: config.llm,
       skipLlm: config.skipLlm,
       source: input.source,
     });
     job.parsed = parsed;
+    log.info(`parse ok scenes=${parsed.scenes?.length ?? 0} (${Date.now() - tParse}ms)`);
     callbacks?.onStageComplete?.("parse");
 
     // Stage 2: TTS (shared across all platforms)
     callbacks?.onStageStart?.("tts");
+    const tTts = Date.now();
     const tts = await generateTTS(parsed, workDir, {
       provider: config.tts.provider,
       voiceId: config.tts.voiceId || input.voiceId,
@@ -114,6 +128,10 @@ export async function runPipeline(
       alignment: config.alignment,
     });
     job.tts = tts;
+    log.info(
+      `tts ok provider=${config.tts.provider} scenes=${tts.scenes?.length ?? 0} ` +
+        `duration=${(tts.totalDurationSeconds ?? 0).toFixed(1)}s (${Date.now() - tTts}ms)`
+    );
     callbacks?.onStageComplete?.("tts");
 
     // Stages 3-6: Per-platform loop
@@ -138,7 +156,9 @@ export async function runPipeline(
       // Stage 5: Render
       callbacks?.onStageStart?.(`render:${platform}`);
       const renderOutput = join(workDir, `render-${platform}.mp4`);
+      const tRender = Date.now();
       await renderVideo(composed, renderOutput, config.templates.entryPoint, config.assets.root);
+      log.info(`render ${platform} ok -> ${renderOutput} (${Date.now() - tRender}ms)`);
       callbacks?.onStageComplete?.(`render:${platform}`);
 
       // Stage 6: Export
@@ -149,6 +169,11 @@ export async function runPipeline(
         config.output.dir,
         jobId
       );
+      const expFile = exported.files[0];
+      log.info(
+        `export ${platform} -> ${expFile?.filePath} ` +
+          `(${((expFile?.fileSizeBytes ?? 0) / 1024 / 1024).toFixed(1)}MB)`
+      );
       allExportFiles.push(...exported.files);
       callbacks?.onStageComplete?.(`export:${platform}`);
     }
@@ -158,6 +183,9 @@ export async function runPipeline(
 
     // Stage 7: Publish — upload to OSS + notify Feishu. Opt-in via config.
     if (!config.skipPublish && config.publish) {
+      log.info(
+        `publish oss=${config.publish.oss ? "on" : "off"} feishu=${config.publish.feishu ? "on" : "off"}`
+      );
       callbacks?.onStageStart?.("publish");
       const publishCtx = {
         jobId,
@@ -176,6 +204,7 @@ export async function runPipeline(
         };
         if (!outcome.ok && outcome.error) {
           job.publishError = outcome.error;
+          log.error(`publish failed: ${outcome.error}`);
           callbacks?.onError?.("publish", new Error(outcome.error));
         }
         callbacks?.onStageComplete?.("publish");
@@ -183,12 +212,14 @@ export async function runPipeline(
         // Publish must never mask a successful render.
         const msg = err instanceof Error ? err.message : String(err);
         job.publishError = msg;
+        log.error(`publish error: ${msg}`);
         callbacks?.onError?.("publish", err instanceof Error ? err : new Error(msg));
       }
     }
   } catch (err) {
     job.status = "failed";
     job.error = err instanceof Error ? err.message : String(err);
+    log.error(`job ${jobId} failed: ${job.error}`);
     callbacks?.onError?.("pipeline", err instanceof Error ? err : new Error(String(err)));
 
     // Render failed before any file existed — push failure info to Feishu.
@@ -201,6 +232,11 @@ export async function runPipeline(
     }
   }
 
+  const elapsed = ((Date.now() - t0) / 1000).toFixed(1);
+  log.info(
+    `job ${jobId} ${job.status} in ${elapsed}s` +
+      (job.publishError ? ` publishError=${job.publishError.slice(0, 160)}` : "")
+  );
   job.updatedAt = Date.now();
   return job;
 }

+ 19 - 7
packages/core/src/publish/index.ts

@@ -1,8 +1,10 @@
 import { isoDateString, type ExportFile } from "@pipeline/shared";
-import { getTimezone } from "@pipeline/shared/node";
+import { getTimezone, createLogger, describeError } from "@pipeline/shared/node";
 import { uploadToOss, type OssConfig } from "./oss.js";
 import { sendFeishuMessage, type FeishuConfig } from "./feishu.js";
 
+const log = createLogger("publish");
+
 export type { OssConfig, FeishuConfig };
 
 export interface PublishConfig {
@@ -78,11 +80,16 @@ export function resolvePublishConfig(
   };
 }
 
-async function safeNotify(config: FeishuConfig, text: string): Promise<void> {
+async function safeNotify(config: FeishuConfig, text: string): Promise<boolean> {
   try {
     await sendFeishuMessage(config, text);
-  } catch {
-    // Notification is best-effort; never let it mask the real outcome.
+    return true;
+  } catch (err) {
+    // Notification is best-effort (must not mask the real outcome), but log it
+    // so a misconfigured webhook / signing failure / network error is visible
+    // (including the fetch cause: DNS / connection / TLS) instead of silent.
+    log.error(`feishu send failed: ${describeError(err)}`);
+    return false;
   }
 }
 
@@ -144,22 +151,27 @@ export async function runPublish(
   const uploaded: UploadedFile[] = [];
 
   if (publish.oss) {
+    log.info(`oss upload start files=${files.length} prefix=${publish.oss.path ?? "(root)"}`);
     try {
       for (const file of files) {
         const result = await uploadToOss(file.filePath, publish.oss, { dateDir });
         uploaded.push({ platform: file.platform, filePath: file.filePath, ossUrl: result.url });
+        log.info(`oss uploaded [${file.platform}] key=${result.key} url=${result.url}`);
       }
     } catch (err) {
-      const error = err instanceof Error ? err.message : String(err);
+      const error = describeError(err);
+      log.error(`oss upload failed: ${error}`);
       if (publish.feishu) {
-        await safeNotify(publish.feishu, buildUploadFailureText(ctx, files, error));
+        const ok = await safeNotify(publish.feishu, buildUploadFailureText(ctx, files, error));
+        log.info(`feishu upload-failure notice ${ok ? "sent" : "failed"}`);
       }
       return { ok: false, uploaded, error };
     }
   }
 
   if (publish.feishu) {
-    await safeNotify(publish.feishu, buildSuccessText(ctx, uploaded, files));
+    const ok = await safeNotify(publish.feishu, buildSuccessText(ctx, uploaded, files));
+    log.info(`feishu success notice ${ok ? "sent" : "failed"}`);
   }
 
   return { ok: true, uploaded };

+ 45 - 5
packages/core/src/publish/oss.ts

@@ -1,5 +1,8 @@
 import OSS from "ali-oss";
 import { basename } from "node:path";
+import { createLogger } from "@pipeline/shared/node";
+
+const log = createLogger("oss");
 
 /**
  * Alibaba Cloud (Aliyun) OSS configuration.
@@ -38,6 +41,33 @@ function joinKey(prefix: string | undefined, ...parts: string[]): string {
     .join("/");
 }
 
+/** Host portion of the bucket's default domain: endpoint (minus protocol) or <region>.aliyuncs.com. */
+function ossHost(config: OssConfig): string {
+  if (config.endpoint) {
+    return config.endpoint.replace(/^https?:\/\//i, "").replace(/\/+$/, "");
+  }
+  return `${config.region}.aliyuncs.com`;
+}
+
+/**
+ * Build the public resource URL for an object. Preference:
+ *   1. OSS_PUBLIC_DOMAIN/key (e.g. a CDN),
+ *   2. a full http(s) URL reported by the SDK,
+ *   3. constructed from bucket + region/endpoint.
+ * Never falls back to the bare key (a path) — ali-oss's multipartUpload often
+ * omits `.url`, which previously produced a path instead of a link.
+ */
+function buildPublicUrl(config: OssConfig, key: string, result?: { url?: string }): string {
+  if (config.publicDomain) {
+    return `${config.publicDomain.replace(/\/+$/, "")}/${key}`;
+  }
+  if (result?.url && /^https?:\/\//i.test(result.url)) {
+    return result.url;
+  }
+  const proto = config.secure === false ? "http" : "https";
+  return `${proto}://${config.bucket}.${ossHost(config)}/${key}`;
+}
+
 /**
  * Upload a single local file to OSS and return its public URL.
  * Uses multipart upload so large MP4s are handled reliably.
@@ -47,6 +77,20 @@ export async function uploadToOss(
   config: OssConfig,
   opts?: { dateDir?: string }
 ): Promise<OssUploadResult> {
+  // The ali-oss SDK signs for oss-<region>.aliyuncs.com. A `s3.`-prefixed
+  // (S3-compatible) endpoint signs incorrectly and OSS returns HTTP 403
+  // AccessDenied. Warn loudly — the Aliyun console often surfaces the S3 URL.
+  if (config.endpoint) {
+    const host = config.endpoint.replace(/^https?:\/\//i, "");
+    if (host.startsWith("s3.") || host.includes(".s3.")) {
+      log.warn(
+        `OSS_ENDPOINT "${config.endpoint}" looks like an S3-compatible endpoint; ` +
+          "the ali-oss SDK will likely get HTTP 403 AccessDenied. " +
+          "Remove OSS_ENDPOINT and set OSS_REGION only (e.g. oss-cn-beijing)."
+      );
+    }
+  }
+
   const client = new OSS({
     region: config.region,
     bucket: config.bucket,
@@ -65,9 +109,5 @@ export async function uploadToOss(
     timeout: 600000,
   });
 
-  const url = config.publicDomain
-    ? `${config.publicDomain.replace(/\/+$/, "")}/${key}`
-    : result.url || key;
-
-  return { key, url };
+  return { key, url: buildPublicUrl(config, key, result) };
 }

+ 3 - 0
packages/shared/src/node.ts

@@ -3,3 +3,6 @@
 export { findMonorepoRoot, resolveOutputDir } from "./utils/paths.js";
 export { isoDateString, formatChineseDate } from "./utils/date.js";
 export { getTimezone } from "./utils/timezone.js";
+export { createLogger } from "./utils/logger.js";
+export type { Logger, LogLevel } from "./utils/logger.js";
+export { describeError } from "./utils/error.js";

+ 36 - 0
packages/shared/src/utils/error.ts

@@ -0,0 +1,36 @@
+/**
+ * Stringify an error including the Node `fetch` cause chain and OSS error
+ * fields. A bare `TypeError: fetch failed` is useless for diagnosis; the real
+ * reason (ENOTFOUND / ECONNREFUSED / ECONNRESET / TLS…) is on `err.cause`.
+ * Likewise ali-oss puts the machine code on `err.code` (e.g. AccessDenied) and
+ * the HTTP status on `err.status`.
+ */
+export function describeError(err: unknown): string {
+  const e = err as {
+    message?: string;
+    code?: string | number;
+    status?: number;
+    cause?: unknown;
+  } | null;
+
+  const msg =
+    e && typeof e.message === "string" && e.message.length ? e.message : String(err);
+
+  const segs: string[] = [];
+  const code = e?.code;
+  if (typeof code === "string" && code && code !== msg) segs.push(code);
+  if (typeof e?.status === "number") segs.push(`HTTP ${e.status}`);
+
+  // Walk the cause chain (Node fetch wraps the real error in `err.cause`).
+  let c = e?.cause;
+  let guard = 0;
+  while (c && guard++ < 4) {
+    const ce = c as { message?: string; code?: string; cause?: unknown };
+    const cm = ce.message ?? String(c);
+    const cc = typeof ce.code === "string" && ce.code ? ce.code : "";
+    segs.push(cc && cc !== cm ? `${cc} ${cm}` : cm);
+    c = ce.cause;
+  }
+
+  return segs.length ? `${msg} [${segs.join(" | ")}]` : msg;
+}

+ 44 - 0
packages/shared/src/utils/logger.ts

@@ -0,0 +1,44 @@
+/**
+ * Tiny structured logger (no deps). Writes one line per event to stdout/stderr:
+ *   2026-07-20T14:00:00.000Z [INFO] [scope] message
+ *
+ * Level via `LOG_LEVEL` env (debug|info|warn|error), default `info`.
+ * `info`/`debug` → stdout; `warn`/`error` → stderr, so `docker compose logs`
+ * captures both severity streams. Node-only (uses process.env / process.stdout).
+ */
+export type LogLevel = "debug" | "info" | "warn" | "error";
+
+const ORDER: Record<LogLevel, number> = { debug: 10, info: 20, warn: 30, error: 40 };
+
+function threshold(): number {
+  const raw = (process.env.LOG_LEVEL || "info").toLowerCase().trim();
+  return raw in ORDER ? ORDER[raw as LogLevel] : ORDER.info;
+}
+
+function stamp(): string {
+  return new Date().toISOString();
+}
+
+function emit(level: LogLevel, scope: string, msg: string): void {
+  if (ORDER[level] < threshold()) return;
+  const line = `${stamp()} [${level.toUpperCase()}] [${scope}] ${msg}\n`;
+  const stream = level === "warn" || level === "error" ? process.stderr : process.stdout;
+  stream.write(line);
+}
+
+export interface Logger {
+  debug(msg: string): void;
+  info(msg: string): void;
+  warn(msg: string): void;
+  error(msg: string): void;
+}
+
+/** Create a scoped logger, e.g. `createLogger("pipeline")`. */
+export function createLogger(scope: string): Logger {
+  return {
+    debug: (m) => emit("debug", scope, m),
+    info: (m) => emit("info", scope, m),
+    warn: (m) => emit("warn", scope, m),
+    error: (m) => emit("error", scope, m),
+  };
+}

+ 5 - 4
packages/tts/src/retry.ts

@@ -1,4 +1,7 @@
 import { setTimeout as sleep } from "node:timers/promises";
+import { createLogger, describeError } from "@pipeline/shared/node";
+
+const log = createLogger("tts");
 
 const DEFAULT_RETRIES = 3;
 const DEFAULT_BASE_DELAY_MS = 800;
@@ -54,9 +57,7 @@ export async function fetchWithRetry(
       response = await fetch(input, init);
     } catch (err) {
       if (attempt >= retries) throw err;
-      console.warn(
-        `[tts] request failed (${err instanceof Error ? err.message : err}), retrying ${attempt + 1}/${retries}`
-      );
+      log.warn(`request failed (${describeError(err)}), retrying ${attempt + 1}/${retries}`);
       await sleep(delayFor(attempt, baseDelay, maxDelay));
       continue;
     }
@@ -72,7 +73,7 @@ export async function fetchWithRetry(
     // Retriable status with retries left: drain the body to free the socket,
     // then back off.
     await response.arrayBuffer().catch(() => {});
-    console.warn(`[tts] HTTP ${response.status}, retrying ${attempt + 1}/${retries}`);
+    log.warn(`HTTP ${response.status}, retrying ${attempt + 1}/${retries}`);
     await sleep(delayFor(attempt, baseDelay, maxDelay));
   }