From 1203e0af9d5bea7dec93300866a5841358fae611 Mon Sep 17 00:00:00 2001 From: kuu13580 <13580kuu@gmail.com> Date: Mon, 6 Jul 2026 03:05:09 +0900 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20=E3=82=AF=E3=83=A9=E3=82=A4?= =?UTF-8?q?=E3=82=A2=E3=83=B3=E3=83=88=E3=82=A8=E3=83=A9=E3=83=BC=E3=81=AE?= =?UTF-8?q?GA=E8=A8=88=E6=B8=AC=E3=81=A8Workers=E3=82=A8=E3=83=A9=E3=83=BC?= =?UTF-8?q?=E8=A6=B3=E6=B8=AC=E6=80=A7=E3=82=92=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit API では補足できないクライアント側エラーと、サーバ側の未捕捉エラーを 観測できるようにする。 フロント (GA4 client_error イベント): - 画像レンダリング失敗 ( の R2 GET 失敗)、画像処理/プレビュー生成失敗、 presigned PUT 失敗、未捕捉例外/未処理reject/チャンクロード失敗を計測 - 本番のみ送信・開発は debugLog・署名URL/handle等をサニタイズ・デデュープ (lib/analytics.ts) Workers (Workers Logs): - app.onError で未捕捉例外を {error:{code:INTERNAL}} 500 に統一しログ記録 - Cron を全ステップ個別 try/catch + per-item 分離、失敗を集約 throw - アカウント削除の背景 R2 削除の失敗をログ化 (lib/logger.ts) Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/architecture.md | 1 + frontend/index.html | 6 +- frontend/src/lib/analytics.ts | 202 ++++++++++++++++++++++ frontend/src/main.tsx | 4 + frontend/src/pages/DashboardPage.tsx | 2 + frontend/src/pages/GalleryPage.tsx | 2 + frontend/src/pages/PhotoDetailPage.tsx | 3 + frontend/src/pages/send/DonePage.tsx | 2 + frontend/src/pages/send/LandingPage.tsx | 2 + frontend/src/pages/send/UploadPage.tsx | 7 + frontend/src/pages/send/UploadingPage.tsx | 38 +++- workers/src/cron/cleanup.ts | 68 ++++++-- workers/src/index.ts | 26 ++- workers/src/lib/logger.ts | 17 ++ workers/src/routes/auth.ts | 14 +- 15 files changed, 363 insertions(+), 31 deletions(-) create mode 100644 frontend/src/lib/analytics.ts create mode 100644 workers/src/lib/logger.ts 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..2606cf2 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..5ee3ad1 --- /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.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() { {receiver.display_name} ) : ( 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..3ac65f7 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,29 @@ 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" }, + }); + } 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..367bdbc 100644 --- a/workers/src/cron/cleanup.ts +++ b/workers/src/cron/cleanup.ts @@ -1,3 +1,4 @@ +import { logError } from "../lib/logger"; import { subtractStorageUsage } from "../lib/quota"; import type { Env } from "../types"; @@ -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,22 @@ 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); + await subtractStorageUsage(env.DB, row.receiver_id as string, totalSize); + await env.DB.prepare("DELETE FROM photos WHERE id = ?").bind(row.id).run(); + } 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/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, + }); + } + }), ); } From 5fb26d5a55bf136ba2a26e860d7a248c837b8504 Mon Sep 17 00:00:00 2001 From: kuu13580 <13580kuu@gmail.com> Date: Mon, 6 Jul 2026 09:26:31 +0900 Subject: [PATCH 2/2] =?UTF-8?q?fix:=20CodeRabbit=20=E6=8C=87=E6=91=98?= =?UTF-8?q?=E5=AF=BE=E5=BF=9C=20(k=E6=BC=8F=E6=B4=A9=E9=98=B2=E6=AD=A2?= =?UTF-8?q?=E3=83=BBdedupe=E3=83=BBbatch=E5=8E=9F=E5=AD=90=E5=8C=96?= =?UTF-8?q?=E3=83=BBPUT=E3=82=BF=E3=82=A4=E3=83=A0=E3=82=A2=E3=82=A6?= =?UTF-8?q?=E3=83=88)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - index.html: gtag config に page_location=origin+pathname を設定し /send/:handle?k=KEY のアクセスキーが GA4 page_view に載るのを防止 - analytics.ts: client_error の dedupe key に target/http_status を追加し upload_put の original/thumb を別カウント - cleanup.ts + quota.ts: cleanupExpiredPhotos の storage_used 減算と photos 削除を DB.batch() で原子化 (subtractStorageUsageStmt を追加) - UploadingPage.tsx: R2 直 PUT に AbortSignal.timeout(120s) を付与 Co-Authored-By: Claude Opus 4.8 (1M context) --- frontend/index.html | 6 +++--- frontend/src/lib/analytics.ts | 2 +- frontend/src/pages/send/UploadingPage.tsx | 2 ++ workers/src/cron/cleanup.ts | 10 +++++++--- workers/src/lib/quota.ts | 20 ++++++++++++++------ 5 files changed, 27 insertions(+), 13 deletions(-) diff --git a/frontend/index.html b/frontend/index.html index 2606cf2..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 index 5ee3ad1..ec63ce8 100644 --- a/frontend/src/lib/analytics.ts +++ b/frontend/src/lib/analytics.ts @@ -137,7 +137,7 @@ export function trackClientError(input: ClientErrorInput): void { if (params[k] === undefined) delete params[k]; } - const dedupeKey = `${params.error_kind}|${params.context}|${params.error_name ?? ""}|${params.error_message ?? ""}`; + 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 に出す(実機デバッグ用) diff --git a/frontend/src/pages/send/UploadingPage.tsx b/frontend/src/pages/send/UploadingPage.tsx index 3ac65f7..bc81097 100644 --- a/frontend/src/pages/send/UploadingPage.tsx +++ b/frontend/src/pages/send/UploadingPage.tsx @@ -475,6 +475,8 @@ async function putBlob(url: string, blob: Blob, target: UploadTarget): Promise { ]); 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(); + // 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, 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(); }