diff --git a/docs/architecture.md b/docs/architecture.md
index 6469395..3d9ced6 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -271,6 +271,7 @@ Content-Type: application/json
| 413 | FILE_TOO_LARGE | ファイルサイズ超過 |
| 415 | INVALID_FORMAT | 画像フォーマット不正(X10: マジックバイト検証失敗) |
| 429 | RATE_LIMITED | レート制限 |
+| 500 | INTERNAL | 未捕捉例外 (app.onError が構造化して返却・Workers Logs に記録) |
| 507 | QUOTA_EXCEEDED | ストレージクォータ超過 |
### 4.3 受信者向けエンドポイント(認証必須)
diff --git a/frontend/index.html b/frontend/index.html
index aff83c1..e12891a 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -69,10 +69,10 @@
diff --git a/frontend/src/lib/analytics.ts b/frontend/src/lib/analytics.ts
new file mode 100644
index 0000000..ec63ce8
--- /dev/null
+++ b/frontend/src/lib/analytics.ts
@@ -0,0 +1,202 @@
+/**
+ * クライアント側エラーの GA4 計測ヘルパー。
+ *
+ * API(Workers)ログには現れないブラウザ内エラー(画像の R2 GET 失敗、
+ * 画像処理パイプラインの失敗、presigned PUT 失敗、未捕捉例外など)を、
+ * 既に `index.html` に導入済みの GA4(gtag.js / `window.gtag`)へ
+ * `client_error` イベントとして送り、実機で何が多発しているかを集計できるようにする。
+ *
+ * ## 送信の原則
+ * - 送るイベントは **`client_error` の 1 種のみ**。分類は `error_kind` パラメータで行う。
+ * - **本番(`import.meta.env.PROD`)でのみ実送信**。開発では GA プロパティを汚さないよう
+ * {@link debugLog} に出すだけ(`?debug=true` で実機確認できる)。
+ * - `window.gtag` が未定義(広告ブロッカー等)なら no-op。
+ * - 同一エラーの連投を防ぐため、キー単位で 1 タブあたり {@link DEDUP_LIMIT} 回まで送信。
+ * - 個人情報・署名付き URL を送らないよう、message/path をサニタイズする。
+ */
+
+import type { ReactEventHandler } from "react";
+import { debugLog } from "./debug-log";
+
+const alog = debugLog.scope("analytics");
+
+declare global {
+ interface Window {
+ gtag?: (...args: unknown[]) => void;
+ dataLayer?: unknown[];
+ }
+}
+
+// ---- 送信データスキーマ ----
+
+/** 発生源の分類。GA4 の error_kind パラメータに入る値集合。 */
+export type ErrorKind =
+ | "image_render" //
の R2 GET 失敗 (onError)
+ | "image_processing" // 変換パイプライン失敗 (decode/canvas/heic 等)
+ | "upload_put" // presigned PUT アップロード失敗
+ | "uncaught" // window.onerror (未捕捉 JS エラー)
+ | "unhandled_rejection" // 未処理 Promise reject
+ | "chunk_load"; // 動的 import のチャンクロード失敗
+
+/** 発生箇所ラベル。自由文字列にせず固定集合で運用しノイズを防ぐ。 */
+export type ErrorContext =
+ | "gallery-thumb"
+ | "photo-detail-view"
+ | "photo-detail-thumb"
+ | "dashboard-thumb"
+ | "done-thumb"
+ | "avatar"
+ | "pipeline" // 送信時の本加工パイプライン
+ | "preview" // 送信前のサムネプレビュー生成
+ | "r2-put"
+ | "global";
+
+/** image_render 用: 壊れた画像の種別。 */
+export type ImageResource = "thumb" | "original" | "avatar";
+
+/** upload_put 用: どちらの PUT が失敗したか。 */
+export type UploadTarget = "original" | "thumb";
+
+/** GA4 の client_error イベントに送るパラメータ全体。 */
+export interface ClientErrorParams {
+ error_kind: ErrorKind;
+ context: ErrorContext;
+ /** location.pathname を正規化したもの(handle/photoId はマスク、search/hash は除去)。 */
+ page_path: string;
+ /** Error.name (例 "EncodingError" "TypeError")。<= 40 文字。 */
+ error_name?: string;
+ /** Error.message をサニタイズ + 100 文字に切り詰めたもの。 */
+ error_message?: string;
+ resource?: ImageResource;
+ target?: UploadTarget;
+ /** R2 の HTTP ステータス(取得できた場合のみ)。 */
+ http_status?: number;
+}
+
+/** 呼び出し側の入力。`page_path` はヘルパー内で自動補完する。 */
+export type ClientErrorInput = Omit;
+
+// ---- 制約・サニタイズ ----
+
+const EVENT_NAME = "client_error";
+const MAX_MESSAGE = 100; // GA4 の文字列パラメータ上限
+const MAX_NAME = 40;
+const DEDUP_LIMIT = 5;
+
+/** キー(kind|context|name|message)ごとの送信回数。1 タブ内で保持。 */
+const sentCounts = new Map();
+
+/** message から署名付き URL 等を除去し、改行を潰して 100 文字に切り詰める。 */
+function sanitizeMessage(msg: string | undefined): string | undefined {
+ if (!msg) return undefined;
+ const cleaned = msg
+ .replace(/https?:\/\/\S+/gi, "[url]") // presigned URL の署名クエリごとマスク
+ .replace(/\s+/g, " ")
+ .trim();
+ return cleaned.slice(0, MAX_MESSAGE) || undefined;
+}
+
+/**
+ * location.pathname を計測用に正規化する。
+ * handle / photoId を含むパスは `*` にマスクして個別識別子を送らない。
+ */
+function sanitizePath(pathname: string): string {
+ if (pathname.startsWith("/send/")) {
+ const sub = pathname.slice("/send/".length).split("/").slice(1).join("/");
+ return sub ? `/send/*/${sub}` : "/send/*";
+ }
+ if (pathname.startsWith("/gallery/")) return "/gallery/*";
+ return pathname;
+}
+
+/** 任意の throw 値から error_name / error_message を安全に取り出す。 */
+export function extractError(
+ err: unknown,
+): Pick {
+ if (err instanceof Error) {
+ // err.name は常に非空文字列なので slice のみでよい (空チェック不要)
+ return { error_name: err.name.slice(0, MAX_NAME), error_message: sanitizeMessage(err.message) };
+ }
+ if (err == null) return {};
+ return { error_message: sanitizeMessage(String(err)) };
+}
+
+// ---- 送信 ----
+
+/**
+ * client_error イベントを送る。本番のみ GA へ実送信し、常に debugLog にミラーする。
+ * `page_path` は呼び出し側が渡さなくてよい(内部で現在パスから補完)。
+ */
+export function trackClientError(input: ClientErrorInput): void {
+ const params: ClientErrorParams = {
+ ...input,
+ page_path: sanitizePath(window.location.pathname),
+ };
+ // undefined のキーは GA に空値を残さないよう除去
+ for (const k of Object.keys(params) as (keyof ClientErrorParams)[]) {
+ if (params[k] === undefined) delete params[k];
+ }
+
+ const dedupeKey = `${params.error_kind}|${params.context}|${params.target ?? ""}|${params.http_status ?? ""}|${params.error_name ?? ""}|${params.error_message ?? ""}`;
+ const count = sentCounts.get(dedupeKey) ?? 0;
+
+ // 上限超過分も含め、送信内容は常に debugLog に出す(実機デバッグ用)
+ alog.log("client_error", params, `(#${count + 1})`);
+
+ if (count >= DEDUP_LIMIT) return;
+ sentCounts.set(dedupeKey, count + 1);
+
+ if (!import.meta.env.PROD) return;
+ if (typeof window.gtag !== "function") return;
+ window.gtag("event", EVENT_NAME, params);
+}
+
+/** (context, resource) ごとに生成済みハンドラをキャッシュし、参照を安定させる。 */
+const imageErrorHandlers = new Map>();
+
+/**
+ * `
` に渡すハンドラを作る。R2 presigned GET 失敗を image_render として計測する。
+ * 例: `
`
+ * GA 送信のみで src は差し替えないため 1 回発火(再レンダーの重複はデデュープで吸収)。
+ *
+ * (context, resource) は定数リテラルなので、ハンドラをキャッシュして参照を安定させる。
+ * レンダーごとのクロージャ生成と、React による onError リスナの張り替え
+ * (onError は非バブリングで DOM に直付けされる) を避けるため。
+ */
+export function onImageError(
+ context: ErrorContext,
+ resource: ImageResource,
+): ReactEventHandler {
+ const key = `${context}|${resource}`;
+ let handler = imageErrorHandlers.get(key);
+ if (!handler) {
+ handler = () => trackClientError({ error_kind: "image_render", context, resource });
+ imageErrorHandlers.set(key, handler);
+ }
+ return handler;
+}
+
+/** 動的 import のチャンクロード失敗を message から判定する。 */
+function isChunkLoadError(reason: unknown): boolean {
+ const msg = reason instanceof Error ? reason.message : String(reason ?? "");
+ return /dynamically imported module|Importing a module script failed|ChunkLoadError/i.test(msg);
+}
+
+/**
+ * グローバルエラーハンドラを登録する。App の外側(React 非依存)で 1 回だけ呼ぶ。
+ * リソースロードエラー(
等)は個別 onError で扱うためここでは拾わない。
+ */
+export function initAnalytics(): void {
+ window.addEventListener("error", (event) => {
+ // ErrorEvent 以外(リソースエラー)は個別 onError 側の担当
+ if (!(event instanceof ErrorEvent)) return;
+ const { error_name, error_message } = extractError(event.error ?? event.message);
+ trackClientError({ error_kind: "uncaught", context: "global", error_name, error_message });
+ });
+
+ window.addEventListener("unhandledrejection", (event) => {
+ const { error_name, error_message } = extractError(event.reason);
+ const error_kind = isChunkLoadError(event.reason) ? "chunk_load" : "unhandled_rejection";
+ trackClientError({ error_kind, context: "global", error_name, error_message });
+ });
+}
diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx
index 98e78f3..7ff8cf2 100644
--- a/frontend/src/main.tsx
+++ b/frontend/src/main.tsx
@@ -1,8 +1,12 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import App from "./App.tsx";
+import { initAnalytics } from "./lib/analytics";
import "./index.css";
+// クライアント側エラー(未捕捉例外 / 未処理 reject / チャンクロード失敗)を GA へ計測する
+initAnalytics();
+
const root = document.getElementById("root") as HTMLElement;
createRoot(root).render(
diff --git a/frontend/src/pages/DashboardPage.tsx b/frontend/src/pages/DashboardPage.tsx
index 8a30922..4df90ec 100644
--- a/frontend/src/pages/DashboardPage.tsx
+++ b/frontend/src/pages/DashboardPage.tsx
@@ -5,6 +5,7 @@ import { Link } from "react-router";
import Card from "../components/ui/Card";
import LoadingSpinner from "../components/ui/LoadingSpinner";
import StorageQuotaBar from "../components/ui/StorageQuotaBar";
+import { onImageError } from "../lib/analytics";
import { receiverApi } from "../lib/api";
import { userAtom } from "../stores/user";
import type { Photo } from "../types/photo";
@@ -133,6 +134,7 @@ function RecentPhotos({ photos, loading }: { photos: Photo[]; loading: boolean }
alt={photo.sender_name ?? "写真"}
className="max-h-full max-w-full rounded-xl object-contain"
loading="lazy"
+ onError={onImageError("dashboard-thumb", "thumb")}
/>
) : (
) : (
📷
diff --git a/frontend/src/pages/PhotoDetailPage.tsx b/frontend/src/pages/PhotoDetailPage.tsx
index e48aa65..e695870 100644
--- a/frontend/src/pages/PhotoDetailPage.tsx
+++ b/frontend/src/pages/PhotoDetailPage.tsx
@@ -4,6 +4,7 @@ import Button from "../components/ui/Button";
import Card from "../components/ui/Card";
import ConfirmDialog from "../components/ui/ConfirmDialog";
import LoadingSpinner from "../components/ui/LoadingSpinner";
+import { onImageError } from "../lib/analytics";
import { receiverApi } from "../lib/api";
import { formatBytes } from "../lib/format";
import type { Photo } from "../types/photo";
@@ -176,6 +177,7 @@ export default function PhotoDetailPage() {
src={photo.thumb_url}
alt=""
aria-hidden="true"
+ onError={onImageError("photo-detail-thumb", "thumb")}
className={`absolute inset-0 h-full w-full object-contain blur-md transition-opacity duration-300 ${
viewLoaded ? "opacity-0" : "opacity-100"
}`}
@@ -186,6 +188,7 @@ export default function PhotoDetailPage() {
src={photo.view_url}
alt={photo.sender_name ?? "写真"}
onLoad={() => setViewLoaded(true)}
+ onError={onImageError("photo-detail-view", "original")}
className={`absolute inset-0 h-full w-full object-contain transition-opacity duration-300 ${
viewLoaded ? "opacity-100" : "opacity-0"
}`}
diff --git a/frontend/src/pages/send/DonePage.tsx b/frontend/src/pages/send/DonePage.tsx
index d6fe572..760adaf 100644
--- a/frontend/src/pages/send/DonePage.tsx
+++ b/frontend/src/pages/send/DonePage.tsx
@@ -3,6 +3,7 @@ import { Link, useLocation, useNavigate, useParams, useSearchParams } from "reac
import SenderAtmosphere from "../../components/send/SenderAtmosphere";
import Alert from "../../components/ui/Alert";
import LoadingSpinner from "../../components/ui/LoadingSpinner";
+import { onImageError } from "../../lib/analytics";
import { ApiError, senderApi } from "../../lib/api";
import { withKey } from "../../lib/send-url";
@@ -87,6 +88,7 @@ export default function DonePage() {
src={p.thumb_url}
alt={p.filename ?? ""}
className="max-h-full max-w-full rounded-xl object-contain"
+ onError={onImageError("done-thumb", "thumb")}
/>
) : null}
diff --git a/frontend/src/pages/send/LandingPage.tsx b/frontend/src/pages/send/LandingPage.tsx
index 9404b1f..9ba761f 100644
--- a/frontend/src/pages/send/LandingPage.tsx
+++ b/frontend/src/pages/send/LandingPage.tsx
@@ -3,6 +3,7 @@ import { Link, useParams, useSearchParams } from "react-router";
import SenderAtmosphere from "../../components/send/SenderAtmosphere";
import Alert from "../../components/ui/Alert";
import LoadingSpinner from "../../components/ui/LoadingSpinner";
+import { onImageError } from "../../lib/analytics";
import { ApiError, type EmbedMode, senderApi } from "../../lib/api";
import { withKey } from "../../lib/send-url";
@@ -71,6 +72,7 @@ export default function LandingPage() {
) : (
diff --git a/frontend/src/pages/send/UploadPage.tsx b/frontend/src/pages/send/UploadPage.tsx
index 90f21ee..7426c64 100644
--- a/frontend/src/pages/send/UploadPage.tsx
+++ b/frontend/src/pages/send/UploadPage.tsx
@@ -14,6 +14,7 @@ import WatermarkDialog from "../../components/send/WatermarkDialog";
import Alert from "../../components/ui/Alert";
import Button from "../../components/ui/Button";
import Card from "../../components/ui/Card";
+import { extractError, trackClientError } from "../../lib/analytics";
import { type EmbedMode, senderApi } from "../../lib/api";
import { runConcurrent } from "../../lib/concurrency";
import { debugLog } from "../../lib/debug-log";
@@ -786,6 +787,12 @@ async function ingestFiles(
await renderThumb(meta);
} catch (err) {
ilog.dumpError(`サムネ生成失敗 (最終スイープ, ${meta.file.name})、プレビュー不可で確定`, err);
+ // 再試行しても生成できなかった確定失敗のみ計測 (iOS OOM デコード等の予兆)
+ trackClientError({
+ error_kind: "image_processing",
+ context: "preview",
+ ...extractError(err),
+ });
applyUpdate(meta.id, { previewReady: true });
}
}
diff --git a/frontend/src/pages/send/UploadingPage.tsx b/frontend/src/pages/send/UploadingPage.tsx
index 9c78a5e..bc81097 100644
--- a/frontend/src/pages/send/UploadingPage.tsx
+++ b/frontend/src/pages/send/UploadingPage.tsx
@@ -4,6 +4,7 @@ import { useNavigate, useParams, useSearchParams } from "react-router";
import SenderAtmosphere from "../../components/send/SenderAtmosphere";
import Alert from "../../components/ui/Alert";
import Button from "../../components/ui/Button";
+import { extractError, trackClientError, type UploadTarget } from "../../lib/analytics";
import { ApiError, senderApi } from "../../lib/api";
import { runConcurrent } from "../../lib/concurrency";
import { debugLog } from "../../lib/debug-log";
@@ -351,6 +352,12 @@ async function runPipeline({
`加工失敗 (name=${f.file.name}, type=${f.file.type}, size=${f.file.size}B, heic=${isHeic(f.file)})`,
err,
);
+ // API に出ないクライアント側の変換失敗 (iOS OOM デコード / canvas OOM / HEIC 変換失敗等) を計測
+ trackClientError({
+ error_kind: "image_processing",
+ context: "pipeline",
+ ...extractError(err),
+ });
updatePhase(f.id, "failed", describeError(err), "convert");
throw err;
}
@@ -439,8 +446,8 @@ async function runPipeline({
updatePhase(p.id, "uploading");
try {
await Promise.all([
- putBlob(up.upload_url, p.processedBlob),
- putBlob(up.thumb_upload_url, p.thumbBlob),
+ putBlob(up.upload_url, p.processedBlob, "original"),
+ putBlob(up.thumb_upload_url, p.thumbBlob, "thumb"),
]);
await senderApi.confirmPhoto(handle, sessionId, up.photo_id, {
thumb_size: p.thumbBlob.size,
@@ -460,14 +467,31 @@ async function runPipeline({
onDone(sessionId);
}
-async function putBlob(url: string, blob: Blob): Promise {
- const res = await fetch(url, {
- method: "PUT",
- body: blob,
- headers: { "Content-Type": "image/jpeg" },
- });
+async function putBlob(url: string, blob: Blob, target: UploadTarget): Promise {
+ // R2 への直 PUT は Workers を経由しないため、失敗は API ログに出ない。GA で計測する。
+ let res: Response;
+ try {
+ res = await fetch(url, {
+ method: "PUT",
+ body: blob,
+ headers: { "Content-Type": "image/jpeg" },
+ // モバイル回線で失敗しないまま長時間ハングするのを防ぐ (20MB 画像を考慮した猶予)
+ signal: AbortSignal.timeout(120_000),
+ });
+ } catch (err) {
+ // ネットワーク断 / CORS プリフライト失敗など (fetch 自体が reject)
+ trackClientError({ error_kind: "upload_put", context: "r2-put", target, ...extractError(err) });
+ throw err;
+ }
if (!res.ok) {
const txt = await res.text().catch(() => "");
+ // presigned URL の期限切れ(403)・署名不一致など
+ trackClientError({
+ error_kind: "upload_put",
+ context: "r2-put",
+ target,
+ http_status: res.status,
+ });
throw new Error(`PUT failed: ${res.status} ${txt.slice(0, 120)}`);
}
}
diff --git a/workers/src/cron/cleanup.ts b/workers/src/cron/cleanup.ts
index 2acc2b7..e21939c 100644
--- a/workers/src/cron/cleanup.ts
+++ b/workers/src/cron/cleanup.ts
@@ -1,4 +1,5 @@
-import { subtractStorageUsage } from "../lib/quota";
+import { logError } from "../lib/logger";
+import { subtractStorageUsageStmt } from "../lib/quota";
import type { Env } from "../types";
const ONE_HOUR = 3600;
@@ -11,12 +12,30 @@ const BATCH_SIZE = 50;
export async function runCleanup(env: Env): Promise {
const now = Math.floor(Date.now() / 1000);
- await markFailedPhotos(env, now);
- await expireSessions(env, now);
- await cleanupFailedPhotos(env);
- await cleanupExpiredPhotos(env, now);
- await pruneOldSessionLogs(env, now);
- await cleanupOrphanedSessions(env, now);
+ // ステップを個別に握って続行する。1 ステップの失敗で後続がスキップされると、
+ // 期限切れ写真が消えない等の副作用が積み上がるため。失敗があれば最後に集約 throw する。
+ const steps: [string, () => Promise][] = [
+ ["markFailedPhotos", () => markFailedPhotos(env, now)],
+ ["expireSessions", () => expireSessions(env, now)],
+ ["cleanupFailedPhotos", () => cleanupFailedPhotos(env)],
+ ["cleanupExpiredPhotos", () => cleanupExpiredPhotos(env, now)],
+ ["pruneOldSessionLogs", () => pruneOldSessionLogs(env, now)],
+ ["cleanupOrphanedSessions", () => cleanupOrphanedSessions(env, now)],
+ ];
+
+ let failedSteps = 0;
+ for (const [name, fn] of steps) {
+ try {
+ await fn();
+ } catch (err) {
+ failedSteps++;
+ logError("cron-step", err, { step: name });
+ }
+ }
+
+ if (failedSteps > 0) {
+ throw new Error(`cleanup finished with ${failedSteps}/${steps.length} failed step(s)`);
+ }
}
/** 1. pending写真のタイムアウト (1時間経過 → failed) */
@@ -50,11 +69,16 @@ async function cleanupFailedPhotos(env: Env): Promise {
.all();
for (const row of rows.results) {
- await Promise.all([
- env.R2_ORIGINALS.delete(row.r2_key_original as string),
- env.R2_THUMBS.delete(row.r2_key_thumb as string),
- ]);
- await env.DB.prepare("DELETE FROM photos WHERE id = ?").bind(row.id).run();
+ // 1 件の R2/D1 失敗でループ全体を止めない(止めると残りは次回 cron まで持ち越し)。
+ try {
+ await Promise.all([
+ env.R2_ORIGINALS.delete(row.r2_key_original as string),
+ env.R2_THUMBS.delete(row.r2_key_thumb as string),
+ ]);
+ await env.DB.prepare("DELETE FROM photos WHERE id = ?").bind(row.id).run();
+ } catch (err) {
+ logError("cron-cleanupFailedPhotos", err, { photoId: row.id });
+ }
}
}
@@ -72,14 +96,26 @@ async function cleanupExpiredPhotos(env: Env, now: number): Promise {
.all();
for (const row of rows.results) {
- await Promise.all([
- env.R2_ORIGINALS.delete(row.r2_key_original as string),
- env.R2_THUMBS.delete(row.r2_key_thumb as string),
- ]);
+ // 1 件の失敗でループ全体を止めない (cleanupFailedPhotos と同じ理由)。
+ try {
+ await Promise.all([
+ env.R2_ORIGINALS.delete(row.r2_key_original as string),
+ env.R2_THUMBS.delete(row.r2_key_thumb as string),
+ ]);
- const totalSize = (row.file_size as number) + (row.thumb_size as number);
- await subtractStorageUsage(env.DB, row.receiver_id as string, totalSize);
- await env.DB.prepare("DELETE FROM photos WHERE id = ?").bind(row.id).run();
+ const totalSize = (row.file_size as number) + (row.thumb_size as number);
+ // storage_used 減算と photos 削除は原子的に実行する (partial failure で
+ // 使用量が再減算されたり戻らなかったりする不整合を防ぐ)。
+ await env.DB.batch([
+ subtractStorageUsageStmt(env.DB, row.receiver_id as string, totalSize),
+ env.DB.prepare("DELETE FROM photos WHERE id = ?").bind(row.id),
+ ]);
+ } catch (err) {
+ logError("cron-cleanupExpiredPhotos", err, {
+ photoId: row.id,
+ receiverId: row.receiver_id,
+ });
+ }
}
}
diff --git a/workers/src/index.ts b/workers/src/index.ts
index 8e115ba..065a68d 100644
--- a/workers/src/index.ts
+++ b/workers/src/index.ts
@@ -1,7 +1,9 @@
import { swaggerUI } from "@hono/swagger-ui";
import { OpenAPIHono } from "@hono/zod-openapi";
import { cors } from "hono/cors";
+import { HTTPException } from "hono/http-exception";
import { runCleanup } from "./cron/cleanup";
+import { logError } from "./lib/logger";
import auth from "./routes/auth";
import dev from "./routes/dev";
import receiver from "./routes/receiver";
@@ -10,6 +12,20 @@ import type { Env } from "./types";
const app = new OpenAPIHono<{ Bindings: Env }>();
+// 未捕捉例外(D1/R2 例外・想定外の throw)を構造化エラーに統一しつつ Workers Logs に記録する。
+// これが無いと 500 がプレーンテキストで返り、失敗の観測もスタックトレース頼みになる。
+app.onError((err, c) => {
+ if (err instanceof HTTPException) {
+ // Hono が明示的に投げた例外はステータスを尊重。5xx のみ観測対象として記録する。
+ if (err.status >= 500) {
+ logError("http-exception", err, { method: c.req.method, path: c.req.path });
+ }
+ return err.getResponse();
+ }
+ logError("unhandled", err, { method: c.req.method, path: c.req.path });
+ return c.json({ error: { code: "INTERNAL", message: "Internal server error" } }, 500);
+});
+
app.use("*", cors());
// セキュリティヘッダ: API レスポンスにも防御を多層化
@@ -55,6 +71,14 @@ app.get("/docs", swaggerUI({ url: "/openapi.json" }));
export default {
fetch: app.fetch,
async scheduled(_event: ScheduledEvent, env: Env, _ctx: ExecutionContext) {
- await runCleanup(env);
+ try {
+ await runCleanup(env);
+ } catch (err) {
+ // runCleanup は各ステップを個別に握って続行するので、ここに来るのは
+ // 「1 つ以上のステップが失敗した」集約シグナル。再 throw で cron invocation を
+ // 失敗として Workers Logs に残す。
+ logError("cron", err);
+ throw err;
+ }
},
};
diff --git a/workers/src/lib/logger.ts b/workers/src/lib/logger.ts
new file mode 100644
index 0000000..e2efb04
--- /dev/null
+++ b/workers/src/lib/logger.ts
@@ -0,0 +1,17 @@
+/**
+ * Workers Logs 向けの構造化エラーログ。
+ *
+ * `wrangler.toml` の `[observability.logs] enabled=true` により、ここで出す `console.error`
+ * は Cloudflare のダッシュボード(Workers Logs)に記録され、後から検索・追跡できる。
+ * JSON 一行で出しておくと、フィールド(scope / path / photoId 等)での絞り込みがしやすい。
+ *
+ * 外部エラートラッキング(Sentry 等)は導入していないため、これが唯一の観測手段。
+ * コンテキスト(どのエンドポイント / どの ID で落ちたか)を必ず添えること。
+ */
+export function logError(scope: string, err: unknown, context?: Record): void {
+ const error =
+ err instanceof Error
+ ? { name: err.name, message: err.message, stack: err.stack }
+ : { message: String(err) };
+ console.error(JSON.stringify({ level: "error", scope, ...context, error }));
+}
diff --git a/workers/src/lib/quota.ts b/workers/src/lib/quota.ts
index 82bb21e..229e4fe 100644
--- a/workers/src/lib/quota.ts
+++ b/workers/src/lib/quota.ts
@@ -16,19 +16,27 @@ export async function addStorageUsage(
return (result.meta.changes ?? 0) > 0;
}
-/** クォータ減算 (削除時) */
-export async function subtractStorageUsage(
+/** クォータ減算 (削除時) の statement を返す。batch で他の書き込みと原子的に実行する用途。 */
+export function subtractStorageUsageStmt(
db: D1Database,
receiverId: string,
bytes: number,
-): Promise {
+): D1PreparedStatement {
const now = Math.floor(Date.now() / 1000);
- await db
+ return db
.prepare(
`UPDATE users
SET storage_used = MAX(0, storage_used - ?), updated_at = ?
WHERE id = ?`,
)
- .bind(bytes, now, receiverId)
- .run();
+ .bind(bytes, now, receiverId);
+}
+
+/** クォータ減算 (削除時) */
+export async function subtractStorageUsage(
+ db: D1Database,
+ receiverId: string,
+ bytes: number,
+): Promise {
+ await subtractStorageUsageStmt(db, receiverId, bytes).run();
}
diff --git a/workers/src/routes/auth.ts b/workers/src/routes/auth.ts
index 7685f82..6ae29b1 100644
--- a/workers/src/routes/auth.ts
+++ b/workers/src/routes/auth.ts
@@ -1,4 +1,5 @@
import { createRoute, OpenAPIHono, z } from "@hono/zod-openapi";
+import { logError } from "../lib/logger";
import { ErrorSchema } from "../lib/schema";
import { generateSendKey } from "../lib/send-key";
import { requireAuth } from "../middleware/auth";
@@ -379,7 +380,18 @@ auth.openapi(deleteAccountRoute, async (c) => {
Promise.allSettled([
...photos.results.map((p) => c.env.R2_ORIGINALS.delete(p.r2_key_original)),
...photos.results.map((p) => c.env.R2_THUMBS.delete(p.r2_key_thumb)),
- ]).then(() => undefined),
+ ]).then((results) => {
+ // 背景削除の失敗はレスポンスに影響しないが、孤立オブジェクトの発生源になる。
+ // サイレントにせず件数を記録して検知できるようにする。
+ const failures = results.filter((r) => r.status === "rejected");
+ if (failures.length > 0) {
+ logError("account-delete-r2", (failures[0] as PromiseRejectedResult).reason, {
+ uid,
+ failed: failures.length,
+ total: results.length,
+ });
+ }
+ }),
);
}